# FPA — Financial Planning Analysis (Manage)

**Portal:** Manage · **Routes:** `manage.fpa.*` + `manage.portal.financial-reports.index` (gated on `Permission::viewLeadsAny()`; writes also need `manage-leads`) plus the public `main.consent.*` form, the public `main.financial-report.*` page and the portal's `main.portal.financial-plan.*` · **Nav:** Portal hub → Wealth Planning → **FPA** / **Consent** pills, and on every lead: Lead → Property Portal → **Financial Report** (`?tab=portal&ptab=fpa` — the key stays `fpa` so old links land).

## What it does

Everything we know about one customer's money, collected in three steps:

1. **Consent.** An agent sends the customer ONE consent link. The customer photographs their IC, confirms their details and signs. The signature permits us to pull their credit file.
2. **WhoPay report.** Once the consent is signed, an admin pastes the customer's WhoPay DSR report link. The system reads the page and stores the credit details (commitments, CCRIS facilities, bank eligibility, WhoPay's advice).
3. **Analysis.** The financial planning analysis itself: income and goals (answered by the customer right after signing, or later in the portal), assets and liabilities, plans and dependants. Historical analyses come from the petaV2 import.

## How it works

- **One consent per lead.** `LeadConsentRepository::generateLink()` never adds a second row: a signed consent is returned as it is, an open invitation is returned as it is, an expired one is **renewed in place** (new token, new 14-day expiry, prefill re-read), and only a lead with no consent gets a new row. The lead row is locked so a double click cannot create two.
- **Which row is "the" consent** is `LeadConsent::currentFor($leadId)`: signed first, then the latest signing, then the latest row. Three things apply that same rule:
  - the one-off migration `2026_09_17_000001_keep_one_consent_per_lead` (soft-deleted the extras that already existed);
  - `LeadConsentRepository::collapseToCurrent()` after a **lead merge** (`LeadRepository::mergeLeadChildren`);
  - `collapseToCurrent()` after the **petaV2 consent import** (`FpaImportRepository::upsertConsents`).

  Extras are always SOFT-deleted, because they still point at IC photographs and PDFs.
