# Wealth Planning (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.wealth.*` · **Nav:** sidebar → **"Wealth Planning"** (standalone, above both income pillars — one plan spans both: the property portfolio AND Resign Mode's business exit) · **Gated by:** `['auth','main']`

## Two strategies, two canvases (2026-08-25)

A plan is created ON a strategy (`wealth_plans.strategy`, chosen in the plan-list modal, never
changed after): **RRR — Rent, Recover, Repeat (楼叠楼)**, the original six-step canvas
(`Edit.vue`), or **PPP — Property Pays Property (楼养楼)**, its own four-step canvas
(`PppEdit.vue` + `Partials/Ppp/*`). They are separate pages on purpose — the two disagree about
everything a canvas encodes: property count (open list vs a fixed pair), binding constraint
(DSR vs cash flow), and success (goal-by-age vs IRR on cash in). One canvas serving both would
branch at every step.

PPP's state blob keeps the RRR shape (`profile`, `properties[]`) plus a `ppp` block
(`outgoingsPct`, `sweep`), so autosave, `summarize()` and the plan list need no special case.
Its two rows carry `role: 'engine' | 'growth'` (`pppLegs()` falls back to position). The math is
`utils/wealthPlan/ppp.js` (`analysePpp`) — a month-loop over ONE plan account: rents in, bills
out (engine purchase at day zero via `propertyCashSchedule`, staged differential during
construction, fees at hand-over, instalment + full-sweep prepayment after), and the member
injects cash only when the account would go negative. `totalCashIn` IS the sum of injections;
`coverage` (Phase-2 rent vs bills, incl. `engineNeeded` — the engine price the rule demands),
`selfFunding` (no injection after hand-over), `payoffYear`, `cashOnCash` and `irr` (bisection)
all derive from it. Two traps its tests killed on day one: the engine's own purchase was
missing from the bills (totalCashIn = buffer only), and the IRR series was read off monthly
rows, which start at month 1 — dropping the day-zero outlay and returning ~59%.

PPP is a **full six-step canvas** (2026-08-25, "PPP is over simplified"): You (StepYou,
`showCareer: false` — Resign Mode is a control PPP's math never reads) → Goal (the shared
StepGoal; PPP's `buildState` hydrates the saved goal) → The pair (`PppStepPair` + `PppLegCard`)
→ Projection (`PppStepProjection`: the standard three-chart row + `PppBreakdownTable`, the plan
account's full statement with a **day-zero "Start" row** — without it the engine purchase, the
statement's biggest line, is invisible) → Loan check (`PppStepLoan`: the shared `StepLoanCheck`
— now takes a `consumerNote` prop — plus the growth-instalment-vs-headroom verdict and the
**seasoning-timing card**: an engine bought the same year the growth leg is signed contributes
NOTHING to that application) → Reality (`PppStepReality` = phases + embedded `PppStepResult`
with a goal verdict). `PppGuidedInterview` is PPP's own typeform capture (engine → growth, with
strategy-rule suggestion chips: 6% yield, price/0.83); the RRR interview is career-shaped and
never mounted here. The Index Result tab renders `PppSummaryCard` for a PPP primary plan —
`PrimaryPlanDashboard` runs `analysePlan()` and reads a PPP state confidently wrong.
`summarize()` counts only PRICED rows, or every blank PPP plan is born saying "2 properties".

**The guided report + the AI coach (2026-08-26).** After the PPP interview, the member lands in
`PppReportWalkthrough` — the result told the way the interview asked: one insight per full-screen
slide (verdict → coverage → phases → projection → outcome → done), tables behind a
"Show me the numbers" expander, slide dots, and the SAME design language as the interviews
(both now share the RRR interview's light shell — white layer over content only via
`lg:left-72`, brand wash, 1px progress bar, All plans / Back / X top bar). It is a READER:
slides mount the same step components the canvas uses, so story and workbench cannot disagree.
Re-enter via the header's "Guided report" pill; closing lands on canvas step 4.

The **AI coach** (`AskPlan`, floating variant) is now permanent: on every RRR canvas step, every
PPP canvas step, and every walkthrough slide — same component and per-plan thread everywhere, so
a conversation follows the member. Context mappers: `pppAiContext()` in `ppp.js` (ONE mapper for
canvas + walkthrough) and a compact inline RRR mapper in `Edit.vue`; both send numbers, never
markup, and stamp `member_is_viewing` with the step/slide on screen. Each walkthrough slide sets
a slide-tuned coach question via the `explain` offer flow. ⚠️ AskPlan's floating launcher
teleports to body at `z-40` — under any full-screen `z-50` layer; hosts that ARE such a layer
pass `z-class="z-[60]"` (new prop) or the coach is invisible exactly where it is most needed.

### PPP canvas: what the member can change, and what "year 20" means (2026-09-03)

**Step 3 leads with WHICH pair — in one collapsed line.** The preset was chosen once, inside the
guided setup, and the canvas could neither show it nor change it — so a plan created before the
presets existed could not reach them at all, and step 3 named neither building. `PppStepPair` opens
with the chooser (`data-preset-chooser`), and switching rewrites both legs through `applyPreset`.
**Collapsed by default since 2026-09-03** (founder): it is a setup question asked once, and it was
taking three option cards, a paragraph and the yield row above a single price. What survives closed
is the ANSWER — the line under the title names the pair and which end of the 7–9% range is on screen
— so the prices below still never come from nowhere; the button says 换一对, not that something is
hidden. Grade A carries its
7–9% range as a control on the canvas too: which end of a projected range is on screen has to be the
member's answer, not ours. `PppLegCard` prints the building's name, location, size and ringgit per
square foot whenever a preset filled them (`data-leg-identity`).

**Step 4 is three tabs, not four.** 现金流 and 逐年明细 were separate for one day; the table IS the
chart's arithmetic, and a reader's evidence one tab away from the claim it supports gets checked once
and never again. `PppStepProjection`'s `sections` prop now receives `['charts', 'explainer',
'breakdown']` for that tab.

**The horizon is explained and editable** (`data-horizon`, 10 / 15 / 20 / 25 / 30). It was hardcoded at
20 and never justified, so every headline read "by year 20" with nothing saying what year 20 was — and
`PppStepResult` told members to "extend the horizon to see it clear" while offering no control anywhere.
It is not a sale date and not a deadline: it is how far the projection runs, and the copy says so.

### PPP: the stress test, and where the two properties come from (2026-09-03)

**Stress lives in the RESULT, not in a corner of the canvas.** `utils/wealthPlan/pppScenarios.js`
runs the plan three times — as planned, a slower market, and it goes wrong — and
`PppStressTable.vue` puts them side by side on the Result tab AND on step 4's 结论 tab. It costs
almost nothing because `analysePpp` is a pure function of a plain object: a scenario is a deep
clone with a few numbers moved. No second engine, so the stressed figures cannot drift from the
base ones. What the stress case does NOT move is the price already paid — a member who bought at
RM 1,000,000 still paid it when the market falls to RM 900,000, and the plan has to show that as
the loss it is rather than quietly re-pricing the purchase. The founder's FX leg is deliberately
absent: every calculation is in ringgit by design, so an FX shock has nothing to act on until the
exit model exists, and a label for it now would be theatre.

**The setup opens with a pair, not a blank price field.** Watching the founder use it: he typed
RM 1,000,000 against RM 1,000 of rent — a 1.2% yield, a property that cannot carry a build — and
the plan accepted it without a word. `utils/wealthPlan/pppPresets.js` offers three paths:

- **Grade A** — real projects at real prices, supplied by the founder: Viia Residence, Mid Valley
  (SPA RM 1,290,000 / net RM 970,000 / valuation RM 1,170,000 / 782 sqft, projected 7–9%) and
  Binastra Cochrane, Cochrane TRX (SPA RM 1,000,000 / net RM 900,000 / valuation RM 900,000 /
  RM 5,000 rent / 763 sqft). These are DATA and change when the deal changes.
- **Grade C** — deliberately synthetic, and it must NEVER carry a real project's name. Naming a
  real building as the bad example would be defamatory and untrue: a building is not a grade, a
  PRICE is. Its two faults are the two that actually sink a plan — paid above what it is worth,
  yielding 2–3%.
- **Custom** — the member's own numbers, which is what the interview asked for before.

Choosing a preset skips every price question it already answered (`when: () => preset ===
'custom'`), but NOT the leverage question: how much the bank lends depends on the member, not on
the building. Grade A adds one question instead — 7%, 8% or 9% — because a projected return is a
RANGE, and picking the top of it on the member's behalf would be us making their optimistic case
for them. `applyPreset` writes only the fields a preset owns; rate, tenure, loan amount and buy
year stay whatever the member's own answers made them.

The grade rides on the leg (`leg.grade`) and prints as a badge on the card beside the building's
name, size and ringgit per square foot.

### PPP: what a member types vs what it is worth (2026-09-03)

**Every leg now carries THREE prices, because `costs.js` always assumed three.** PPP shipped with
one input per leg bound to `mv`, so `netPrice` fell back to it and the two could never disagree.
Consequences, all found by an audit against the founder's own reading of step 3 (*"why seems like
no more consider market value ? what if overprice ?"*):

- **Day-one equity was structurally zero on property 1** and equal to the rebate on property 2.
- **Overpaying made the plan look BETTER.** The wealth chart seeds from `engine.mv`, so paying
  RM 167,000 over the market grew that overpayment at 3% a year and reported a bigger portfolio
  for it. Property 1 is a CASH purchase — no bank orders a valuation, so nothing in the
  transaction catches it. The plan rewarded the mistake.
- **The margin-of-finance cap was computed off the SPA**, which is the one number a rebate
  structure inflates. A marked-up SPA with a fat rebate produced a loan no bank would write and
  reported `bufferNeeded: 0` for a plan needing a six-figure cash call *during construction*.

Now: `netPrice` is the cash paid, `spaPrice` the contract, `mv` an independent valuation — the
comparable for property 1, the bank's expected valuation for property 2 (defaulted to the price
after rebate, which is the conservative Malaysian convention). `costs.js` gained
**`dayOneEquitySigned`**; the existing `dayOnePaperEquity` stays clamped at zero because RRR
renders it as a green badge, and a clamped figure turns "you overpaid" into a badge that simply
does not appear. Nobody notices a chip that was never there. Saved plans are **backfilled on
hydrate** in `PppEdit.buildState()` rather than migrated, so every existing plan reads exactly as
it does today until the member fills in a real comparable.

### Three readings a member could not resolve (2026-09-03)

All three were the same fault — a number stated without the thing that makes it mean something:

- **"Income then"** — then *when*? It is the income once the loan is gone, so the label now names
  that year (`Income from year 9`), carries the monthly equivalent, and says what still comes off it
  when the loan is NOT cleared by the horizon (`PppStepResult`).
- **"+RM 7,155 · Left over after the repayment"** — the answer without its arithmetic. The note now
  prints the subtraction from the plan's own row: *both rents RM 9,435 − the repayment RM 2,280*.
- **"Return over 20 years" vs "IRR over 20 years"** — one number, two names, on two screens a member
  reads in one sitting. Both are now `Return over N years (IRR)` with the same plain explanation
  ("one yearly rate that balances your money in against everything it gives back"), and the stress
  table's column is `Return (IRR)`.

Pinned in `pppPrimaryDashboard.test.js`. The rule they share: **a figure carries the moment it is
true at, and a derived figure carries the sum it came from.**

### One Result tab, whatever strategy the plan is (2026-09-03)

**`Partials/PlanReport.vue` is the report SHELL, and both dashboards render inside it.** The founder
read the two Result tabs side by side — RRR rendered a research note (masthead, verdict rating,
numbered sections you switch between, an AI drawer, Download PDF, a next-steps rail) and PPP rendered
one card of tiles — and settled it: *"the RRR one should be a standard template."*

The shell owns what is the same question on every plan: who this is for, the one-line verdict as a
rating chip, the numbered section nav (one section on screen, ALL of them when printing), Download
PDF (the browser's own print — every figure is computed in the browser, so a server-rendered PDF
would need a second copy of the engine), and the assistant drawer. Each dashboard passes
`sections`, a `verdict`, the masthead lines and its own `#section-{id}` bodies.

