# Post-VP Tracker — a bought unit from the keys to the sale

**Portal:** Manage + Main · **Nav:** Manage → a project's Show page → **Post-VP** tab; Setting →
**Post-VP Checklist**; Main → the member dashboard's **My properties** card → `/property/my-properties/{uuid}`

## What it does

After a member buys a unit through PropertyLab, the unit goes through seven stages — the founder's
list, 2026-09-21: **收匙 → 验房 → 缺陷 → 装修 → 招租 → 出租中 → 卖出** (key collection → inspection →
defects → renovation → finding a tenant → tenanted → sold), and "每一阶段有清单、有负责人、有时间" —
every stage has a checklist, a person in charge (PIC) and dates.

- The **team** sets each project's stage dates and PICs once, starts the tracker for its units, and
  ticks the checklist as things happen.
- The **member** sees, on their dashboard, every unit they bought, which stage it is in, and the one
  step that is theirs to do next — then a full page per unit.

Four rulings from the owner (2026-09-21) shape it:

1. **Dates: the project sets the default, a unit may override it.** Units of one project get their
   keys together; a late unit or a slow renovation is set on that unit alone.
2. **Checklists come from templates edited on Setting**, copied onto a unit when its tracker starts —
   editing a template shapes units started from then on, never one already running.
3. **One PropertyLab PIC per stage, and every item says who does it** (owner / PropertyLab /
   developer / contractor), so a member sees at a glance which items are theirs.
4. **The member only reads it in this first version.** Ticking their own items and uploading defect
   photos is phase 3.

## How it works

- **A unit is a `Booking`** (Active or Completed) — the one record that ties a buyer (`lead_id`), a
  project and a unit number together. A cancelled booking bought nothing and is never tracked.
- **Four tables**, all without schema foreign keys (GUIDELINES §7):
  - `post_vp_checklist_templates` (`Src\PostVp\ChecklistTemplate`) — the Setting list, seeded with a
    DRAFT checklist by migration `2026_09_21_200004` for the owner to edit.
  - `project_stage_schedules` (`ProjectStageSchedule`) — one row per (project, stage): default
    `starts_on`, `ends_on`, `pic_admin_id`.
  - `booking_stages` (`BookingStage`) — seven rows per unit: `status` (not started / in progress /
    done / skipped), and OVERRIDES `starts_on` / `ends_on` / `pic_admin_id` — **null means "the
    project's"**, so moving the project's dates carries every unit nobody set by hand. Staff-only
    `notes`. `started_at` / `completed_at` are stamped by the repository on a status change.
  - `booking_stage_tasks` (`BookingStageTask`) — the unit's checklist. `due_days` counts from the
    stage's RESOLVED start, so due dates follow the schedule; `due_on` pins one by hand.
    `is_member_visible` hides an internal item from the member.
- **One presenter resolves everything**: `Src\PostVp\Support\UnitJourneyPresenter::present($booking,
  VIEW_STAFF|VIEW_MEMBER)` — resolved dates and PIC per stage, due dates, overdue flags, the current
  stage (first one not done/skipped), progress and the next open task (the member view: the next task
  assigned to the OWNER). The Manage tab, the dashboard card and the member page all call it, so they
  can never disagree about a date. The member view drops notes, hidden items, overrides and
  who-ticked-what. Callers eager-load `RELATIONS` (+ `STAFF_RELATIONS`); it never queries.
- **`phase`**: `construction` (no stage moving and the VP date is in the future or unknown) →
  `journey` → `finished` (every stage done or skipped).
- **Defect liability** = VP + 24 months (`DEFECT_LIABILITY_MONTHS`, Malaysia's Housing Development
  Act Schedules G/H) — shown as "free defect repairs until" to the member.
- **Manage — the project's Post-VP tab** (`PostVpTab.vue`): key dates → project schedule (with a
  client-side "Suggest from VP date" that fills EMPTY fields only, from `SUGGESTED_OFFSETS` in
  `utils/postVp.js`) → the units table → one unit's tracker (`PostVpUnitModal.vue`). The data is the
  `postVp` **`Inertia::optional`** prop on `SalesProjectsController::show`, fetched when the tab
  mounts; writes ask for it back with `only`, and the tab keeps showing the last payload while a
  fresh one loads (no "Loading" flash on every tick).
- **Visibility**: a unit is shown/writable only when the viewer passes `GroupScope` for its project
  AND `LeadVisibility` for its buyer (`PresentsPostVp::postVpUnit`) — the same two rules as the
  project's Leads tab. Reads need `view-projects`; every write needs `manage-projects`. **No new
  permission**, so a deploy needs no `RolesSeeder` run.
