# Wealth Planning → the mobile app's API

## What it does

Gives the PropertyLab member app (Flutter) **the member's wealth plan**: its stored inputs, the key
results for the Home screen, the full Result page, a create / edit form, and loan eligibility
(WhoPay). There is no second backend and — this is the part that needed building — **no second
engine**: the portal has no server-side plan maths, so the API runs the portal's own JavaScript
under Node and prints what it says.

| Endpoint (under `/member-api/v1`) | Portal equivalent |
|---|---|
| `GET wealth-plan` (`?scenario=base\|stress`) | Wealth Planning → **Result** tab |
| `GET wealth-plan/form` | the canvas's Steps 1 · 2 · 3 · 5, as data |
| `POST wealth-plan` | `POST /wealth-planning` + the first autosave, in one request |
| `PUT wealth-plan` | `PUT /wealth-planning/{id}` (autosave) — merged, not replaced |
| `GET wealth-plan/eligibility` | **Loan Eligibility** tab |
| `POST wealth-plan/eligibility` `{url}` | `POST /wealth-planning/whopay-reports` |
| `DELETE wealth-plan/eligibility/{uuid}` | `DELETE /wealth-planning/whopay-reports/{id}` |
| `POST wealth-plan/eligibility/request` | the agent's **consent link** button (`FpaController::generateConsentLink`) — see *Requesting a check* below |
| `GET member/dashboard` → `wealth_plan` | (new) the key results on Home |

All need `Authorization: Bearer <member JWT>`; all sit behind `auth:api`, the group's
`throttle:60,1`, **`feature:wealth-planning`** (the gate every `wealth-planning` web route carries —
`Src\Membership\Feature::WEALTH_PLANNING`, default OPEN) and the contact-verification refusal the
mobile dashboard uses (`App\Http\Controllers\Concerns\RequiresVerifiedContact` — extracted from
`MobileDashboardController` so both refuse in the same words). Own throttle prefixes:
`wealth-plan-create` 10/min, `wealth-plan-save` 30/min, `wealth-eligibility` 6/min — a throttle's
key is the USER, not the route, so each limiter needs its own third argument. Responses are
`Cache-Control: private, no-store`. The complete contract with example JSON for every endpoint is
the Flutter engineer's copy: `propertylab-mobile/docs/app/api/wealth-plan.md`.

## How it works

### One plan per member — the app's rule, not the module's

The website keeps many plans per lead with one marked primary. **The app allows exactly one**
(product decision, 2026-09-20). Which one it shows is NOT a new rule:

- **`WealthPlan::shownFor($leadId)`** — the plan marked `is_primary`, else the latest-updated
  (`is_primary desc, updated_at desc, id desc`). This is the rule the portal already had in three
  places (the Result tab, the portal dashboard card, the mobile dashboard); it now lives once, on
  the model, and all four callers use it. `is_primary` is a radio
  (`WealthPlanRepository::markPrimary()`), so one query is the same answer as "primary, else latest".
- `plan.plan_count` and `plan.is_fallback` tell the app when the website holds more — it prints one
  quiet line ("Showing your primary plan — you have N on the web; switch it there"), no picker.
- **`POST` when a plan exists → `409 { code: "plan_exists" }`**, whether the plan was made in the
  app or on the website. The check runs inside `WealthPlanRepository::createWithState(…,
  onlyIfNone: true)` under a lock on the lead row, so a double tap cannot mint two. The repository
  stays multi-plan; the website never passes that flag.
- No delete and no "set primary" on mobile.

### The engine — the portal's JavaScript, run under Node

`Src\Wealth\Services\WealthPlanEngine` → `resources/js/utils/wealthPlan/cli/run.mjs`.

- **Why.** Every figure on the Result page is computed in the browser by
  `resources/js/utils/wealthPlan/*.js`. A PHP or Dart port would be a second engine — what this
  module's handbook already refuses for the PDF. The runner imports the UNCHANGED modules, reads
  `{ state, currentYear }` on stdin and writes `{ engine, stressAssumptions, base, stress }` on
  stdout: both scenarios in one run, so the app's Base ⇄ Stress switch never spawns again.