It owns no numbers, because the strategies disagree about what a plan means. Both report the same
four stops: **Summary & next step · What you're buying · Income & checks · The bank's view** — and
answer them from their own engine (`analysePlan()` for RRR, `analysePpp()` for PPP; the RRR one still
reads a PPP state confidently wrong, which is why there are two dashboards and not one).

PPP's bodies: **01** the goal verdict and the six numbers with a Base / Stress switch driven by
`pppScenarios` (the same three runs the stress table shows, computed once); **02** the two legs with
what was PAID against what it is WORTH; **03** how it pays for itself — the phases as one bar, the
build as a three-line subtraction, the after-keys coverage sentence and the stress table; **04** the
bank's view, the ONE loan this strategy applies for.

**A report ANSWERS; the canvas shows its working (2026-09-03).** For one afternoon 03 also mounted
`PppStepProjection`'s charts, assumptions and 20-year statement and a fourth stop mounted
`PppStepPhases`'s whole build walkthrough. The founder read that and said *"now too many info"* — a
reader who has to scroll a workbench to reach a conclusion has been handed the workbench. Each
section now states its answer with the least evidence that supports it and links into the canvas
step where the arithmetic lives. The build walkthrough's four steps became `buildLedger`: bills over
the construction years, minus the rent over the same years, equals what the member tops up.

**A foreigner gets three stops, not four.** No Malaysian DSR can be run on income earned elsewhere,
so "The bank's view" would be a heading a reader pays for with a click and gets "we cannot check
this" back. What the passport really changes — the margin commonly offered and the tenure ceiling —
is a fact about property 2, so it prints on property 2's card (`data-leg-passport`). Both halves are
pinned in `pppPrimaryDashboard.test.js`, including that the 20-year statement has not crept back.

Pinned by [planReportTemplate.test.js](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/planReportTemplate.test.js),
which mounts BOTH dashboards and asserts the shell: the rating chip, a numbered nav starting at `01
exec`, that clicking a stop actually SWITCHES which section is displayed, AI Assistance + Download
PDF, and a named masthead. What each section SAYS stays each dashboard's own test.

### PPP step 4 is sub-tabbed; the Result tab answers instead of summarising (2026-09-03)

**`PppStepFour.vue`** replaced `PppStepOutcome`. Step 4 was one scroll too long even for a
Malaysian, and a foreigner's version stacks three screens. Four tabs, in the order a buyer asks:
结论 → 现金流 → 建筑期 (foreigner only) → 逐年明细. It hosts `ShowTabs` (`param-name="view"`,
`variant="pills"`) so only the active tab mounts — the three chart canvases are no longer all
built on every visit — and the choice syncs to `?view=`. `PppStepProjection` gained a **`sections`**
prop so the host can ask for one block at a time; rendering everything stays the default, so the
guided report is unchanged.