- **The consent link needs one contact** (email OR phone). `fpaConsentLinkReady()` matches the repository.
- **One button, "Copy consent link"** (`ConsentStep.vue`): it copies the existing link, or POSTs `consent-link` first and copies the link from the response page. The clipboard is handed a promise (`ClipboardItem`) during the click, because Safari refuses a plain `writeText` after a network round trip. If the browser still blocks it, the link stays visible on the card to copy by hand. Hidden once the consent is signed.
- **Signing** (`/consent/{token}`, public, throttled): see `ConsentController` and `LeadConsentRepository::sign()`. After signing, the customer goes straight to `/consent/{token}/questionnaire`.
- **Staff preview: `?admin` on the consent link** (e.g. `/consent/{token}?admin`) puts a dashed **Skip** button under each level so staff can walk the whole form without filling it in, plus an amber "Staff preview" strip so nobody mistakes it for the customer's view. Granted by an **admin SESSION plus the flag**, never the query string alone — the link is public, so a customer who is sent it with `?admin` still gets the ordinary form (`ConsentController::previewMode`, mirrored in `QuestionnaireController::show`; presence-based, since a valueless `?admin` reads as false through `boolean()`). It moves the SCREEN only: `SignRequest` and `QuestionnaireRequest` are untouched, so a skipped form still cannot be submitted. Level 3's Skip is a LINK to `/consent/{token}/questionnaire?admin` — and for a preview only, `QuestionnaireController::show` will open those questions on an UNSIGNED consent (`previewConsent()`), so the last three levels can be looked at without signing something real. That relaxation is read-only: `store()` still resolves a SIGNED consent, so a preview's Finish writes nothing and returns to the form, and the page says so (`previewUnsigned`). Pinned by `QuestionnaireTest::test_a_staff_preview_may_read_the_questions_before_signing_but_never_write`.
- **Reading the IC is OFF (owner, 2026-09-18).** `POST /consent/{token}/ic-scan` sends the photographed card to the AI provider (prompt `ic_extract`, flash tier, default Gemini) and prefills name + IC number for the customer to confirm — it never overwrites a field they have already filled, and a failure is silent. It is now gated on **`config('ai.consent_ic_scan')` / `CONSENT_IC_SCAN`, default false**: the endpoint 404s, `Sign.vue` is told through the `icScanEnabled` prop and never calls it, and the customer types their name and number themselves. WHY: it sends a customer's identity document to a third party and the agreement text does not say so — turn it on once that sentence is in the consent wording. The read result (name + IC) is also written to `ai_requests.response_text`; the image bytes are not (AiClient replaces attachment bytes with size placeholders).
- **The public pages play like a game (owner request, 2026-09-18); the masthead reads "Financial Planning / By PropertyLab", and there is no score — the star counter and the points were dropped the same day for being too game-like on a legal form, leaving the level bar and map.** Six levels across the two pages: 1 Snap your ID · 2 Check your details · 3 Review & sign (`Main/Consent/Sign.vue`), 4 About you · 5 Your income · 6 Your goals (`Main/Consent/Questionnaire.vue`), then a trophy screen (`QuestionnaireDone.vue`). Shared pieces in `resources/js/Components/Fpa/Quest/`: `questLevels.js` (the one list of levels, icons, colours), `QuestShell.vue` (backdrop + progress bar + level map + "Level N cleared" toast + confetti; the page calls its exposed `levelUp(n)` / `celebrate()`), `QuestLevelCard.vue` (the sliding white card), `QuestConfetti.vue` (hand-rolled canvas, no dependency), `QuestBackdrop.vue` (also used by `Closed.vue`): a photo of KL's twin towers at blue hour under a navy wash with a 40-second slow push-in — `public/main/images/consent/kl-twin-towers-blue-hour.webp`, 1400px, by Alim (@apyfz) on Unsplash, **Unsplash License** (free commercial use, no attribution needed). A still, not a video, on purpose: the link is opened on phones on mobile data. It replaced drifting colour blobs + twinkling stars, which the owner found too childish. Animations are the `quest-*` keyframes in `resources/css/app.css`, all switched off for prefers-reduced-motion. What is NOT gamified: the agreement wording, the read-back before signing and the tick box. Behaviour kept from before: all levels stay mounted (`v-show`) so errors attach; a server error on an earlier level now jumps back to that level; resizing the signature pad clears the signature rather than submitting ink the customer can no longer see; the questionnaire opens with a "Consent signed!" welcome once per link per tab (`sessionStorage`).
- **A signed consent ALWAYS has its PDF (2026-09-17).** `sign()` renders the PDF (`Src\Lead\Support\ConsentPdfRenderer`, Dompdf) BEFORE anything is marked signed. If it fails — Dompdf missing, an IC photo unreadable, anything — the photos that attempt uploaded are deleted again, `ConsentPdfFailedException` is thrown, the consent stays INVITED, and the customer sees "please press Submit again" under the signature box (their answers stay on the form). The profile only gets its copy of a new IC photo after the signature is saved. IC photos wider than 1400px are shrunk before embedding: two full-size phone photos inside Dompdf can exhaust PHP's memory and kill the request where no catch can see it.
- **Backfill: `php artisan consent:render-missing-pdfs [--dry-run] [--limit=N]`** generates the PDF for consents signed before that rule, from the SAVED snapshot, signature, signing date and IC photos. It skips imported petaV2 consents still waiting for their real PDF (`petav2_pdf_path` set → `fpa:import-media`) and consents with no stored signature. Re-running is safe. It refuses (and reports) a consent whose IC photo file cannot be read, rather than producing a PDF without it.
- **"Signed PDF not generated"** (amber, on the Consent card) = a signed consent with no PDF and no petaV2 file pending (`pdf_missing` in the payload). Run the backfill command.
- ⚠️ **Dompdf must be installed** (`composer install`). Since signing is all-or-nothing, a server without it refuses EVERY signature — which is the intent (no signed consent without a document), but it means a broken vendor directory shows up as customers unable to sign.
- **WhoPay step** — `POST /manage/fpa/leads/{id}/whopay-report` → `FpaController::storeWhopayReport`:
  1. The lead must be visible to the admin (`LeadVisibility`), and its consent must be **signed**; otherwise the request is refused and nothing is fetched.
  2. `StoreWhopayReportRequest` only accepts a `whopay.` URL, so the server cannot be pointed at another address.
  3. `WhopayAnalyzerService::analyze()` fetches the HTML and parses it with regex (no AI).
  4. `FpaAnalysisRepository::screen()` creates the analysis if the lead has none, stores the report through `WhopayReportRepository::createFromParsed()` with `screening_status = ANALYZED`, links `fpa_analyses.whopay_report_id`, and sets `claimed_at` so a petaV2 re-import cannot swap it back.
  5. **Every paste records its date** (`analyzed_at`, shown as "checked …" plus "CCRIS data as of …" from the latest *Date Balance Updated*). A customer's CCRIS changes over time, so admins re-run WhoPay:
     - a **different** link → a new report that becomes the one the analysis reads; the previous one is KEPT and listed under "Earlier reports" (date, profile, DSR, income, monthly commitments, outstanding), so a paid-off loan is visible as a change. The portal reports the customer saved are listed there too.
     - the **same** link again → `WhopayReportRepository::updateFromParsed()` re-reads that row in place (every parsed column rewritten, so a figure that is gone becomes null, not stale).