- **Member** — `DashboardController::myProperties()` (every live booking on the member's lead; reads
  `projects.name`, never the catalogue connection) → `Components/Dashboard/MyPropertiesSection.vue`,
  placed ABOVE the personal widgets. `MyPropertiesController@show` opens one unit for its buyer only
  (another member's uuid is a 404). The member's three writes (who renovates, their own loan / value,
  moving one of their stages) go through `ownedBooking()` + `RaiseRenovationEnquiry` / `SaveOwnerNumbers` /
  `MoveOwnerStage`, the same actions from the portal and from `member-api/v1/my-properties/{id}/…`
  (2026-09-25). Member copy passes the first-time reader test: the card says "the
  keys" and "repairs"; the page defines "vacant possession" and the repair period where they first
  appear. Chinese in `resources/lang/zh_CN.json`; stage and item names come in both languages from the
  server (`name_zh`, `title_zh`).

## After the first real project (2026-09-21, owner's rulings on Maxim Risen)

- **The member sees only their OWN late items flagged.** `UnitJourneyPresenter` sets a task's
  `is_overdue` in the member view only when the owner is the one who does it; a stage's lateness is
  flagged to staff only. The team's lateness is the team's to chase — the Manage tab still shows it all.
- **A due date never falls after its stage ends.** `BookingStage::dueDateOf()` caps `start + due_days`
  at the stage's resolved end (a one-day 验房 must not list items due a fortnight later); a date pinned
  by hand (`due_on`) is kept as typed.
- **整批更新同一阶段.** The project tab has a checkbox per tracked unit and one bar: stage + status +
  "Update N units" (with a confirm). `PUT /manage/sales-projects/{id}/post-vp/stages/{stage}`
  (`ProjectSchedulesController@bulkStage`, `manage.sales-projects.post-vp.stages.update`) →
  `BookingStageRepository::setStatus()`: only the status moves, stamped by the same rule as a single
  unit (`stampStatus()`); untracked units, other projects' units and units the viewer may not see are
  skipped.
- **Not built, on purpose:** a bulk portal invitation for the buyers (owner declined 2026-09-21).

## A schedule of DURATIONS (2026-09-23, owner)

Seven pairs of typed dates per project was unmaintainable: a handover slipping two weeks meant
re-typing fourteen dates, so nobody did, and units went on quoting a plan the project had abandoned.

A project now states **one date** — when the keys are collected (stage 1's `starts_on`) — and **how
long each stage runs**. `Src\PostVp\Support\StageTimeline` chains the rest: a stage starts
`gap_days` after the previous one ended, runs `duration_days`, and ends on the last of them.

- **The owner's lengths** (`StageTimeline::DEFAULTS`): 收匙 1 · 验房 2 · 缺陷 30 · 装修 30 after a
  **1-day handover** · 招租 30. 出租中 and 卖出 carry none — one runs until the unit is sold, the
  other ends the journey, so a length would have to be invented. A null duration ENDS the chain:
  nothing after an open-ended stage has a date to follow from.
- **The renovation handover is a `gap`, not an eighth stage.** The seven stages are the vocabulary a
  MEMBER reads; adding one to describe an internal handover would change what they are told they own.
- ⚠️ **A STORED date still wins.** Projects configured before this keep the dates somebody typed, and
  a colleague may still pin a single stage by hand — clearing it hands that stage back to the chain.
  Without the rule, adding durations would have silently overwritten every schedule already entered.
  The Manage form therefore CLEARS stages 2–7's dates when it saves, because that is the only way to
  switch a project to durations.
- **Form:** Post-VP tab → `Starts` (anchor only) · `Runs for` · `Handover` · `PIC`. The derived dates
  are printed beside the inputs, so the consequence of a length is visible before saving.

### A unit moves on its own

`StageTimeline::shiftFrom()` moves ONE stage to a new start and carries every stage after it, each
keeping its length. Stages BEFORE it are untouched — they already happened.

Two callers, one rule:

- **The buyer**, on their unit page (`PUT property/my-properties/{id}/stages/{stage}`,
  `MyPropertiesController@moveStage`). Their renovation starting late is a fact about their unit, not
  about the project. A renovation pushed two weeks with the tenant search left behind would claim the
  unit is let while it is still a building site — which is why the rest shifts.
- **A colleague**, from the Leads table's **Key Collection** column (the unit's anchor), through
  `SalesProjectsController@updateUnitEconomics`.

A unit whose tracker has not been started shows **"not tracked"** and cannot be dated: creating stage
rows here would open a tracker the team has not.

### Who renovates — `bookings.renovation_by`

`Self` · `PropertyLab` · `Partner` (`Booking::RENOVATION_BYS`), set from the Leads table. It decides
whose job the renovation stage is, and it is nullable because the answer is not known at booking.

Choosing **PropertyLab** assigns that unit's renovation stage to the colleague the project's schedule
already names for it — but ONLY when the stage has no PIC of its own, since somebody who deliberately
put a different colleague on this unit outranks the project default. Changing it back to Self does
**not** unassign: withdrawing an assignment is a separate decision, not something a dropdown should do
on the way past. The member's unit page says which of the three it is, so a page that tells a buyer
"yours to do" never says it about work the team is doing.

## Repayment starts at vacant possession (2026-09-24)

`UnitEconomics` amortises the loan from **`projects.vp_at`**, not from the booking date. Before the
keys are handed over the buyer is on progressive interest, not on an instalment, so amortising
earlier would credit them with principal they have not repaid — and overstate their equity by
exactly that amount.

It adds five keys. **Until repayment starts, `outstanding` is the whole loan amount** — including
while the project has no VP date yet (owner, 2026-09-25: *"outstanding loan … should just follow the
loan amount as default first"*): repayment begins at VP, so a unit without one has repaid nothing.
With no loan entered, that is the assumed loan (net price, else SPA price), and the dashboard tile
says so. `outstanding` is null only with no loan amount at all, or once repayment HAS started and
the rate or tenure it needs is missing. `loan_started_on` / `months_paid` stay null without a VP date:

| Key | Means |
|---|---|
| `loan_started_on` | the project's VP date |
| `months_paid` | whole months from that date to today, floored, capped at the tenure |
| `outstanding` | what is still owed, by the standard remaining-balance identity `B = P · [(1+r)^N − (1+r)^n] ÷ [(1+r)^N − 1]` — not a loop |
| `principal_paid` | loan amount less `outstanding` |
| `net_equity` | market value less `outstanding` |

⚠️ **`net_equity` and `gross_equity` answer different questions and both are kept.** Gross equity
measures the market against what they PAID; net equity measures it against what is still OWED. An
owner asks both in the same breath, and collapsing them loses one.

## The owner's own page — two tabs (2026-09-23)

`/property/my-properties/{uuid}` splits into **Forecast performance** (default) and **Renovation
steps** (the seven stages, unchanged), on the shared `ShowTabs` — only the open tab mounts, and
`?tab=` survives a refresh or a shared link.

### Forecast performance

Read top-down as the three questions an owner asks in that order: what is it worth against what I
paid · what does it cost and earn each month · where did each figure come from. Every figure comes
from `Src\PostVp\Support\UnitEconomics` — the same class the colleague's leads table reads.

- **The word FORECAST is in the heading, not a footnote**, and the page says in one line that the
  actual rent will appear beside these once the unit is let. Nothing here claims to be an actual, so
  nothing has to be unwound to add them later.
- **An assumed loan is amber, not grey.** The instalment card asks to be corrected rather than
  stating a fact; a figure the platform assumed must never look like one the bank issued.
- **The ledger names every figure's source** — SPA price, net price, rebate, loan amount, tenure,
  rate, instalment, market value, equity, forecast rent, cash flow, gross yield, each with the
  sentence that says where it came from. A number a member cannot trace is one they are right not to
  trust; "market value" in particular says *median asking price of comparable units nearby — not a
  valuation*, because Malaysia has no transaction registry to value against.
- **The owner corrects two of them** (`PUT property/my-properties/{id}/numbers`): their real loan and
  their own valuation, both of which outrank ours and are stamped as theirs so a colleague reading
  the same row in Manage can tell whose figure it is. The dialog previews the instalment and cash
  flow as they type — the loan is entered to answer a question, and making them save to find out is
  the thing that made the old inline editor useless.
- **A unit with no layout named shows none of it**, and says why, instead of a page of dashes.

The dashboard card carries the same four figures (value · equity · rent · cash flow) under a
`FORECAST` label and links straight to this tab.

## Not built yet (phase 2 / 3)

- ~~`booking_id` on `renovation_jobs`, `rental_estimates` / `rental_tenancies`~~ — **done
  2026-09-23** for `renovation_jobs` and `rental_estimate_submissions` (the table is
  `rental_estimate_submissions`, not `rental_estimates`). Still missing on `rental_tenancies` and
  `concierge_requests`, so 出租中 and 卖出 still read a checklist tick rather than a real status.
  How a unit reaches each board differs on purpose: renovation is the BUYER'S decision, asked on
  their dashboard; letting is the default service and opens at the VP date. See the
  [renovation](../renovation/readMe.md) and [rental-estimate](../../main/rental-estimate/readMe.md)
  handbooks.
- **Actuals beside the forecast.** Once a unit is let, `rental_estimate_submissions.final_rent` and
  its tenancy are the real numbers; the owner's page is built to show them next to the forecast
  rather than replace it, but nothing reads them yet.
- Reminders: `Notifier` (Telegram) to the PIC when a stage or item is due/overdue; member WhatsApp.
- The DMAIC **C** card (`JourneyCard::CONTROL_STEP_KEYS` — vp, defect, reno, tenant, manage) reading
  this tracker instead of the member's own ticks.
- A cross-project board (every unit by stage, overdue first).
- The mobile app's Landlord tab (`member-api`), and the member ticking their own items + uploading
  defect photos (`MediaService`).

## Related files

**Backend**
- `src/PostVp/BookingStage.php` · `BookingStageTask.php` · `ChecklistTemplate.php` · `ProjectStageSchedule.php`
- `src/PostVp/Repositories/` — `BookingStageRepository` (start / update), `BookingStageTaskRepository`
  (create / complete / reopen / delete), `ChecklistTemplateRepository`, `ProjectStageScheduleRepository`; facades in `src/PostVp/Facades/`
- `src/PostVp/Support/UnitJourneyPresenter.php`
- `app/Http/Controllers/Concerns/PresentsPostVp.php` — the tab payload + `postVpUnit()` visibility
- `app/Http/Controllers/Manage/PostVp/` — `ProjectSchedulesController`, `UnitsController`, `TasksController`, `ChecklistTemplatesController`
- `app/Http/Controllers/Manage/Engagements/SalesProjectsController.php` — the `postVp` optional prop
- `app/Http/Controllers/Main/DashboardController.php` — `myProperties()`; `app/Http/Controllers/Main/Portal/MyPropertiesController.php`
- `app/Http/Requests/Manage/PostVp/{Schedules,Units,Tasks,ChecklistTemplates}/`
- Relations: `Booking::stages()`, `Project::stageSchedules()`

**Frontend**
- `resources/js/Pages/Manage/SalesProjects/Partials/PostVpTab.vue` · `PostVpUnitModal.vue` (mounted in `Show.vue`)
- `resources/js/Pages/Manage/PostVp/Checklist/Index.vue` · `Partials/ChecklistTemplateFormModal.vue`; tab in `Components/SettingTabs.vue`
- `resources/js/Components/Dashboard/MyPropertiesSection.vue` (mounted in `Pages/Dashboard.vue`) · `Pages/Main/Portal/MyProperties/Show.vue`
- `resources/js/utils/postVp.js` — tones, dates, suggested offsets, `waLink`

**Migrations** — `database/migrations/2026_09_21_200000` … `200004`

**Routes**
- `routes/web.php` — `manage.sales-projects.post-vp.schedule.update` (PUT `{id}/post-vp/schedule`),
  `manage.sales-projects.post-vp.start` (POST `{id}/post-vp/start`), and the `manage.post-vp.*` group:
  `units.start`, `units.update`, `tasks.store` (`units/{id}/stages/{stage}/tasks`), `tasks.complete`,
  `tasks.reopen`, `tasks.destroy`, `checklist.index|store|update|destroy`
- `routes/main.php` — `main.portal.my-properties.show` (`GET /property/my-properties/{id}`)

**Tests** — `tests/Feature/PostVp/PostVpTrackerTest.php`

## Related modules

- [Engagements & Bookings](/docs/modules_handbook/manage/engagement/readMe.md) — the `Booking` a unit is, and the project Show page ([sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md)) that hosts the Post-VP tab.
- [Dashboard (Main)](/docs/modules_handbook/main/dashboard/readMe.md) — the My properties card.
- [Renovation](/docs/modules_handbook/manage/renovation/readMe.md), Rental Estimates and [Property Concierge](/docs/modules_handbook/main/landlord-management/readMe.md) — the modules the 装修 / 招租 / 出租中 / 卖出 stages will read in phase 2.