**`PppPrimaryDashboard.vue`** replaced `PppSummaryCard` on the plans page Result tab (*"now PPP is
over simplified"*), and on the same day moved inside the shared `PlanReport` shell (above). Five grey tiles became: the goal verdict, **exactly six numbers**, the plan's
life as one bar, the coverage answer after hand-over, and a "fix these first" list that only fires
on real contradictions. Six is a ceiling — a dashboard showing twelve is a spreadsheet. The
monthly figure is quoted at a NAMED MOMENT (the first month after the keys), never averaged: in
PPP that number is deeply negative during the build and strongly positive after, so an average is
true in no month of the plan.

### Two engine corrections worth knowing (2026-09-03)

- **`irr` counted the bank's money.** The terminal flow was the pair's gross market value.
  `portfolioEquity` and `debtAtHorizon` are now returned and the IRR uses equity. Every test ran a
  20-year horizon, where the sweep has already cleared the loan and the two are identical — so the
  bug was invisible for the life of the file and appeared exactly at the 10 years an investor asks
  about, worth about 1.7 percentage points.
- **The sweep is a choice.** `ppp.sweepPct` (0–100) replaced the `sweep` boolean, which is still
  read as the default so no saved plan changes behaviour. It is the share of each month's SURPLUS
  — rent after the instalment — not of the running account: sweeping a percentage of the account
  would keep re-taxing money the member already decided to keep, and a 50% choice would drain to
  zero instead of splitting the rent in half. Asked in the guided setup, controlled on step 3.

### Which strategy a FOREIGNER gets — the rule (2026-09-03)

**A member buying from outside Malaysia builds PPP. It is a recommendation in the UI and a
constraint in the maths, not a preference.** RRR's engine is a Malaysian bank re-approving the
member between properties, and that approval is computed from Malaysian income against a
Malaysian DSR line. A salary paid in Hong Kong cannot be run through that check, so the loop
the strategy is named after never turns once. PPP asks no bank to approve anyone's income:
property 1 is bought with cash, and property 2 is carried by property 1's rent. The passport
changes the LOAN TERMS on property 2; it never changes whether the plan works.

Said in three places on the chooser (`Partials/StrategyChooser.vue`, pinned by
`strategyChooser.test.js`): a banner above both cards, a badge on each card, and the reason inside
each card. Saying it only on the PPP card would leave a foreigner reading the RRR card with nothing
to warn them.

### Every step screen is an `@container` (2026-09-03)

`StepYou` · `StepGoal` · `StepProperties` · `PropertyForm` · `StepLoanCheck` · `StepReality` ·
`StrategyChooser` carry `@container` on their own root and lay themselves out with `@`-variants
instead of viewport breakpoints (`sm`→`@2xl`, `lg`→`@3xl`/`@4xl`, `xl`→`@4xl` — the mapping that
keeps today's canvas, since main ≈ viewport − sidebar − padding).

Why their own container rather than AppShell's: **the same screen is mounted at four different
widths**, and two of them are teleported to `body`, where AppShell's `@container` is not an ancestor
at all — the guided interview and the report walkthrough. The third is the New-plan modal (also
teleported), and the fourth is the Learning Hub guide's ~500px preview frame. Viewport breakpoints
described none of them: on a 1600px screen the preview fired every `lg:` rule and crushed Step 2's
two-field row into four 100px columns of wrapped labels (founder, 2026-09-03).

⚠️ The components that still rely on an ancestor container (`PppStepPair`, `PppStepProjection`,
`PppStepPhases`, `PppLegCard`) have the same latent issue inside the walkthrough. Not touched here.

### The chooser is its own component now (2026-09-03)

`StrategyChooser.vue` was the body of `Index.vue`'s New-plan modal. It moved out so the Learning Hub
guide could mount the REAL chooser — a modal cannot be opened with props, and its rule is that a
lesson may only show a screen the product actually has
([portal-guide](/docs/modules_handbook/main/portal-guide/readMe.md)). `Index.vue` still owns opening,
closing and the `router.post`; the component owns the two cards and emits `choose`. Nothing about the
screen changed, and `strategyChooser.test.js` now reads the new file.

**Every step component carries `data-guide` anchors.** They are inert attributes the guide's
spotlight holds on to, and `portalGuide.test.js` mounts each step and fails when one stops resolving
— so renaming a block here breaks a test rather than quietly turning a lesson into "look at the thing
that is no longer there". The whole canvas is taught, both strategies: eleven chapters under
Learning Hub → How to Use PropertyLab → Wealth Planning.

**Residency lives on `state.profile.isForeigner`, and it is editable on the canvas** —
`StepYou`'s "Where you are buying from" card, behind `showResidency` (PPP passes it; the RRR
canvas must NOT, its math never reads residency and a switch wired to nothing teaches members
that switches here are decorative). It was written only by the guided interview until
2026-09-03, which meant a mis-click could not be corrected and an inherited plan could not be
read. What the answer changes:

| | Citizen / PR | Foreigner |
|---|---|---|
| Margin offered on property 2 | up to 90% | commonly 50%, sometimes 70% |
| Tenure ceiling | 35 years | 30 years |
| Steps shown | all six | 1 to 4 |
| Second currency | not offered | HKD / USD / CNY, member-editable rate |

**Tenure is one rule in one file** — `utils/wealthPlan/tenure.js`, `maxTenure(isForeigner, age)`.
Two limits and the shorter wins: the product ceiling above, and age 70, by which the loan must be
settled. A 50-year-old gets 20 years whoever they are, and the loan card prints the subtraction
rather than an unexplained number. The interview had its own copy until 2026-09-03, so the canvas
could be set to a tenure the interview would have refused; `PppEdit` now re-applies the rule on
every change to residency or age, shortening only — raising it would be us choosing a longer loan
than the member asked for.

**Steps 5 and 6 are hidden for a foreigner, but step 6's CONTENT is not lost.** Step 5 is the loan
check, which needs local income; that is the whole reason for hiding. Step 6 is not a loan check —
it answers whether property 1's rent covers property 2's bills, and what the member ends up
holding — so it moves into step 4 via `PppStepOutcome.vue`. Hiding it outright left the one reader
with no local banker to reassure them holding charts and no verdict. `PppStepResult` takes
`hideGoalVerdict` there, because the projection above it already answers the goal and the same
sentence twice reads as two findings.

**What the model still does NOT know about a foreign buyer** (open, 2026-09-03): non-resident
rental income is taxed at a flat 30% with no reliefs, and the engine deducts only `outgoingsPct`;
stamp duty for non-citizens is a flat 8% from 1 January 2026 against a citizen's tiered 1–4%, and
entry costs are not modelled at all; RPGT for a non-citizen is 30% for five years then 10% forever,
so any "value in year N" is pre-tax; and the foreigner minimum purchase price varies by state,
which can make a plan unapprovable rather than merely expensive. Each one makes the current
verdict optimistic for a foreigner. Do not quietly assume they are handled.

## What it does
A member's private property-portfolio planner. Each member keeps multiple **Wealth Plans**, edited in
a **6-step canvas** (You → Goal → Properties → Projection → Loan check → Reality) that is **autosaved**
as you type. Since 2026-08-27 the canvas also says **where it sits on the member's DMAIC road** —
once, above the strip: *"This is the Define stage of your road…"* — and each open step gets a one-line
hint saying what the step is FOR (the Loan-check hint names all THREE dials that close a gap: buy
fewer/cheaper · move the goal later · raise income — "adjust the properties in Step 3" had been the
only advice on offer). Nothing about the canvas changed; six titled cards read as a *form*, so a red
Projection looked like the member's mistake rather than the check doing its job.
> ⚠️ **The DMAIC letters name the JOURNEY, never the steps.** A first draft the same day labelled
> the six steps D/M/A/I/C individually (You+Goal = D … Loan check = I) and was reverted before
> shipping: the course's own D unit is "目标 + DSR", so a member who learnt *DSR is Define* must never
> open this page and find Loan check labelled *Improve*. One set of letters, one meaning. Wealth
> Planning is ALL of Define (loan check included); Analyze Property is Measure·Analyze·Improve;
> Landlord Management is Control. All of the planning math — amortisation, cashflow projection, Malaysian capital costs,
and the sequential bank-rules simulator — runs **client-side**; the backend only persists the wizard
state and derives a few headline figures for the list page. The module also stores reusable **WhoPay
CCRIS loan-eligibility reports** (parsed from a pasted WhoPay report URL) that the canvas reads for
income/DSR inputs.

The canvas also carries **Resign Mode** (Step 1 → Career path): for a member planning the
employee → property-owner → entrepreneur pathway, it enforces the one non-negotiable rule — **buy
every property BEFORE resigning**, because banks lend to the payslip and want ~2 years of business
financials after it stops. Turning it on adds the entrepreneur inputs (resign age, living expenses,
liquid savings, business income — default 0, conservative — and a rent haircut), draws a **resign
line** on every chart (OPM Window ⇄ Entrepreneur Zone), warns per-property in Step 3 when a purchase
falls outside the loan window, replaces Step 4's single verdict with a **Resign Readiness panel**
(4 checks at the resign year + the earliest safe resign age when the chosen one fails), and makes the
Step 6 simulator end the payslip — auto-rejecting post-resign purchases with their own reason.
`stay_employed` (the default) leaves every existing plan byte-identical in behaviour, and a third
mode — `own_business`, for a member who is **already** self-employed — is maths-identical to
`stay_employed` (every math module gates strictly on `resign_to_entrepreneur`): it exists so the
income question can talk provable business income instead of a payslip, never to change a number.

Capture happens in the **Guided Interview** — a Typeform-style flow that takes over the content
area (the app sidebar stays visible on desktop; one question per screen, chip answers, Enter to
advance, slide transitions): a **blank plan drops straight into it**;
an existing plan re-runs it from the "✨ Guided setup" pill. It confirms what the platform already
knows with one tap (name + DOB-age from the profile, income from the latest WhoPay report — the
controller's `prefill` prop) and **proposes** the rest (living expenses at 45/60/75% of take-home,
resign age via "fire the boss in 3/5/7/10 years" chips, business income options led by the
recommended RM 0), so a Resign-Mode plan is ~11 screens of mostly taps. Accepting the WhoPay
salary also imports that report's **commitments** in the same tap (only over fields still 0/unset).
On the closing summary an **AI second opinion** (one `AiClient` call, resign path only) may propose
refinements to living expenses / business income / rent haircut — accept/dismiss chips the member
must explicitly tap; nothing auto-applies, and a failed call leaves no trace. Detail questions that
made members give up are **predefined and demoted, not asked**: `bizGrowth` (0%) and `rentHaircut`
(10%) live behind Step 1's "Advanced assumptions" disclosure, the Step-5 bank assumptions (DSR cap,
seasoning, MoF, growth/recognition rates) behind its "Advanced — bank assumptions" disclosure, and
`dependents` is gone from the ask entirely (no math consumes it; the key stays in `defaultProfile()`
so old blobs round-trip). **Funding events** (`state.grants`,
`[{label, amount, year}]`) model Cradle-style grants: a one-tap preset (RM100k at resign +
RM500k two years later, editable in Step 1), with only `GRANT_SALARY_ALLOC` (20%) of each tranche
counting toward living costs in the year it lands — a grant is business runway, never personal
income, and never DSR-recognised. Tranches show as gold markers on the resign-line charts.

The landing page is a **verdict-first Result dashboard**, not a card grid: one page, three
questions, three tabs (`ShowTabs`, `?tab=`-synced) — **Result** (where am I vs my goal — the
default), **My Plans** (the card grid), **Loan Eligibility** (the saved WhoPay checks). Result
renders the member's **primary plan** (`is_primary` — one per lead, radio semantics; a lead's
first plan is auto-marked, and with none marked the latest-updated plan renders flagged with a
one-tap "Set as primary") through `PrimaryPlanDashboard.vue`.

### What you're buying · cashflow · payoff (2026-08-26)