- **What is shown** comes from one trait, `PresentsFpaAnalysis::fpaWorkflow()` → `{ lead_uuid, consent, consentLinkReady, screening, screeningReady, reportReady, panels, properties }`. The Lead page, the read-only Lead modal and `/manage/fpa/{uuid}` all render it through `Components/Fpa/FpaWorkflow.vue`, so they cannot disagree. The modal hides the action buttons (`leadReadonly`).
- **Two tabs inside Financial Report** (`FpaWorkflow.vue`, `?rtab=` on the standalone page):
  1. **Consent & WhoPay report** — steps 1 and 2 as numbered cards; the tab badge reads `n/2`.
- **What the questions ask (2026-09-18, owner).** Company is asked ONCE, on level 5 (it was on the consent form too). Levels 4–6: the birth date and gender are NOT asked when the signed IC is a Malaysian NRIC — the card encodes both and signing already wrote them to the profile (`identityFromIc`, from `IcExtraction::birthDateFrom`); the address is labelled "Where you live now"; salary says **monthly NET, after EPF, SOCSO and tax**; **rental income is now ASKED** (`inc_rental_self`, migration `2026_09_19_000001`) — it was left out of the original table while it was a SUM over `fpa_items`, but those items are no longer collected and a customer's properties are entered on the report AFTER signing, so at the moment the form is filled there is nothing to sum. It is therefore what the customer STATES; `lead_properties.monthly_rent` stays the breakdown, and the Income card prints "The properties on file add up to RM X / month" with an amber note when the two differ by more than RM 50 — a disagreement worth a phone call, made visible instead of averaged away. The card takes the properties through a `properties` prop (lead tab and the public report both pass them). **petaV2's rental income still reads back**: the import copied it as `fpa_items` rows of type `rental_income` rather than as a total (it was derived there), so `PresentsFpaAnalysis::recordedRentalIncome()` sums those items into `income.rental_recorded`. The card shows the STATED figure when there is one and the recorded figure otherwise (labelled "from their earlier FPA record") — never both, since that would double the same money. On prod today that is 1 customer, RM 5,000/month across 2 items; and level 6 asks its FOUR questions **one at a time** (a numbered dot row inside the card; "Next question" is never gated, Finish appears on the last one) — each is a **text box the customer writes in**, with **four quick picks (A–D, `FpaAnalysis::GOAL_OPTIONS`, capped by `GOAL_OPTIONS_MAX`)** that type their words into that same box and untype them when tapped again. The picks are **folded behind a "No idea? See some examples" toggle**, closed by default: on show they are the loudest thing under the question and turn "tell us what you want" into "choose one of our four". A question that already carries one of them opens itself, so a pick can always be taken back. The picks are a help, not the answer: there is one value per column, still free text, so a hand-typed answer and one recorded before the picks existed both read back unchanged. The portal's Income & Goals tab renders all four questions together (`goalKeys` defaults to the full set).
- ⚠️ **Fixed with it: a save used to WIPE the birth date and gender.** `MapsFpaQuestionnaire` mapped `date_of_birth` / `gender` unconditionally, so any screen that does not render them — the portal's Income & Goals tab, always; the consent questions, now — posted nothing and wrote NULL over what the IC had given us, with no way back. Both are now guarded by `$request->has(...)`, like `is_smoker` already was. Pinned by `QuestionnaireTest::test_answers_that_the_form_did_not_ask_for_do_not_wipe_the_profile`.
  2. **Financial planning analysis** — ONLY the customer's **questionnaire answers**, as they were asked in the consent link (`QuestionnaireAnswers.vue`: About you → Income → Goals — payload `analysis.about` / `analysis.income` / `analysis.goals`; occupation, company, birth date, gender and address are read live from the profile and address the questionnaire writes to).