- **Import resolution: none needed.** The engine modules import only each other, by relative path
  with the `.js` extension, and `package.json` is `"type": "module"` — plain `node` resolves them.
  No bundler, alias map or loader. `cli/run.test.js` spawns the real binary, so an engine module
  that ever imports `@/…`, a `.vue` file or `import.meta.env` fails there, not in production.
- **The move (G-12).** Four things the Result page says lived inside `.vue` files and could not be
  imported: the readiness gates, the advisor note, the next-best-move steps
  (`PrimaryPlanDashboard.vue`) and the three bank's-view summaries (`PerformanceSummary.vue`). They
  were MOVED, word for word, to **`utils/wealthPlan/planReport.js`**; both components now import
  them. ⚠️ This is a frontend change: the live bundle keeps its own (identical) copy until the next
  `bash scripts/live-build.sh`.
- **Typed figures.** `headlineStats()` returns display strings. `cli/headlineFigures.js` returns,
  card for card, the number behind each (`value` + `unit` + `period`) — no maths, only fields
  `analysePlan()` already computed — and `run.test.js` pins every figure to its card by formatting
  it back.
- **Safety.** Array command (no shell; the plan travels on stdin) · 5 s hard timeout
  (`TIMEOUT_SECONDS`) · 256 KB input cap · stderr to the log, never the response · the plan itself
  is never logged, only its cache key · binary from `config('services.node.binary')` (`NODE_BINARY`,
  default `node`).
- **Cache.** Content-addressed: `sha1(engine fingerprint | state | calendar year)`. The
  fingerprint hashes every engine file's name, size and mtime plus the output version, so an edit
  to the plan or a deploy of new engine code is simply a new key — nothing to invalidate. Results
  live 7 days (the TTL only bounds memory); **a failure is remembered for 60 s**, so a box without
  Node does not spawn a doomed process on every open. Measured on this box (Node 20, 4 vCPU):
  ~160–210 ms per cold run, a cache hit is one Redis read.
- **Graceful failure.** Node missing, a crash, a timeout, unreadable output → `analyse()` returns
  null and the endpoint answers **200** with the stored plan, `summary: null`, `result: null`,
  `analysis_error: "unavailable"`. It never throws.

### What is analysed, and what is `omitted`

`App\Actions\Wealth\AnalyseWealthPlan` — shared by the endpoints and the dashboard block:

| Plan | `summary` / `result` | `analysis_error` | `omitted` |
|---|---|---|---|
| RRR with inputs | the engine's answer | null | see below |
| blank (`state` null) | null | null | `[]` — the app opens the create flow |
| PPP | null | null | `["ppp_result"]` — its six numbers still live in `PppPrimaryDashboard.vue` |
| engine down | null | `"unavailable"` | as far as the stored state shows |

`omitted` also carries `resign_mode` (section 05 "Resigning" and the Resign inputs are
website-only), `grants`, and `advanced_assumptions` (a bank assumption moved off its default).
**The result is never simplified to match**: a Resign Mode plan gets the website's Resign verdict
and gates; moved assumptions are honoured. Only their screens are missing.

### Writes — a second writer of one blob

- **`Src\Wealth\Support\PlanFieldSchema`** holds the canvas's labels, hints, units, ranges and
  defaults as data. Until now they existed only in Vue templates and the server validated nothing
  but "state is an array". The schema feeds `GET …/form` AND the Form Requests
  (`Api\v1\WealthPlan\StoreRequest` **extends** the portal's `StorePlanRequest`; `UpdateRequest`
  extends that), so the app hardcodes no range and the server enforces the same ones. ⚠️ The
  website's canvas does not read the schema yet — change a range in the Vue input and here together.