The result page used to describe the portfolio only in AGGREGATE ("3 properties · RM 1.8m of
loans"), which the user reported as two gaps: it "didn't mention what property he purchase",
and it "didn't highlight the impact of positive cashflow property which helps to finish the
loan much earlier". Three changes close them:

- **Properties have names.** `nextPropertyDefaults` gained `name` / `location`, `PropertyForm`
  gained an optional name input, and — the part that was actually broken —
  `applyPresetToProperty` now copies the deal's **name and location**, not only its figures.
  The link to a curated deal (`dealId`) had existed all along but nothing ever resolved it
  back, so a property the member picked BY NAME from the deal browser became "Property 2" the
  moment it was chosen. Blank names still render as `Property N`, so old plans read exactly as
  they did.
- **A new section, `PropertyOutcomes.vue`, sits SECOND** (right after the executive summary):
  every number below it is a consequence of these properties, so a reader who has not seen
  them is reading conclusions about nothing. One card per property — name, buy age, hand-over
  age when it differs, value / loan / rent / instalment, and the monthly `rent − instalment`.
- **The mechanic is finally stated.** `propertyOutcome.js` derives, per property, whether it
  covers its own instalment and how much earlier its loan ends because the surplus is recycled
  into principal. Two honesty rules are baked in and pinned by `propertyOutcome.test.js`:
  cashflow is read at the FIRST FULL MONTH after hand-over (an average over the horizon blends
  in grown rent and flatters a deal that starts underwater), and "years earlier" compares
  against the loan's natural end (hand-over + tenure) with the counterfactual run over a window
  long enough to contain BOTH payoffs — comparing a truncated baseline against a completed one
  would invent savings that are really just an unfinished loan. `outcomesSummary` never nets a
  shortfall against a surplus, and headlines the BEST single acceleration rather than a sum of
  years.
- `analysePlan()` now returns `schedules`, `outcomes` and `outcomeSummary`, so this stays "one
  plan, analysed once" — no component re-derives a property result.

> 🐞 **Engine bug found doing this, fixed at the root.** `buildPropertySchedule` marked a loan
> paid off with `bal === 0`, but amortising to full term leaves a floating-point residual
> (~1.5e-8 on a 400k/30y loan), so `paidOffMonth` stayed **null forever** on any loan that ran
> its natural term. That also fed the `payoff` goal verdict, which counted a fully-settled loan
> as "still owing". Balances under half a sen now settle (`projection.js`).

### Plan vs actual (2026-08-28)

A plan is a forecast until the member actually buys. Since Phase 2 of
[DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md) the road's **C · Control card** — where the
member records the SPA month, the VP month, the month a tenant moved in, the rent they actually get and
the bank's valuation — **writes those facts back into the plan the road's Define hand-off created**, and
the canvas shows them beside what it planned.

**What the road writes.** `Src\Journey\Support\WealthPlanSync::mirror()` touches ONE row,
`state.properties[0]` (the row `WealthPlanSeed` created from the Measure card — the road's one purchase),
and writes exactly three keys, through `WealthPlanRepository::update()` so the summary columns recompute,
inside the card's own transaction under `lockForUpdate()`:

| key | value |
|---|---|
| `properties[0].status` | `planned` \| `signed` \| `handed_over` \| `rented` — the journey status as a word, derived exactly like `LearningJourney::statusFromControlPayload()` |
| `properties[0].actual` | `{ spa_at, vp_at, rented_at, actual_rent, bank_valuation }` — every key present; `null` is 我还不知道, never a zero |
| `properties[0].completionYear` | ONLY when `vp_at` is known: the whole-year offset from now to the real hand-over month, never earlier than `buyYear` |

`completionYear` is the only EXISTING key it touches, and on purpose: the projection engine already
starts the rent clock from it, so an actual hand-over date is the fact the seed's "~3 years" was guessing
at. **Nothing else in the blob changes** — price, loan, rent assumptions and every other row stay the
member's. The write is skipped, never failed, when there is nothing honest to say (no pinned plan, a plan
that is no longer the member's or is soft-deleted, no property row, or a mirror that changes nothing —
`WealthPlanSync::equivalent()` compares recursively key-sorted, because MySQL re-orders a JSON object's
keys and a strict `===` called every identical re-save "changed").

**What the canvas does with it.**

- **`propertyHelpers.js` owns the vocabulary**: `PROPERTY_STATUS_*` + `PROPERTY_STATUSES` (word, Chinese,
  badge tone — kept in step with `WealthPlanSync::STATUSES`), `ACTUAL_FIELDS`, `propertyStatusOf()`
  (the word if it is one of the four, else read off the `actual` dates), `propertyReached()` (statuses are
  cumulative), `propertyStatusBadges()` and `propertyStatusSummary()`. `nextPropertyDefaults()` gives a
  hand-added row `status: 'planned'` + `actual: null`, and `backfillPropertyFields()` **keeps** whatever
  the sync wrote and only coerces what it did not — a plan saved before the field existed has no `status`
  key at all, which is how the dashboard knows to stay quiet rather than print three zeros about nothing.
- **Step 3** (`StepProperties.vue`): the property tab carries ONE word (the furthest milestone), and the
  card carries the whole story — a `计划 vs 实际` panel with every milestone it has reached
  (`已签约 2026-09` brand · `已交楼` indigo · `已出租 RM 2,300` emerald), the plan-beside-actual lines
  (rent, valuation, the hand-over month that moved `completionYear`) **only where BOTH sides are known**,
  and a link back to the C card: 「改那张卡，这里跟着变」. It is read-only here — the facts are edited on
  the card, once.
- **The Result dashboard** (`PrimaryPlanDashboard.vue`) prints one line in the identity header —
  `计划 N 间 · 已签约 x · 已出租 y` — and only when some row actually carries a status.
- **The engine reads none of it.** `status` / `actual` are display; a row with them projects exactly as
  one without, because the only number that moved is `completionYear`, which the engine already used.

### Keeping a copy — "Download PDF" (2026-08-26)

The button is the **browser's own print-to-PDF** (`window.print()` + an `@media print` block in
`app.css`), and that is a decision, not a shortcut. Every figure on this page is computed in the
browser from the plan's `state`; a server-rendered PDF would need a second copy of the whole
projection engine, and the day the two drifted the member would be holding a document that
disagreed with their screen. Printing what is on screen cannot drift. A canvas-scraper was also
ruled out on a harder point: Tailwind v4 emits `oklch()` colours, which **html2canvas cannot
parse**.

What the print rules do: hide the app chrome (`print-hide` on AppShell's sidebar + sticky
header, so no page prints its own navigation), drop the sidebar gutter, keep background colours
(green = rent covers the loan is *meaning*, not decoration), keep sections whole across sheets,
and swap two things for print-only equivalents — a line naming the scenario (a printed stress
test that looks like the base plan is a lie the reader cannot detect) and the **call to action
referring the member to the PropertyLab Professional team** at a Property Deal Day, because on
paper the "Book my Property Deal Day" button is dead ink. `document.title` is swapped during the
print so the saved file is named after the member's plan.