- **Advisor-session data is NOT shown (2026-09-17).** Assets & liabilities (`fpa_items`), plan items (`fpa_plan_items`), spouse + dependants (`fpa_dependants`, `spouse_*`), `retirement_vision` and the engine snapshot (`snap_*`) are not asked in the consent link — only the petaV2 import ever wrote them. They were removed from the Lead tab, `/manage/fpa/{uuid}`, the `/manage/fpa` list's item/plan counts, and from the payload (`PresentsFpaAnalysis::fpaPanel`). The tables, columns, models and the petaV2 import are still in place.
- **WhoPay card layout** (`WhopayStep.vue`): verdict band (profile colour + icon + WhoPay's comment + checked / CCRIS-as-of dates) → four KPI tiles (net income, current commitment, DSR with a 0–150% meter, income required with "Short by / Covered" against net income) → the loan WhoPay tested → monthly commitments and eligible loan by bank as single-hue brand bars (banks sorted, RM 0 = "Not eligible") → **CCRIS details** (`CcrisTable.vue`, laid out like WhoPay's own table: No · Date · Sts · Capacity · Facility · Outstanding · Balance updated · Limit/Instalment · Col type · 12-month payment grid · LGL sts · Status updated; arrears > 0 in red, 0 quiet green, no record = ·) → WhoPay advice + recent credit applications → earlier reports. **"Paste a newer report" opens a modal**; the very first report is pasted inline.
- **CCRIS month headings** (`ccris_months`) are worked out, not parsed — WhoPay prints bare letters. First column = the month after the latest *Date Balance Updated*, then back 12 months (`PresentsFpaAnalysis::ccrisMonths`). Each account carries `payments` (12 cells, newest first; int = instalments in arrears, null = no record).
- **Financial Report page — PUBLIC (owner's decision, 2026-09-17)** — `GET /financial-report/{token}` (`main.financial-report.show`, `Main\FinancialReport\FinancialReportController`, `Pages/Main/FinancialReport/Show.vue`). No login: the customer and the agent open the same link. Laid out as one printable document: cover (name, IC, email, phone, prepared date, reference, consent signed, credit checked) → **Summary** (monthly income as declared, monthly commitments from CCRIS, DSR, property equity, credit profile) → **01 Client profile** (questionnaire answers) → **02 Property portfolio** (editable, see below) → **03 Credit assessment** (the WhoPay card, read-only via `leadReadonly`) → important notes + the signed consent PDF (`/financial-report/{token}/consent-pdf`). A contents rail shows on wide screens (the active chapter is tracked with an IntersectionObserver); toolbar, rail and edit buttons are hidden when printing.
- **THE PRINTED REPORT IS THE DELIVERABLE (2026-09-19)** — the founder prints this page and hands it to a customer, so paper is not a degraded copy of the screen. It was failing as one: **no logo anywhere on paper** (the only one lived in the screen toolbar, which is `print:hidden`), nothing identifying pages 2-5, and the CCRIS grid ran off the right edge of the sheet with no scrollbar to hint that columns were missing. What the page now does, and why each choice is the way it is:
  - ⚠️ **The document is wrapped in a REAL `<table>`** (`.fr-doc`, one cell, `<thead>` + `<tfoot>` hidden on screen). That is the ONLY way left to repeat a running header on every printed page in Chrome: `position: fixed` paints once mid-document in Chrome 152, and a `display: table-header-group` div does not repeat either — both were tried against this page and photographed failing. Forced breaks and `break-inside: avoid` still work inside the cell, which is what makes the markup worth it. `table-layout: fixed` is not optional: a table sizes to its content, and without it the article pushed past its column on screen and cut off the fourth summary card.
  - **The running header** is the logo + "Personal financial report · {name}"; the **footer** is "Private & confidential — prepared for {name}" + "PropertyLab · {date}". A sheet separated from the stack still says whose it is.
  - **The cover prints WHITE with navy type** rather than reversed out of its navy band. A reversed cover depends on the reader leaving "Background graphics" ON in the print dialog, and Chrome defaults it OFF — white text on white paper is how a report arrives blank. The cover's own logo row is `print:hidden` for the opposite reason: the running header already carries the mark, and printing it twice three lines apart is what makes a document look automated.
  - **Chapters 01 and 03 start on a fresh sheet** (`print-break-before`), so page 1 is the cover + summary + a print-only "In this report" contents list — a reader holding a half-empty first page cannot otherwise tell it is not the end of the report. Cards, tables and rows carry `break-inside: avoid`; headings carry `break-after: avoid`.
  - **The CCRIS grid fits now.** Its `min-w-[1100px]` is released in print (`min-width: 0`), the type drops to 6.5pt and padding to 1.5pt — but **`word-break` stays `normal`**: `break-word` let every column shrink to one character and shredded the headers down the page ("C A P A C I T Y"). Whole words give the auto layout a real minimum.
  - All of it lives in ONE `<style>` block in `Pages/Main/FinancialReport/Show.vue`, scoped to `[data-fin-report]`, because [app.css](/resources/css/app.css)'s print block already owns what every page shares (hiding chrome, forcing exact colour, `.print-section`, `.print-break-before`).
  - **The token** is `lead_consents.report_token` — 48 random characters of its own (not the lead uuid, not the consent token). Minted at signing (`LeadConsentRepository::sign`); migration `2026_09_17_000003` backfilled every consent already signed; a consent without one (a later petaV2 import) gets one the first time an admin opens the report (`LeadConsentRepository::issueReportToken`). Only a SIGNED consent's token resolves — an unknown, revoked or unsigned token is the same 404. Throttled like the consent form.
  - **What it does NOT send:** the WhoPay report address, stored-file ids, report uuids, the lead uuid, IC images. `FinancialReportController::publicReport()` strips them from the shared `fpaWorkflow` payload — any new field added to that payload must be checked there too.
  - **From the Lead page:** `ReportLinkBar.vue` above the Financial Report tabs, once the consent is signed (`fpa.reportReady`): **Open report** (`GET /manage/fpa/leads/{id}/report` → `FpaController::report`, admin + `LeadVisibility`, mints the token if missing and redirects to the public page), **Copy link** (`fpa.consent.report_link`), **New link** (`POST /manage/fpa/leads/{id}/report-link` → `renewReportLink`, `manage-leads`; the old link dies at once).
- **Owned properties** — `lead_properties` (`Src\Lead\LeadProperty`, `LeadPropertyRepository`). The properties a lead ALREADY OWNS, deliberately NOT the Wealth Plan's `state.properties` (those are planned purchases that drive the plan's projection). Same field set as the Wealth Plan property card: name, location, type (the plan's type keys `new_hda / subsale / auction / commercial / bulk_rebate`), SPA price, net price, market value, loan, rate, tenure, rent/mo, renovation. **Worked out, never stored:** rebate % (SPA vs net, `LeadProperty::rebatePercent`), monthly instalment (annuity formula, `LeadProperty::monthlyInstalment`), equity (market value − loan) — in `PresentsFpaAnalysis::fpaProperties`, payload `fpa.properties`.
  - **Anyone with the report link can add / edit / remove** (`POST/PUT/DELETE /financial-report/{token}/properties[/{uuid}]`, `StorePropertyRequest` — only the name is required). A property uuid from another lead is a 404. Each write goes on the lead's activity trail (`TYPE_LEAD_PROPERTY_ADDED / UPDATED (coalesced) / DELETED`, no actor — the link holder is unknown). Removal is a soft delete.
  - Shown read-only on the Lead page's **Financial planning analysis** tab (`PropertyPortfolio.vue` without `editable`). Merge re-points them, purge deletes them (`IdentityChildMap`: `lead_properties.lead_id`).
- **About you** now also carries who the customer is (`about.name / id_number / email / phone`); the analysis tab's separate name/status header card was removed.
- **The worklist: Portal → Financial Report** (`/manage/portal-engagement/financial-reports`, `manage.portal.financial-reports.index`, `Manage\Portal\FinancialReportsController`, `Pages/Manage/Portal/FinancialReports/Index.vue`). One row per CONSENT — not per analysis — because somebody sent a link who never signed is the most actionable row there is, and appears in no analysis list. Columns: customer (snapshot first, profile as fallback) · consent status + signed date + the "PDF not generated" flag · questions answered (an analysis ALONE is not an answer — pasting a WhoPay report creates one, so it reads salary/goals) · credit report (rating + DSR + date, latest per lead) · the public report link · started. Filters: search (snapshot name/IC/email/phone or the live account), consent status, credit report done / not yet, date range. Four tiles above it count consents / signed / awaiting / credit checked. Every row's action opens `/manage/leads/{uuid}?tab=portal&ptab=fpa`, where the rest of the writes live. **The one write here is "New consent link"** (gated on `manage-leads`): `Partials/StartConsentModal.vue` picks the person with the shared `LeadComboBox` (so a customer we do not hold yet is created through the identity gate, never inserted), then READS `GET /manage/fpa/leads/{id}/status` (`manage.fpa.lead-status`, `FpaController::leadStatus`) before offering anything — a lead keeps ONE consent, so the honest answer is usually "they already have one, here it is", with a copy button. It offers to create only when there is no consent, or when an invitation has expired (which renews it in place), and says so when a lead has neither email nor phone to send it to. Scoped by `LeadVisibility` like the rest of the module; the per-page lookups (latest report, who answered) are ONE bulk query each, never per row. Pinned by `tests/Feature/Fpa/FinancialReportListTest.php`.
- **The Financial Report badge** on the lead's Property Portal strip counts the steps done (0–3).
- **Old links:** the separate Lead → Property Portal → **Consents** pill was folded into Financial Report (2026-09-17). `?ptab=consents` is remapped to `fpa` (`LEGACY_PORTAL_TAB_MAP` in `useLeadTabs.js`).
- **Parser fix (2026-09-17):** CCRIS amounts now go through `parseNumber()`. Before, `(float) "2,158"` stored a RM 2,158 instalment as 2.

## Data

| Table | Holds |
|---|---|
| `lead_consents` | The lead's one consent: token, `report_token` (public Financial Report link), status (1 invited / 2 signed), `snapshot_*` as signed, signature, IP, `id_front_media_id` / `id_back_media_id` / `pdf_media_id`, `expires_at`, `petav2_id`. |
| `wealth_whopay_reports` | WhoPay CCRIS reports, owned by `lead_id`. Portal reports leave `screening_status` null; admin-pasted ones are `2` (analyzed). `raw_json` keeps the whole parse (`raw_gemini_json` → profile, financial summary, commitment summary, advice); the screening columns (`facility_*`, `rpt_*`, `elig_new_home_loan`, `credit_facilities_json`, `bank_recommendations_json`) are filled from it. |
| `lead_properties` | Properties the lead already owns, entered on the public Financial Report. Soft-deleted. |
| `fpa_analyses` | One analysis per lead (normally): `whopay_report_id`, `claimed_at`, the questionnaire columns, and petaV2's `snap_*` figures. |
| `fpa_items` / `fpa_plan_items` / `fpa_dependants` | Children of an analysis, keyed on `fpa_analysis_id`. |

## Related files

**Backend**
- [src/Lead/LeadConsent.php](/src/Lead/LeadConsent.php) — `currentFor()`, `isOpen()`, `missingContacts()`
- [src/Lead/Repositories/LeadConsentRepository.php](/src/Lead/Repositories/LeadConsentRepository.php) — `generateLink()`, `sign()`, `renderMissingPdf()`, `collapseToCurrent()`
- [src/Lead/LeadProperty.php](/src/Lead/LeadProperty.php), [src/Lead/Repositories/LeadPropertyRepository.php](/src/Lead/Repositories/LeadPropertyRepository.php) — owned properties
- [src/Lead/Support/ConsentPdfRenderer.php](/src/Lead/Support/ConsentPdfRenderer.php) — the PDF; [src/Lead/Exceptions/ConsentPdfFailedException.php](/src/Lead/Exceptions/ConsentPdfFailedException.php)
- [src/Fpa/FpaAnalysis.php](/src/Fpa/FpaAnalysis.php), [FpaItem.php](/src/Fpa/FpaItem.php), [FpaPlanItem.php](/src/Fpa/FpaPlanItem.php), [FpaDependant.php](/src/Fpa/FpaDependant.php)
- [src/Fpa/Repositories/FpaAnalysisRepository.php](/src/Fpa/Repositories/FpaAnalysisRepository.php) — `forLead()`, `screen()`, `saveQuestionnaire()`
- [src/Fpa/Repositories/FpaImportRepository.php](/src/Fpa/Repositories/FpaImportRepository.php) — petaV2 import writes
- [src/Wealth/WhopayReport.php](/src/Wealth/WhopayReport.php), [src/Wealth/Repositories/WhopayReportRepository.php](/src/Wealth/Repositories/WhopayReportRepository.php), [src/Wealth/Services/WhopayAnalyzerService.php](/src/Wealth/Services/WhopayAnalyzerService.php)
- [app/Http/Controllers/Manage/Fpa/FpaController.php](/app/Http/Controllers/Manage/Fpa/FpaController.php) — lists, show, consent link, WhoPay report, documents
- [app/Http/Controllers/Concerns/PresentsFpaAnalysis.php](/app/Http/Controllers/Concerns/PresentsFpaAnalysis.php) — the one payload shape
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `fpa` prop on `show` and `quick`
- [app/Http/Controllers/Main/Consent/ConsentController.php](/app/Http/Controllers/Main/Consent/ConsentController.php), [QuestionnaireController.php](/app/Http/Controllers/Main/Consent/QuestionnaireController.php)
- [app/Http/Controllers/Main/FinancialReport/FinancialReportController.php](/app/Http/Controllers/Main/FinancialReport/FinancialReportController.php) — the public report + owned-property writes; `app/Http/Requests/Main/FinancialReport/StorePropertyRequest.php`, `UpdatePropertyRequest.php`
- [app/Http/Controllers/Main/Portal/FinancialPlanController.php](/app/Http/Controllers/Main/Portal/FinancialPlanController.php)
- [app/Http/Requests/Manage/Fpa/StoreWhopayReportRequest.php](/app/Http/Requests/Manage/Fpa/StoreWhopayReportRequest.php), `FpaQueryRequest.php`, `ConsentQueryRequest.php`
- [app/Jobs/Automation/SendPaymentAutomationWhatsApp.php](/app/Jobs/Automation/SendPaymentAutomationWhatsApp.php) — sends the buyer's consent link (`consentLinkFor()`)
- Commands: `consent:render-missing-pdfs` ([app/Console/Commands/RenderMissingConsentPdfs.php](/app/Console/Commands/RenderMissingConsentPdfs.php)), `fpa:import-petav2`, `fpa:import-media`, `media:convert-heic`

**Frontend**
- [resources/js/Components/Fpa/FpaWorkflow.vue](/resources/js/Components/Fpa/FpaWorkflow.vue) — the two tabs
- [resources/js/Components/Fpa/ConsentStep.vue](/resources/js/Components/Fpa/ConsentStep.vue), [WhopayStep.vue](/resources/js/Components/Fpa/WhopayStep.vue), [FpaAnalysisPanel.vue](/resources/js/Components/Fpa/FpaAnalysisPanel.vue), [QuestionnaireAnswers.vue](/resources/js/Components/Fpa/QuestionnaireAnswers.vue)
- [resources/js/Components/Fpa/PropertyPortfolio.vue](/resources/js/Components/Fpa/PropertyPortfolio.vue), [PropertyFormModal.vue](/resources/js/Components/Fpa/PropertyFormModal.vue), [ReportLinkBar.vue](/resources/js/Components/Fpa/ReportLinkBar.vue), [ReportSection.vue](/resources/js/Components/Fpa/ReportSection.vue)
- [resources/js/Pages/Manage/Leads/Partials/Tabs/FpaTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/FpaTab.vue), [resources/js/Pages/Manage/Fpa/Show.vue](/resources/js/Pages/Manage/Fpa/Show.vue), `Index.vue`, `Consents.vue`
- [resources/js/composables/useLeadTabs.js](/resources/js/composables/useLeadTabs.js) — FPA pill + `LEGACY_PORTAL_TAB_MAP`
- Public: `Pages/Main/Consent/Sign.vue`, `Questionnaire.vue`, `Closed.vue`, `QuestionnaireDone.vue`, `Pages/Main/FinancialReport/Show.vue`

**Migrations**
- `2026_08_03_000001_create_lead_consents_table`, `2026_08_03_000002…000005` (fpa tables), `2026_08_03_000006_add_screening_columns_to_wealth_whopay_reports_table`, `2026_08_05_000001_add_claimed_at_to_fpa_analyses`, `2026_09_17_000001_keep_one_consent_per_lead`, `2026_09_17_000002_create_lead_properties_table`, `2026_09_17_000003_add_report_token_to_lead_consents_table`, `2026_09_19_000001_add_rental_income_to_fpa_analyses_table`

**Routes**
- `routes/web.php` → `manage.fpa.*` (incl. `consent-link`, `whopay-report`, `report`, `report-link`)
- `routes/main.php` → `main.consent.*`, `main.financial-report.*`, `main.portal.financial-plan.*`

**Tests**
- `tests/Feature/Fpa/ConsentPdfGuaranteeTest.php`, `LeadFpaWorkflowTest.php`, `FpaScreensTest.php`, `ConsentFormTest.php`, `QuestionnaireTest.php`, `tests/Unit/Wealth/*`