- **`App\Actions\Wealth\MergeMobilePlanState`** lays the app's input OVER the stored blob. The
  canvas re-sends the whole state; the app sends what changed. Everything it does not send — Resign
  inputs, grants, bank assumptions, legacy keys, and per-row keys other modules wrote (`status` /
  `actual` / `journeyUuid` from the road, `source` / `dealId` from a deal) — is kept. Properties
  are sent as the whole list; a row naming a stored `id` is that row edited, a row without one is
  new (canvas defaults), and ids are re-issued `p1..pN` by position as the canvas does. Rows get
  the canvas's hydrate rules on the way in (`backfillPropertyFields`: SPA ← MV, net ← SPA less
  rebate, hand-over ≥ purchase), so the engine never meets a half-priced row.
- **Stale writes.** The website autosaves the same blob. `PUT` accepts the `updated_at` the app
  last read and answers `409 { code: "stale" }` when the plan has moved on.
- **The five-day lock.** A trainee's Step 3 is read-only on the website until their Improve card;
  a `PUT` carrying `state.properties` under that lock is a 422 with the lock's reason.
- A new plan starts from `WealthPlanSeed::fromCards()` — the member's Define card when their road
  has one (`defaults_source: "define_card"`, incl. the ×1.5 passive target), else the canvas's
  blank defaults.

### Loan eligibility (WhoPay) and G-04

The portal's own parse and save (`WhopayAnalyzerService::analyze()` →
`WhopayReportRepository::createFromParsed()`), with a JSON 422 in place of a flash. The fetch is
synchronous, up to 30 s — the app's timeout must be ≥ 40 s.

**Privacy (gap G-04).** The member's list filters on `lead_id` alone, so a check an ADMIN pasted
after a signed consent appears in it — and its `report_url` is the WhoPay address the public
Financial Report deliberately strips. `eligibilityCard()` returns `report_url` **only when
`screening_status` is null** (the member pasted it); for the team's it is null, with
`source: "team"` and `can_view_original: false`. The figures themselves are the member's own and
are returned either way. `MobileWealthPlanTest::test_the_whopay_address_of_a_team_run_check_is_withheld`
searches every wealth response for the address.