⚠️ **That component follows the user's approved HTML mockup 1:1** (2026-08-25, explicit
"follow the mockup 100%" instruction — it REPLACED an earlier progressive-disclosure
/ accordion design that had reshaped the mockup; do not reintroduce that). Its layout, in
order: a **topbar** (eyebrow · member name · target/resign/updated subline, with the verdict
chip on the right) → the **Base plan ⇄ Stress test** segmented switch with its always-visible
hint (Stress is the SAME engine re-run at rates +2pp, rent −15%, business income −40%, never
hand-tuned numbers) → the **lifetime income hero**, a stacked-area chart (salary slate / net
rental green / business amber) carrying the OPM-window tint, zone captions, the floating
`RESIGN {age}` pill and numbered green "Buy · age" markers on the baseline → a **three-column
grid**: the **four readiness gates** ("the four gates of the AI APP method") beside the
navy **"Next best move"** rail (3 funnel steps → the Deal Day button via `DealDayModal` →
`POST /deal-day`, which reuses `consultation_requests` with
`landing_url='/wealth-planning#deal-day'` as the source marker), the **PropertyLab AI Advisor**
note (deterministic — derived from the failing gate or the base-vs-stress delta, NOT an AI
call) and the disclaimer. Reading order was rebuilt 2026-08-25 for a first-time member: verdict chip → **"Where you
stand"** (the four headline numbers, ABOVE the chart — they are the answers; the chart is the
evidence) → the income chart (RM per MONTH, width-capped) → the grid. Every section carries an
**Explain** button (`ExplainButton.vue`): tapping it does NOT fire a question, it hands the
assistant a section and the assistant OFFERS ("Want me to explain X?" Yes / No), so a stray tap
never spends a call or floods the panel. Below the grid sit two further sections (2026-08-25, on the user's
"why only show income view"): **"When you can actually resign"** — `ResignRequirementChart`,
the monthly outgoings-vs-income lines behind the 12-month rule for every candidate age — and
**"Performance summary"**, three cards covering the canvas's own analysis steps
(`PerformanceSummary.vue`: 4 Projection/theoretical · 5 Loan check/income & DSR · 6
Reality/bank-approved), each linking to its step. Everything on it is computed from the plan's
real state; the dashboard also hosts the **"Ask about my plan"** assistant (below). Series colors come
from `utils/wealthPlan/chartTheme.js` — `CHART` (gradient tokens, used by the canvas steps)
and `AREA` (the mockup's solid translucent stacked fills, used here).

> ⚠️ **The "Foundation Score" is gone** (2026-08-25, on the user's "remove the foundation score.
> i dun understand it"). It was three toggles (`profile.hasEmergencyFund` / `hasIncomeProtection`
> / `hasClearedDebt`) feeding a fixed sentence — **no math ever read them**, so it asked the
> member for input and gave nothing back. Both surfaces are removed (the result card and Step 1's
> Foundation section); the KEYS stay in `defaultProfile()`, legacy-retained like `dependents`, so
> a plan saved earlier still round-trips. Do not reintroduce it without a rule that actually
> consumes the answers.

> ⚠️ **One plan, analysed once.** [`utils/wealthPlan/planAnalysis.js`](/resources/js/utils/wealthPlan/planAnalysis.js)
> (`analysePlan` / `stressedState` / `verdictOf` / `goalAmount` / `headlineStats`) is the single
> analysis both the result dashboard and the canvas's **Step 4** read, and
> `Partials/PlanHeadlineStats.vue` + `Partials/LifetimeIncomeChart.vue` are the shared blocks
> both render. Step 4 therefore opens with the same four answers and the same income chart the
> result page shows (2026-08-25). Never re-derive a plan result inside a component.

> ⚠️ **One basis for every income figure.** `analysePlan`'s `salaryY` / `grossRentY` / `rentY` /
> `bizY` / `paymentY` are RM **per month**, read at the END of each plan year (the `Y` suffix is
> legacy — they are not annual). The chart plots them, the working-out table prints them and the
> goal metric subtracts them, from the same rounded integers, so a row adds up to the headline
> exactly. They were three different derivations until 2026-08-25 and a reader's own arithmetic
> came out ~RM 100 short.

> ⚠️ **The step strip has THREE states, and none of them is "where you clicked"** (2026-08-25).
> `stepState` (Edit.vue) returns per step: `'empty'` (grey number — not given what it asks for),
> `'done'` (green tick — satisfied), `'attention'` (red **!** and a red card tint — filled in, but
> something here will not work). Attention fires on: living costs ≥ salary (1), a goal the plan
> never reaches (2), a purchase outside the loan window (3), a failing resign verdict or missed
> goal (4), no DSR headroom (5), any purchase the simulator rejects (6). It was
> `step.n < currentStep` — position, not truth — so every tick vanished on refresh.
> Steps 4 and 6 hold no input, so they are never green merely for being filled: they are the
> verdicts, green when their checks pass and red when they fail.
>
> **Every `attention` carries a `reason` sentence, and the strip renders it in place of the step's
> subtitle.** The first question a bare red "!" produced was "why are these two red?" — a warning
> that cannot say what is wrong is a riddle, not a warning. A corollary: if a rule cannot be
> stated in one short sentence, it is too vague to colour a step red.
>
> ⚠️ **A result may only redden the step that computes it**, and the first version broke this.
> The canvas has a grammar: **1–2 declare** (what is true, what you want), **3 is the lever** (the
> only thing the member varies), **4–6 are the results**. "This plan never reaches your target" is
> a fact about the PROJECTION — reddening *Goal* for it blames the member for wanting something,
> offers nothing actionable on that step, and reports one failure twice. A declaration goes red
> only when it contradicts ITSELF: a resign age already behind you, a goal age you have passed, a
> goal date past the end of the horizon. Every verdict belongs to 4, 5 or 6. Edit.vue runs `analysePlan` +
> `runSimulator` for this; that is the same work steps 4 and 6 already do.
>
> Autosave stays (a member must not be able to lose a half-finished plan, least of all mid-
> interview), and the header now ALSO has a **Save** button — asked for 2026-08-25 so saving feels
> deliberate. It calls `flushAutosave()`, writing the pending debounce immediately; it is not a
> second write path.

> ⚠️ **Cash leaves in STAGES, not in one lump** —
> [`utils/wealthPlan/cashSchedule.js`](/resources/js/utils/wealthPlan/cashSchedule.js)
> (`propertyCashSchedule` / `cashPaidBefore`, 2026-08-25). `computePropertyCosts` says how MUCH;
> this says WHEN. For a project under construction the **differential** (price the loan does not
> cover — e.g. RM 500k price on a RM 450k loan = RM 50k) is billed evenly across the construction
> months, and **MOT + legal + renovation fall at hand-over**, because there is nothing to transfer
> or renovate until the keys exist. A completed property is still one payment on the purchase day.
> The even spread is a deliberate stand-in for HDA Schedule H progress billing, which no plan can
> predict — swap a stage table in here and every consumer follows.
> **INVARIANT: the schedule always sums to `costs.totalCashOut`** (tested). Consumers: the Step 6
> cash-deployment chart, the purchase card's staging note, and `cashDeployedBefore` — which now
> counts only cash actually PAID by resign day, so a purchase still under construction does not
> overstate the reserve it has eaten.

> ⚠️ **A property neither earns nor costs anything until it is COMPLETED** (2026-08-25). Each
> property row carries `completionYear` — years from now, like `buyYear`. While a project is under
> construction the plan books **no rent AND no instalment**: the loan is drawn (the balance sits at
> the full amount) but amortisation starts at hand-over and runs its full term from there.
> Progressive-release interest is deliberately ignored — the user's call, and a simplification
> that understates the cost of the construction period rather than overstating income.
> ⚠️ The DSR side is different on purpose: `runSimulator` keeps counting the committed instalment
> from the purchase, because a bank includes a housing loan it has already committed to whether or
> not the building is finished. Step 6 says so on the unbuilt row. The member's rent
> figure is what the FINISHED property fetches, so it is NOT grown across the construction period.
> `runSimulator` follows: no rent is recognised before completion, and the **seasoning clock runs
> from hand-over**, not from purchase (a bank wants rental history, which cannot exist on a
> building site) — its `rentBreakdown` gains a `status: 'unbuilt'` row. Curated deals carry a
> calendar `completion_year` (migration 2026_08_25_140000; blank = ready on purchase) which
> `applyPresetToProperty` converts to the canvas's offset.
>
> ⚠️ Read the field with `completionYearOf(p)`, never `Number(p.completionYear) ?? p.buyYear` —
> `Number(undefined)` is NaN, NaN is not nullish, and that exact expression poisoned every figure
> for rows saved before the field existed. Plans without it behave EXACTLY as before (completion
> defaults to the buy year), which `completion.test.js` pins.

> ⚠️ **Step 6 shows the bridge from the member's income to the BANK's** (2026-08-25). Recognised
> income is salary + `rentRecog`% of rent from properties past `seasoning`; each purchase card now
> states that arithmetic and lists every property from `event.rentBreakdown`, including ones not
> counted yet and how many of the required months they have been held. The DSR chart's series
> carry member language, and the "rent you already received" series is dropped from the legend
> whenever it is zero — which it is for anyone who owned no property before the plan.

> ⚠️ **Step 6's DSR bar labels sit UNDER their own band** — the legend row shares the bar's flex
> ratios, so nobody has to match a colour to a list (2026-08-25). The grey band is **"Kept for
> living"**, not "above the cap": it is the other 30% of income a bank refuses to lend against
> precisely so the borrower can live. "Above the cap" described the geometry, not the meaning.
>
> ⚠️ **Step 6's DSR bar carries its own key** (2026-08-25). It was four unlabelled colours with
> hover-only `title` tooltips — invisible on touch, and a member asked "red means what, why two red
> bars, one longer one shorter". Now: an explainer above the timeline (what DSR is, what the cap
> is), a colour key, and a per-purchase legend repeating each band WITH its RM figure. The bars are
> normalised to each purchase's own recognised income, so the same repayment draws longer on a
> smaller income — that trap is stated in words, and the percentage is named as the comparable
> number. If you add a band, add it to both keys.

> ⚠️ **One axis for every plan chart** — `ageYearAxis()` / `ageYearTitle()` in
> [`chartTheme.js`](/resources/js/utils/wealthPlan/chartTheme.js) (2026-08-25): **age on the first
> line, calendar year in brackets underneath**, a label every 5 years, and a tooltip that says the
> same ("Age 27 (2030)"). Step 4's three charts sat side by side labelling one timeline three
> different ways. Pass `offset: 0` for the charts that prepend a "today" anchor and `1` for those
> starting at the first projected year — that is the only thing that differs between them.

> ⚠️ **Step 4 is a summary, not a wall of boxes** (2026-08-25). Its result is ONE sentence with a
> verdict dot (`summary`) plus ONE inline row of facts (`shape`: properties · loans · peak
> instalment · equity at N · interest · recycling) — that replaced four headline boxes AND five
> metric cards. Then the three charts sit on ONE row (`@2xl:grid-cols-2 @5xl:grid-cols-3`): income
> · wealth · cashflow, answering what comes in, what it builds and what it costs. Boxes are for
> figures you compare against each other; a verdict is a sentence.

> ⚠️ **ONE breakdown table, not two.** `Partials/PlanBreakdownTable.vue` merges what used to be
> Step 4's loan table and a separate income table under the chart (2026-08-25). Banded headers —
> *Money in* / *Loans take out* / *Left* / *Worth by then* — and a **Per month ⇄ Per year** toggle
> (monthly = the last month of that plan year, the same month every other figure is read at;
> yearly = the twelve months summed). The result column is **"Rent after loan"**, deliberately NOT
> "You keep": it is rent minus the repayment, and sitting it beside Total income invited readers to
> subtract the wrong pair. Column position is part of the definition here. A "Where each column
> comes from" note under the table states the method for every column.

> ⚠️ **No vacancy allowance in the income figures** (2026-08-25). Rent shown is the rent the plan
> collects; the downside case is the **Stress test**, a scenario the member chooses, not a silent
> deduction inside the base case. The allowance still applies inside `resign.js`'s SURVIVAL checks
> — a deliberately stricter question — and the result page's coverage stat now READS
> `readiness.checks.coverage` instead of recomputing it, so the stat and the gate cannot differ.

> ⚠️ **Every chart figure is reproducible.** `LifetimeIncomeChart` carries a "Show how these
> numbers are worked out" table — one row per age, per month, walking salary · rent collected ·
> − vacancy allowance · = net rental · business · total income · − loan repayment · = you keep.
> Open by default in Step 4 (the working screen), collapsed on the result page. If you change how
> a band is derived, change this table with it — its whole job is that the reader can add it up.

> ⚠️ **Passive income = rent MINUS the repayments due on it** — defined once in
> [`utils/wealthPlan/goalMetric.js`](/resources/js/utils/wealthPlan/goalMetric.js) and read by BOTH
> Step 4's verdict and the result dashboard. Until 2026-08-25 the two disagreed: Step 4 subtracted
> the instalments, the dashboard quoted gross rent after a vacancy haircut and forgot them, so the
> dashboard reported roughly 3x the passive income the plan screen would confirm. The rent counted is rent AFTER the member's vacancy/upkeep allowance, the same rent the chart
> plots — counting gross here while the chart showed the allowance deducted meant the headline
> could not be reproduced from the picture under it. It can be
> NEGATIVE in early years — a loan-funded property costs money before it makes any — and hiding
> that flatters the plan. Never re-derive this metric in a component.

> Plans + reports are owned by the **lead** (lead == portal user), so everything keys on `lead_id`.
> **The planning math involves no AI** — projections and bank rules are deterministic client-side
> code, and WhoPay parsing is plain HTML/regex extraction (`WhopayAnalyzerService`), not an AI call
> (the legacy `raw_gemini_json` column name is a port artifact, not a Gemini call). The two AI
> touchpoints — the interview's optional **second opinion** and the result page's **"Ask about my
> plan"** chat (both below) — refine or explain member inputs; neither computes a plan number.

### Opened by the road's D card · the demo unit · the five-day lock on Step 3 (2026-09-02)
- A plan is **created automatically** the first time a member's DMAIC-road **D card** is complete
  (`LearningJourneyRepository::ensureWealthPlan()`), seeded by `WealthPlanSeed::fromCards()`: Step 1 · 2
  key for key from the card (`ROAD_PROFILE_FIELDS` — the canvas prints 「来自你的 D 卡」 on them via the
  `roadSource` prop), `recycle: 100` on. Later D edits reach the plan only through the member's
  explicit 「更新到 Wealth Plan」 (`refresh=1` on the road's handoff route).
- **Step 4 always has a row.** Without a priced M card the seed adds a `source: 'demo'` row (the
  cohort's project or a synthetic dual-key; `StepProperties` shows the 「示范单位」 badge, read-only);
  the road's I card replaces it (`WealthPlanSync::mirrorImprove()`).
- **Step 3 (Properties) is locked for a five-day TRAINEE until their I card is complete** — prop
  `training: { enrolled, locks: { 3: verdict|null } }` from `TrainingAccess::verdict($user,
  'wp.step.3')`; the strip prints the day and reason, the step opens read-only. Non-trainees, graduates
  and admins never see a lock. See the road handbook's *five-day training* section.

## How it works
- **Storage (lead-keyed).** `wealth_plans` holds one plan: the full wizard save-state as an opaque JSON
  blob in `state` (shape-identical to petav2 for a faithful port), plus promoted summary columns
  (`goal_type`, `target_age`, `property_count`, `total_loan`, `total_mv`) and `last_opened_at`.
  `state` is the **single source of truth**; the summary columns are recomputed from it on every save
  via `WealthPlan::summarize()` and exist only for the card-grid list. `wealth_whopay_reports` holds a
  parsed report (per-category commitments, net income, DSR, credit rating, `raw_json` full extraction).
  Both extend `SoftDeleteModel` + `HasUuid` + `RecordsBlame`.
- **Plan lifecycle (Inertia).** `index` renders the three-tab dashboard page (primary/fallback plan
  + plan cards + saved WhoPay reports; `primaryIsFallback` only when there is an actual choice —
  more than one plan). `store` creates a **blank** plan (auto-`is_primary` when it is the lead's
  first) and redirects into its editor. `primary` (`POST {id}/primary` →
  `WealthPlanRepository::markPrimary()`) moves the flag radio-style inside one transaction. `edit` opens the 6-step
  canvas for one owned plan (touches `last_opened_at`). `update` is the **autosave** endpoint — a
  debounced Inertia `PUT` (via `useWealthPlanAutosave`, `preserveState`) that saves only the `state`
  blob; the repository re-derives the summary columns and the display name (from the Step-1 profile
  name). `destroy` soft-deletes. Every action is scoped to the authenticated lead (`ownedPlan()`).
- **Data flow.** User inputs in each step mutate one reactive `state` object → the canvas's
  `utils/wealthPlan/*` math modules compute the month-by-month projection, costs, and bank-rule
  simulation **live in the browser** → results feed the `WealthChart` and projection tables →
  `state` is autosaved (PUT) → on reload, `Edit.vue` hydrates the canvas verbatim from the saved blob.
- **Resign Mode (all client-side, like the rest of the math).** New `state.profile` keys —
  `careerMode` ('stay_employed' | 'own_business' | 'resign_to_entrepreneur'), `resignAge`, `livingExpenses`,
  `liquidSavings`, `bizIncome`, `bizGrowth`, `rentHaircut` — hydrate through `defaultProfile()`
  (⚠️ a key absent from those defaults is silently dropped on reload; that map is the schema).
  The pure checks live in `utils/wealthPlan/resign.js`: window classification per property
  (`outside`/`edge`/`inside` vs the resign year), the **12-Month Self-Sustain Rule** (liquid savings
  MINUS the capital cash the plan itself deploys pre-resign must cover 12× any monthly gap),
  instalment coverage on **haircut actual rent** (the bank's recognised-rent % is a different number
  on purpose — approval maths vs. survival maths), a composite verdict, and
  `earliestSafeResignAge()` — a slide-the-resign-year solver that returns null rather than a
  fabricated age when no age within the horizon works. The resign line itself is a ~100-line inline
  Chart.js plugin (`resignLinePlugin.js`, registered in `WealthChart` — deliberately NOT
  chartjs-plugin-annotation) that no-ops unless a chart passes `plugins.resignLine` options.
  `bankRules.js` gained the same flat state keys: salary stops (earning AND recognition) at the
  resign month, business income is tracked for charts but **never** DSR-recognised, the horizon
  extends past the resign year so the charts show the salary vanish, and purchases dated at/after it
  are rejected with `reason: 'resigned'` before the DSR math so the event card says WHY.
- **WhoPay reports.** `storeWhopay` takes a pasted WhoPay URL, calls `WhopayAnalyzerService::analyze()`
  (fetch HTML via the `Http` facade → regex-extract span IDs → map to columns), and saves the result
  through `WhopayReportRepository`. Saved reports are listed on the index and surfaced into the canvas's
  loan-check step; `destroyWhopay` removes one.
- **AI second opinion (the module's one AI call).** `POST wealth-planning/{id}/ai-review`
  (`WealthPlanAiReviewController`, prompt key `AiRequest::PROMPT_WEALTH_PLAN_REVIEW`, body in
  `resources/prompts/wealth_plan_review.md`) fires from the interview's summary screen, **resign path
  only**, as a plain JSON `fetch` in parallel with the "building" beat — never blocking it. The
  figures travel **in the request body** (the last answer's autosave may still be inside its
  debounce, so the saved blob is deliberately never read); `Edit.vue` flushes the autosave via
  `saveNow()` on the same event. The reply is whitelisted + clamped server-side
  (`livingExpenses` → 30–90% of salary, `bizIncome` → 0–salary, `rentHaircut` → 5–30%; unknown keys,
  missing/oversized reasons and no-op proposals dropped) — a chip writes straight into the plan on
  tap, so nothing unclamped may reach the client. Company key; `throttle:10,1` caps the burst, a
  per-user `RateLimiter` (20/day) caps the bill; provider failure → 503 with empty proposals and the
  interview shows nothing. AiClient logs every call into `ai_requests` as usual.
- **"Ask about my plan" (the module's other AI call).** `POST wealth-planning/{id}/ask`
  (`WealthPlanAskController`, prompt key `AiRequest::PROMPT_WEALTH_PLAN_ASK`, body in
  `resources/prompts/wealth_plan_ask.md`) powers the Result dashboard's assistant. It replies as an
  **SSE stream** on the shared `StreamsServerSentEvents` lane (the one AI Conversations uses), so
  the answer is typed out at real generation speed rather than appearing after a blank several
  seconds long; the panel appends each `delta` to the open turn. ⚠️ The split matters: the refusals
  IN FRONT of the stream (not your plan / snapshot too big / out of questions) answer as JSON with a
  real status code, while a provider failure — which happens after the stream was already accepted
  with a 200 — travels as an `error` EVENT. Answers are **Markdown** (a bold headline, one short
  line, up to two bullets — the prompt caps it near 60 words) rendered through the shared
  `utils/markdown.js`, which escapes first, so `v-html` is safe.
  **Every exchange is filed in the member's [AI Chatbot](/docs/modules_handbook/main/ai-conversations/readMe.md)
  history** (2026-08-25 — it used to be tab-only and died with the session): one
  `ai_conversations` row per plan via `wealth_plan_id`, created on the first question and resumed
  by every later one, so the history shows a conversation ABOUT a plan rather than a pile of
  one-line threads. It is stored with `is_wealth_context` on, so continuing it inside AI Chatbot —
  where this page's computed snapshot does not exist — still has the plan to reason about. It does
  NOT spend the member's AI credits: those are AI Conversations' currency and this runs on the
  company key. The panel's **New chat** clears the panel and starts a NEW thread (`fresh: true`)
  rather than deleting anything — the previous conversation stays in history. A `conversation`
  uuid supplied by the panel is honoured only when it is the member's own AND belongs to this
  plan, so a copied uuid can never append to a stranger's history. It renders as a **sticky right-hand column**
  that opens itself and explains the verdict on arrival (one call per plan per tab, guarded by the
  stored thread — a beginner should not have to find a button to learn what their own verdict
  means); the parent measures its own width with a `ResizeObserver` and falls back to the
  launcher + popup below ~1040px, mounting exactly ONE instance either way so there is one thread
  and one call. Because all plan maths is client-side, the page
  ships its own **computed analysis snapshot** (`aiContext` — profile, goal, properties, base +
  stress results, the on-screen blockers) in each request body, size-capped (15 KB) and quoted to
  the model as data; the saved `state` blob is deliberately never read, and the snapshot rides on
  the **final** message only (the member may have flipped Base ⇄ Stress between turns). The prompt
  explains bank concepts in plain language, never recomputes numbers, never contradicts the page's
  verdict, and steers personal-judgement questions ("should I resign?") to a Property Deal Day.
  Nothing persists server-side — the thread lives in the tab's `sessionStorage`, keyed by user +
  plan. Company key; `throttle:10,1` + a per-user 30/day `RateLimiter`; provider failure → 503 the
  panel shows as an error bubble. Boundary tests: `tests/Feature/Main/Portal/Wealth/WealthPlanAskTest.php`.

## Related files

**Backend — Models**
- [src/Wealth/WealthPlan.php](/src/Wealth/WealthPlan.php) — the plan; `GOAL_*` / `GOAL_TYPES` constants; `state` JSON cast; `summarize()` (derives summary columns from `state`); `lead()` + `owner` accessor.
- [src/Wealth/WhopayReport.php](/src/Wealth/WhopayReport.php) — a parsed CCRIS report (commitments, net income, DSR, `raw_json`).

**Backend — Repositories**
- [src/Wealth/Repositories/WealthPlanRepository.php](/src/Wealth/Repositories/WealthPlanRepository.php) — `createBlank` (auto-primary for a lead's first plan) / `update` (persist state + recompute summary + name) / `markPrimary` (radio per lead) / `touchOpened` / `delete`.
- [src/Wealth/Repositories/WhopayReportRepository.php](/src/Wealth/Repositories/WhopayReportRepository.php) — `createFromParsed` (maps the extraction to columns) / `delete`.

**Backend — Service**
- [src/Wealth/Services/WhopayAnalyzerService.php](/src/Wealth/Services/WhopayAnalyzerService.php) — `analyze(url)`: fetch + regex-parse a WhoPay DSR report into structured columns (no AI; ported from petav2).

**Backend — Console command (historical import)**
- [app/Console/Commands/ImportPetav2WealthPlans.php](/app/Console/Commands/ImportPetav2WealthPlans.php) — `wealth:import-petav2 {--dry-run} {--chunk=200}`: copies the petaV2 InvestHink portal's `wealth_plans` + `wealth_whopay_reports` into petav3, resolving each petaV2 `portal_user_id` to a lead through the shared [`LeadLinker`](/src/Lead/Services/LeadLinker.php) (never a hand-rolled matcher). **Idempotent on `petav2_id`** — the source row's own primary key, unique on both tables — so it is safe to re-run for a top-up at cutover while petaV2 is still live; a locally-deleted plan is never resurrected (`SkipsLocallyDeletedOnImport`) and a re-run never rotates a plan's `uuid`. Exits non-zero unless every source row is accounted for. Also chained as the third step of `petav2:import-all`.
- [src/Wealth/Support/Petav2WealthMapper.php](/src/Wealth/Support/Petav2WealthMapper.php) — the pure row shaper it uses (`planRow` / `whopayRow`); no DB access, `state` / `raw_json` and timestamps copied verbatim as raw strings.
- Write path: `WealthPlanRepository::bulkUpsertFromPetav2()` / `WhopayReportRepository::bulkUpsertFromPetav2()` — keyed upsert on `petav2_id` inside a transaction.

**Backend — Controller & Form Requests**
- [app/Http/Controllers/Main/Portal/WealthPlanningController.php](/app/Http/Controllers/Main/Portal/WealthPlanningController.php) — index/store/edit/update/destroy + storeWhopay/destroyWhopay; `ownedPlan()` scoping; `planCard()` / `reportCard()` mappers; `prefill()` (name + DOB-age from the profile, salary **and commitments from the same latest WhoPay row** — one report, one trust decision).
- [app/Http/Controllers/Main/Portal/WealthPlanAiReviewController.php](/app/Http/Controllers/Main/Portal/WealthPlanAiReviewController.php) — the interview's AI second opinion (whitelist + clamp, daily cap, fail-soft; see the How-it-works bullet).
- [app/Http/Controllers/Main/Portal/WealthPlanAskController.php](/app/Http/Controllers/Main/Portal/WealthPlanAskController.php) — the Result dashboard's "Ask about my plan" chat (analysis snapshot in the body, daily cap, fail-soft; see the How-it-works bullet).
- [app/Http/Controllers/Main/Portal/DealDayController.php](/app/Http/Controllers/Main/Portal/DealDayController.php) — the dashboard's "Book Deal Day" CTA → a `consultation_requests` row (`landing_url='/wealth-planning#deal-day'` marks the source; identity from the session, never the payload).
- [app/Http/Requests/Main/Portal/Wealth/UpdatePlanRequest.php](/app/Http/Requests/Main/Portal/Wealth/UpdatePlanRequest.php) — `state` present + array (canvas owns inner shape).
- [app/Http/Requests/Main/Portal/Wealth/StoreWhopayRequest.php](/app/Http/Requests/Main/Portal/Wealth/StoreWhopayRequest.php) — validates the pasted WhoPay URL.
- [app/Http/Requests/Main/Portal/Wealth/AiReviewRequest.php](/app/Http/Requests/Main/Portal/Wealth/AiReviewRequest.php) — validates the intake figures the AI review carries in its body.
- [app/Http/Requests/Main/Portal/Wealth/AskPlanRequest.php](/app/Http/Requests/Main/Portal/Wealth/AskPlanRequest.php) — validates the ask chat's question + short thread + analysis snapshot (snapshot shape stays the projection engine's business — validated as "an array", size-capped in the controller).
- [app/Http/Requests/Main/Portal/DealDay/StoreRequest.php](/app/Http/Requests/Main/Portal/DealDay/StoreRequest.php) — validates the Deal Day request (preferred date + slot + notes — a preference, not a booking; a human confirms).

**Frontend (Vue)**
- [resources/js/Pages/Main/Portal/WealthPlanning/Index.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Index.vue) — the three-tab landing page (Result dashboard / plan card grid with primary badge + set-as-primary / saved eligibility checks) + the eligibility and Deal Day modals.
- [PrimaryPlanDashboard.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/PrimaryPlanDashboard.vue) — the Result tab, built to the approved mockup (topbar + verdict chip · Base/Stress switch · stacked lifetime chart · Foundation / stats+gates / action rail); also computes the ask chat's `aiContext` snapshot + state-aware starter questions.
- [AskPlan.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/AskPlan.vue) — the "Ask about my plan" assistant, in two variants from one component (`docked` sticky column / `floating` launcher + popup; the parent measures its width and mounts exactly one). Streams SSE and renders Markdown, with the affordances a chat is judged on: a blinking caret while writing, typing dots before the first token, a **Stop** button that keeps the partial answer, **Copy** and **Try again** under a finished or failed reply, auto-scroll that stops the moment the member scrolls up (plus a "Latest" pill back), an auto-growing composer, `aria-live`, and a `prefers-reduced-motion` guard on both animations.
- [DealDayModal.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/DealDayModal.vue) — the Deal Day booking modal (`POST /deal-day`).
- [PerformanceSummary.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/PerformanceSummary.vue) — the result page's steps 4/5/6 cards. Runs the SAME modules the steps do (`projection.js` rows, `eligibility.js`, `runSimulator`) so a summary can never quote a figure its own step contradicts.
- [ResignRequirementChart.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/ResignRequirementChart.vue) — monthly outgoings vs income-without-a-salary across every candidate resign age, mounted by BOTH Step 4 and the result page. Draws the monthly lines, not the reserve: an available reserve goes negative when a plan deploys more cash than the member holds, and a chart that dives below zero reads as broken (the takeaway names that cash shortfall in words instead).
- [resources/js/Pages/Main/Portal/WealthPlanning/Edit.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Edit.vue) — the 6-step canvas orchestrator; owns the reactive `state` + step navigation + autosave.
- [Partials/StrategyChooser.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StrategyChooser.vue) — RRR vs PPP, the two cards and the foreigner recommendation; hosted by `Index.vue`'s New-plan modal and by the Learning Hub guide.
- [Partials/PlanReport.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/PlanReport.vue) — the Result tab's report shell (masthead · verdict chip · numbered section nav · Download PDF · AI drawer), filled by `PrimaryPlanDashboard` and `Ppp/PppPrimaryDashboard`.
- Step partials: [StepYou.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepYou.vue) · [StepGoal.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepGoal.vue) · [StepProperties.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepProperties.vue) (+ the `计划 vs 实际` panel — [StepProperties.test.js](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepProperties.test.js)) · [StepProjection.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepProjection.vue) · [StepLoanCheck.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepLoanCheck.vue) · [StepReality.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepReality.vue)
- Projection chart: [resources/js/Components/WealthChart.vue](/resources/js/Components/WealthChart.vue) — **shared, not a Wealth Planning partial**: it lives in `Components/` because Analyze Property (`Components/PropertyAnalysis/TabRental.vue`, `Airbnb/Overview.vue`) draws the same Chart.js line/bar chart. Imported by `StepGoal` / `StepProjection` / `StepReality` as `../../../../../Components/WealthChart.vue`.
- Other partials: [PropertyForm.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/PropertyForm.vue) · [DealBrowser.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/DealBrowser.vue) (Step 3's pick-a-deal card grid; renders the `presets` prop — ACTIVE [deal presets](/docs/modules_handbook/manage/portal-engagement/readMe.md) mapped by `WealthPlanningController::presetCard()` — and emits `select` so the picked deal pre-fills a property. **Since 2026-08-26 a preset can be LINKED to a real master-catalogue project** (`wealth_deal_presets.catalog_project_id` + `catalog_project_uuid`, picked by type-ahead on the admin form), and the card then leads with the building's photographs and a strip of its unit layouts. ⚠️ The catalogue is a SEPARATE DB connection: it must never be joined or eager-loaded from a site model — [`Src\Wealth\Services\DealPresetCatalogue`](/src/Wealth/Services/DealPresetCatalogue.php) is the one place that reads it, plucking ids and running a single `whereIn`. The link is presentation ONLY — the deal's `mv`/`net_price`/`rent` stay the admin's negotiated figures, never the catalogue's market statistics — and it degrades to the old text card when absent, unreachable, or when the project has no media (only ~873 of 37k catalogue rows carry both pictures and layouts)) · field inputs ([MoneyField.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/MoneyField.vue) · [NumberField.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/NumberField.vue) · [TextField.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/TextField.vue) · [SelectField.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/SelectField.vue) · [FieldLabel.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/FieldLabel.vue))
- [resources/js/composables/useWealthPlanAutosave.js](/resources/js/composables/useWealthPlanAutosave.js) — debounced `state`-only Inertia PUT autosave.
- Math utils (client-side, ported verbatim from petav2): [projection.js](/resources/js/utils/wealthPlan/projection.js) (amortisation + cashflow schedule) · [installment.js](/resources/js/utils/wealthPlan/installment.js) (⚠️ mirrored in PHP by [`Src\Journey\Support\Instalment`](/src/Journey/Support/Instalment.php) for the road's New Projects cashflow sieve — change one, change both) · [costs.js](/resources/js/utils/wealthPlan/costs.js) (MY capital costs) · [bankRules.js](/resources/js/utils/wealthPlan/bankRules.js) (Step-6 sequential simulator, incl. Resign Mode gating) · [propertyTypes.js](/resources/js/utils/wealthPlan/propertyTypes.js) · [propertyHelpers.js](/resources/js/utils/wealthPlan/propertyHelpers.js) · [format.js](/resources/js/utils/wealthPlan/format.js) (each with a `*.test.js` sibling) · [chartTheme.js](/resources/js/utils/wealthPlan/chartTheme.js) (the portal-wide chart tokens: emerald rental / amber business / slate salary, navy tooltip, gradient-under-line fills — the sidebar pillars and every wealth chart share this one mapping).
- Loan side (shared by Step 5, Step 6 and the result summary): [eligibility.js](/resources/js/utils/wealthPlan/eligibility.js) — `eligibilitySnapshot(profile)` (today's effective income / DSR / headroom / ~loan capacity), `simulatorState(profile, year)` (the flat state `runSimulator` wants) and `simulatorProperties(properties)` (fundable only, id guaranteed — the simulator labels rent rows with `prop.id.slice(1)`, so an id-less property used to throw, which the landing page cannot afford). `eligibility.test.js` sibling.
- Resign Mode: [resign.js](/resources/js/utils/wealthPlan/resign.js) (window checks · 12-month rule incl. grant salary support · coverage · composite verdict · earliest-safe-age solver, which now walks `resignRequirementSeries()` — one row per candidate age, so the chart and the verdict can never name different ages · `cradlePreset()`/`grantSalarySupport()`; `resign.test.js` sibling) · [resignLinePlugin.js](/resources/js/utils/wealthPlan/resignLinePlugin.js) (the inline Chart.js resign-line/zones plugin + grant markers + `resignLineOptions()` helper) · [ResignReadinessPanel.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/ResignReadinessPanel.vue) (Step 4's checklist + verdict; hosts the goal verdict as check 4) · the loan-window countdown banner lives in `Edit.vue` (canvas frame, visible on every step).
- Guided interview: [GuidedInterview.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/GuidedInterview.vue) (the full-screen capture flow — writes straight into the reactive `state`, so autosave persists each answer; branching step list is computed from the answers; hosts the summary's AI second-opinion chips + fires the `ai-review` fetch) · [proposals.js](/resources/js/utils/wealthPlan/proposals.js) (smart-default proposers: expense tiers, biz-income options, timing chip sets; `proposals.test.js` sibling) · `WealthPlanningController::prefill()` (name/DOB-age + salary/commitments from the latest WhoPay row). Step panels render through `<KeepAlive>` + `<Transition>` in `Edit.vue` — panel-local state survives navigation while the swap slides in the travel direction.
- Nav entry: [resources/js/Layouts/AppLayout.vue](/resources/js/Layouts/AppLayout.vue) — standalone entry above the two income pillars (one plan spans both: the property portfolio AND Resign Mode's business exit).

**Migrations**
- [database/migrations/2026_08_25_090000_add_wealth_plan_id_to_ai_conversations_table.php](/database/migrations/2026_08_25_090000_add_wealth_plan_id_to_ai_conversations_table.php) — links a chat thread to a plan (the Scout `property_analysis_id` pattern).
- [database/migrations/2026_06_05_000001_create_wealth_plans_table.php](/database/migrations/2026_06_05_000001_create_wealth_plans_table.php)
- [database/migrations/2026_06_05_000002_create_wealth_whopay_reports_table.php](/database/migrations/2026_06_05_000002_create_wealth_whopay_reports_table.php)
- [database/migrations/2026_06_13_000002_add_lead_id_to_wealth_plans_table.php](/database/migrations/2026_06_13_000002_add_lead_id_to_wealth_plans_table.php) — re-keys ownership from `member_id` to `lead_id`.
- [database/migrations/2026_07_29_000001_add_petav2_id_to_wealth_tables.php](/database/migrations/2026_07_29_000001_add_petav2_id_to_wealth_tables.php) — nullable **unique** `petav2_id` on both tables: the idempotency key for `wealth:import-petav2`.
- [database/migrations/2026_08_24_180000_add_is_primary_to_wealth_plans_table.php](/database/migrations/2026_08_24_180000_add_is_primary_to_wealth_plans_table.php) — `is_primary` (one per lead, radio semantics via `markPrimary()`).

**Seeders** — none (members create their own plans).

**Routes**
**Frontend — the PPP canvas (`Partials/Ppp/`)**
- [PppEdit.vue](/resources/js/Pages/Main/Portal/WealthPlanning/PppEdit.vue) — the orchestrator. Owns `state`, the step strip (four steps for a foreigner, six otherwise), the second-currency props passed to every step, and the tenure clamp that re-applies `maxTenure()` whenever residency or age changes.
- [PppStepPair.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepPair.vue) + [PppLegCard.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppLegCard.vue) — step 3. The loan leg's tenure field is capped by `maxTenure()` and prints `tenureNote()` beside it.
- [PppStepProjection.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepProjection.vue) — step 4: the goal verdict, the wealth chart, and a cash-flow block that pivots Both / Property 1 / Property 2 off `analysis.rows` rather than recomputing.
- [PppStepOutcome.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepOutcome.vue) — step 4 AS A FOREIGNER SEES IT: projection + phases + result in one screen, because steps 5 and 6 are hidden and only step 5 is a loan check.
- [PppStepPhases.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepPhases.vue) — the coverage waterfall and the signed year-by-year chart. Its guard cross-checks the waterfall's three figures against the totals row of the year table, so picture and table cannot drift apart.
- [WaterfallChart.vue](/resources/js/Components/WaterfallChart.vue) — **shared, not a PPP partial**: bars that chain onto each other so a subtraction happens in the shape instead of in the reader's head.
- Math: [ppp.js](/resources/js/utils/wealthPlan/ppp.js) (`analysePpp`, `pppGoalVerdict`, `pppAiContext`) · [tenure.js](/resources/js/utils/wealthPlan/tenure.js) (`maxTenure` / `tenureNote` — ONE rule, imported by both the interview and the canvas) · [currency.js](/resources/js/utils/wealthPlan/currency.js) (the second currency; the rate is never quoted as live, and the member may overwrite it).
- Guards worth knowing before editing: [pppPlainWords.test.js](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/pppPlainWords.test.js) mounts every PPP screen and fails if a member would read 「income engine」/「growth leg」/「buffer」 — the data model still uses those names, and should; [pppInterview.test.js](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/pppInterview.test.js) pins the founder's own corrections to the guided setup; [strategyChooser.test.js](/resources/js/Pages/Main/Portal/WealthPlanning/strategyChooser.test.js) pins the foreigner recommendation; [stepYouResidency.test.js](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/stepYouResidency.test.js) pins the residency card and keeps it OFF the RRR canvas.

- [routes/main.php](/routes/main.php) — the `main.portal.wealth.*` group (plans CRUD + `whopay-reports` store/destroy + `{id}/ai-review` + `{id}/ask` + `{id}/primary` + `deal-day`).

**See also:** [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) — its grounding toggle feeds a member's recent `WealthPlan` rows into the chat as context ·
[DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md) — the Learning Hub's road: its **Define** card is handed off here (`POST /property/academy/road/handoff/wealth` builds a canvas state via [`WealthPlanSeed`](/src/Journey/Support/WealthPlanSeed.php), pins the plan on the cycle and reopens the SAME plan on a second click), its Measure / Deal Day / Takeoff hand-offs land on the plan dashboard, and its **Control** card mirrors the purchase back through [`WealthPlanSync`](/src/Journey/Support/WealthPlanSync.php) — see [Plan vs actual](#plan-vs-actual-2026-08-28) above.