**Team-run checks stay (owner's rulings, 2026-09-21).** `WhopayReport::isTeamRun()`
(`screening_status` not null) is the one test. A team-run check is **never deletable by the
member** — it is what marks their Financial Report as delivered on the staff side — and its
address is **never shown**, on the website AND the app: the portal's `reportCard()` withholds
`report_url`, sends `is_team_run` + `can_delete`, and `destroyWhopay()` refuses with a flash; the
API's `DELETE eligibility/{id}` answers 403 and `can_delete` is false.
`MobileWealthPlanTest::test_a_team_run_check_cannot_be_removed_by_the_member` covers both doors.

**Requesting a check (owner's ruling, 2026-09-21).** Members pasted 7 of 116 checks; the team
pulls the rest after the member signs the financial-planning consent. `POST eligibility/request`
starts THAT flow: `LeadConsentRepository::generateLink()` mints or reuses the lead's one consent
(the same link an agent sends), and `Notifier` sends **`wealth.eligibility_requested`** to the team
("pull their WhoPay report", throttled one per member per hour). Answers
`{ status: "awaiting_signature", consent_url }` — the app opens the public `/consent/{token}` page
in the browser — or `{ status: "signed", consent_url: null }` when the consent is already signed.
422 when the member has neither an email nor a phone (a consent must reach somebody). Own throttle
`wealth-eligibility-request` 3/min. ⚠️ The event is new: an existing Telegram destination receives
it only after an admin ticks it on **Manage → Notifications** (the registry's `default` pre-ticks
new destinations only — see the Notify handbook).

### The Home block

`member/dashboard` gains `wealth_plan: { has_plan, summary|null }` — additive, same
`contract_version`. `App\Actions\Wealth\BuildWealthPlanSummary` caches the verdict + figures
against the plan's id + `updated_at` (+ fingerprint + year): on a hit Home reads two narrow rows
and never selects `state`; on a miss it analyses once and remembers (a failure for 60 s). The
whole block is inside one try/catch — whatever happens, the dashboard gets `summary: null` and the
stored `portfolio` block is the fallback. The dashboard's encoder is an existing contract and
writes money as `5000`; the wealth endpoints write `5000.0`. Same numbers — a client parses `as num`.

## Deploy checklist

1. **Node at RUNTIME** on the app server, not only at build time. Under Apache `mod_php` the PATH
   is Apache's — set `NODE_BINARY=/usr/bin/node` (absolute) in `.env` if `node` is not on it.
2. `php artisan config:cache` (new `services.node.binary`) and `php artisan route:cache` (seven
   new routes) — a merge-added route 404s until the cache is rebuilt.
3. `bash scripts/live-build.sh` — the two `.vue` files now import `planReport.js`.
4. No migration, no seeder, no new permission. `proc_open` must not be in `disable_functions`.
5. Verify: `echo '{"state":{}}' | node resources/js/utils/wealthPlan/cli/run.mjs | head -c 80`,
   then `GET /member-api/v1/wealth-plan` for a member with a plan → `analysis_error: null`.

## Related files

- [app/Http/Controllers/Api/v1/MobileWealthPlanController.php](/app/Http/Controllers/Api/v1/MobileWealthPlanController.php) — extends `Main\Portal\WealthPlanningController`.
- [src/Wealth/Services/WealthPlanEngine.php](/src/Wealth/Services/WealthPlanEngine.php) — the Node bridge, cache, failure mode.
- [resources/js/utils/wealthPlan/cli/run.mjs](/resources/js/utils/wealthPlan/cli/run.mjs) · [cli/headlineFigures.js](/resources/js/utils/wealthPlan/cli/headlineFigures.js) · [cli/run.test.js](/resources/js/utils/wealthPlan/cli/run.test.js)
- [resources/js/utils/wealthPlan/planReport.js](/resources/js/utils/wealthPlan/planReport.js) — gates · advisor note · next steps · bank's view (moved out of the `.vue` files).
- [app/Actions/Wealth/AnalyseWealthPlan.php](/app/Actions/Wealth/AnalyseWealthPlan.php) · [MergeMobilePlanState.php](/app/Actions/Wealth/MergeMobilePlanState.php) · [BuildWealthPlanSummary.php](/app/Actions/Wealth/BuildWealthPlanSummary.php)
- [src/Wealth/Support/PlanFieldSchema.php](/src/Wealth/Support/PlanFieldSchema.php) — the field schema.
- [app/Http/Requests/Api/v1/WealthPlan/](/app/Http/Requests/Api/v1/WealthPlan/) — `StoreRequest` / `UpdateRequest`.
- [app/Http/Transformers/v1/MobileWealthPlanTransformer.php](/app/Http/Transformers/v1/MobileWealthPlanTransformer.php) · [WealthPlanSummaryTransformer.php](/app/Http/Transformers/v1/WealthPlanSummaryTransformer.php)
- [src/Wealth/WealthPlan.php](/src/Wealth/WealthPlan.php) `shownFor()` · [src/Wealth/Repositories/WealthPlanRepository.php](/src/Wealth/Repositories/WealthPlanRepository.php) `createWithState()`
- [app/Http/Controllers/Concerns/RequiresVerifiedContact.php](/app/Http/Controllers/Concerns/RequiresVerifiedContact.php)
- [routes/api/v1.php](/routes/api/v1.php) · [config/services.php](/config/services.php) (`node.binary`)
- [tests/Feature/Api/v1/MobileWealthPlanTest.php](/tests/Feature/Api/v1/MobileWealthPlanTest.php) — incl. the PARITY test (API vs the portal's functions called directly in a bare Node process).

## Later (not in v1)

Resign Mode inputs + section 05 · grants · advanced assumptions · PPP create and PPP result (needs
the same move out of `PppPrimaryDashboard.vue`) · curated deal presets for Step 3 · the guided
interview · AI review / second opinion · "Ask about my plan" (the server can now build the
`analysis` snapshot the browser builds today) · PDF · one shared field schema read by the canvas too.
