# Booking closeout — the SPA & LO paperwork, and the commission payout

**Portal:** Manage · **Shipped:** 2026-09-21 · **Surfaces:** the `BookingModal`'s **SPA & LO** and
**Payout** tabs, the red **"!"** in the engagement tables' Status cell, the Lead Show Pipeline
tab's **Paperwork** chip, the payout chip under the Commission cell, the **Commission Payout**
headline card and the **Payout** filter ·
**Routes:** `manage.bookings.update` (the tabs save with the booking) ·
`manage.bookings.loan-offer.{store,show,destroy}`

Part of the [Engagements & Bookings handbook](/docs/modules_handbook/manage/engagement/readMe.md).

## What it does

Once a deal **converts**, two jobs are left, and each got a tab on the Edit Booking modal:

1. **Paperwork (SPA & LO).** The sales team records the **SPA signed date**, the **LO signed date**,
   uploads the **loan-offer letter** (LO = the bank's loan offer), and says **who attended** the
   signing. A deal that converted **on or after 1 Aug 2026** (`Booking::PAPERWORK_REQUIRED_FROM`)
   and is still missing any of the four shows a **red "!" in a circle** beside its status. Clicking it
   opens the modal straight onto the SPA & LO tab.
2. **Payout.** How much commission the company has **claimed** from the developer, how much of that
   was **paid**, and the **balance**. Gross commission → (optional) −8% SST → net → claims → balance.
   **Converted deals only** (founder, 2026-10-06): the Payout tab only appears when editing the
   booking of a Converted deal, and every payout figure below counts Converted deals only.

## How it works

### Who owes paperwork

`Booking::requiresPaperwork($engagement)` is true when the engagement is **Converted**
(`STATUS_COMPLETED`, 8) **and** its `won_at` is on or after `PAPERWORK_REQUIRED_FROM`. `won_at` is
stamped each time a deal moves **into** Converted (`EngagementRepository::changeStatus`). Both UI
status controls skip a same-status pick, so re-saving does not move the date.

⚠️ **Known gap — not fixed as of 2026-10-02:** `won_at` means "when this row last moved into
Converted", not "when the sale converted". The legacy booking importer moves every imported row with
`changeStatus()`, so an imported Converted deal gets **`won_at` = the import moment**, not its
booking date — any historic conversion imported on or after 2026-08-01 shows the red "!" and is
asked for paperwork the team was never meant to chase. `won_at` is also **never cleared** when a
deal moves off Converted. See [imports.md](/docs/modules_handbook/manage/engagement/imports.md).

`Booking::paperworkMissing()` returns the keys of `PAPERWORK_ITEMS` still missing:

| key | done when |
|---|---|
| `spa_signed_at` | the date is set |
| `lo_signed_at` | the date is set |
| `loan_offer` | at least one `media` row in collection `loan_offer` is owned by the booking |
| `attendees` | at least one `booking_attendees` row |

The **"!" is server-computed** (`closeout.paperwork_required` + `closeout.paperwork_missing`), so it
changes after a save. **Inside** the modal the checklist and the tab badge are recomputed live from
the form (`utils/bookingCloseout.js → formPaperworkMissing`), so they go green as fields are filled.

When the rule shipped, all 37 Converted deals on Sutera KLCC + Binastra Cochrane had converted in
Aug/Sept 2026, so every one of them started with a "!".

### The LO letter — uploaded instantly, not on Save

The file is a `Src\Common\Media` row owned by the booking (`Booking::loanOfferFiles()`, collection
`Booking::COLLECTION_LOAN_OFFER` → GCS `media/loan_offer/…`), stored through `MediaService` — see the
[Media handbook](/docs/modules_handbook/shared/media/readMe.md).

`BookingLoanOffersController` answers **JSON**:

- `POST {id}/loan-offer` stores one file (PDF / JPG / PNG / WebP, ≤ 20 MB, ≤ 10 per booking —
  `UploadLoanOfferRequest`) and returns the booking's full file list.
- `DELETE {id}/loan-offer/{file}` removes the object and its row via `MediaService::delete()`.
- `GET {id}/loan-offer/{file}` **redirects** to a short-lived signed URL. The page never gets a
  signed URL in its props, because the bucket is private and a signature expires while the page is
  still open. Same pattern as `PaymentClaimsController::receipt`.

All three check `LeadVisibility`. A file uuid is only looked up **within this booking's own
`loan_offer` files**, so a uuid from another booking or another collection returns 404.

**Why instant:** a closer at the bank uploading from a phone should not lose the file because they
forgot to press Save. The trade-off: the file list is **not form state** (`loFiles` in
`BookingModal`). If the modal closes **without** a save after a file changed, it runs
`router.reload()` so the row's "!" catches up.

The uploader's name goes into `media.meta.uploaded_by`, so the list can show who uploaded each file
without a join per file.

### Attended by

`booking_attendees` has one row per booking × staff `users.id`, unique on the pair. It is a child
table like `booking_bankers`: no uuid, no soft delete, and the whole list is replaced on every save
(`BookingRepository::syncAttendees`). The form sends **staff uuids**, and the controller maps them to
ids (`BookingsController::attendeeIds`).

The picker is the house chip + `ComboBox` pattern, the same one `CommissionSplitEditor` uses, over
`assignableAdmins`. The deal's own Team members appear as one-tap suggestions, because they are
usually the people who attended.

"Sales team" here means **people**, not the `teams` table. At build time that table held only one
test row, and the question the business asks is *who was in the room*.

### The payout statement

```
gross commission   = Booking::claimableCommissionAt() (basis price × rate — BEFORE the adjustment)
− SST              = gross − net                     (only when bookings.is_sst_deducted)
net commission     = gross ÷ (1 + SST_RATE/100)      (÷ 1.08 — NEVER gross × 0.92)
claimed / paid     = Σ booking_commission_claims.claimed_amount / .paid_amount
balance            = net − paid
not yet claimed    = net − claimed
```

`Booking::SST_RATE = 8`, and `Booking::netCommission()` mirrors `utils/bookingCloseout.js →
netCommission()`.

⚠️ **The payout is claimed on the commission BEFORE the adjustment** (founder, 2026-10-06). The
developer pays the full rate (e.g. 6% of the net price); the adjustment — a rebate to the buyer, a
cut to developer staff — is paid out of that money only AFTER it arrives. So the Payout tab, the row
chip, the Payout card and the filter all use `Booking::claimableCommissionAt()` (= the legacy
absolute `commission` when one exists, else `autoCommissionAt()`), while the **split** keeps dividing
the adjusted figure (`commissionAt()`). The modal passes `derivedCommission` (not `commissionValue`)
to the tab, and the tab says so in a note whenever an adjustment exists, because the Commission tab
shows the adjusted figure and the two would otherwise look like they disagree.

**The SST box is ticked by default** (2026-10-06): the column default is `true`
(and `Booking::$attributes`, and the modal's form), and migration
`2026_10_06_100001_default_sst_deducted_on_bookings` ticked every existing booking that was still
unticked **and had no claim** — on production that day that was all 499, none of which anyone had
ever set. A deal already being claimed on keeps whatever its admin chose.

The gross stays **derived** and is never stored. The Payout tab reads the modal's live
`derivedCommission`, so changing a price on Booking details changes the net straight away.

**The Payout tab's layout (redesigned 2026-10-06)** — [`BookingPayoutTab.vue`](/resources/js/Components/Sales/BookingPayoutTab.vue)
answers three questions top to bottom, then lists the claims:

1. **Where does it stand?** One plain sentence in the deal's payout-state colour ("Nothing claimed
   yet" / "Waiting for the developer to pay RM x" / "RM x received — RM y still to come" / "Fully
   paid") — `payoutState()` in `bookingCloseout.js`, the mirror of `Booking::payoutState()`, so the
   words match the row chip.
2. **How much is there?** Gross − SST = **Net**, as an equation, with "every claim is measured
   against this" under the net.
3. **How far along?** One bar and three boxes — **Received** · **Claimed, awaiting payment** ·
   **Not claimed yet** — which always add up to the net. (The first version showed Claimed / Paid /
   *Balance*, and "balance" read like "not claimed yet".)

Each claim is two numbered steps in the order they happen: **① Claimed from the developer**, then
**② Payment received** (with a "Received in full" button). Step ① takes the amount **as a % of the
net OR in RM** — [`ClaimShareInput.vue`](/resources/js/Components/Sales/ClaimShareInput.vue), two
linked boxes — plus quick picks **10% / 20% / 30% / 50% / All the rest** (what is unclaimed once
every OTHER claim is counted). Developers pay commission in tranches, so admins think in percent.
⚠️ **Only the RM amount is stored** (`claimed_amount` — an invoice is money); the % is always worked
out from it (`shareOfNet()` / `amountForShare()`), so if the price or the SST setting later changes
the net, the claim keeps its RM and its % moves — never the other way round.

A claim (`BookingCommissionClaim`) is a **money record**, so unlike the other child rows it has a
**uuid, full blame and a soft delete**. `BookingRepository::syncCommissionClaims` matches the
submitted rows **by uuid**:

- a known uuid **updates** that row, keeping its id and its `created_by`;
- a row with no uuid is **created**;
- a stored claim missing from the list is **soft-deleted**, with `deleted_by` recorded.

A wholly blank row (an "Add claim" the admin never filled) is dropped. A row with any other field
filled but no `claimed_amount` is refused (`required_with`).

⚠️ **`reject()`, not `except()`.** On an *Eloquent* collection, `except()` filters by **primary key**
and ignores `keyBy('uuid')`. The first version used it, and every save that edited one claim
soft-deleted all of them.

A claim's state (`awaiting` / `part_paid` / `paid`) is derived from its paid vs claimed amounts
(`BookingCommissionClaim::state()`, mirrored in JS as `claimState()`). The labels come from
`BookingCommissionClaim::STATES` in the payload.

### Record invoice — one developer invoice across many units (2026-10-06)

A developer pays commission on ONE invoice covering many units (INV-00041: 50% on twelve Binastra
units). Opening twelve Payout tabs for it is slow and easy to get wrong, so the project page has a
**Record Invoice** button (header, `manage-leads`) →
[`RecordInvoiceModal.vue`](/resources/js/Components/Sales/RecordInvoiceModal.vue):

1. **The invoice, typed once** — invoice no. + claim date (required), the **share of each unit's net**
   (default 50%), the SST box (ticked; it is SET on every unit recorded, because the amounts are worked
   out on it), "the developer has paid this invoice" + date received, and an optional remark.
2. **Tick the units** — every converted deal of the project the viewer may see, with its net, what is
   already claimed, and "This invoice RM x" (share × net, typed over per unit when the invoice differs;
   a box left empty goes back to share × net). A unit whose claim this line will UPDATE says so, and a
   line that would push the unit's claims past its net is flagged.
3. **Check the total against the paper invoice**, then save.

`POST manage.sales-projects.invoices.store` → `CommissionInvoicesController@store`
(`CommissionInvoices\StoreRequest`: every line must be a CONVERTED deal of THIS project; the
controller also checks `LeadVisibility` per unit) → `BookingRepository::recordInvoice()`, one
transaction, one claim per unit:

- a claim with the **same invoice no.** (case-insensitive) is updated — re-recording the invoice (a
  typo fixed, the payment date added later) never doubles it;
- else a claim with **no invoice no. and the same amount** is updated — the line entered by hand
  before the invoice was;
- else a new claim is created.
- With a payment date the line is marked paid in full; without one, a payment already on an updated
  claim is left alone.

The unit list is the Show page's **optional** Inertia prop `invoiceDeals`
(`SalesProjectsController::invoiceDeals()`), fetched by a partial reload when the modal first opens —
never on the page's own visit. Its `gross` is `claimableCommissionAt()` (before any adjustment).

### The DEAL's payout state (2026-10-06)

Separate from a single claim's state: where a whole **Converted** deal's commission stands —
`Booking::PAYOUT_STATES`, decided by `Booking::payoutState($net, $claimed, $paid)`:

| State | Rule |
|---|---|
| `not_claimed` — Not claimed | nothing claimed |
| `awaiting` — Awaiting payment | claimed, nothing paid |
| `part_paid` — Part paid | something paid, below the net |
| `paid` — Fully paid | paid reaches the net (a deal with no net — no price or rate — never reads "Fully paid") |

`Booking::payout($rate, $basis)` returns the figures (`net`, `claimed`, `paid`, `awaiting` =
claimed − paid, `balance` = net − paid) plus the state; `Engagement::payout()` wraps it and returns
**null unless the deal is Converted**. ONE rule feeds three surfaces, so they can never disagree:

- **Row chip** — `engagementCard()` sends `payout` (+ `label` / `color`); the Commission cell shows
  "Not claimed" / "Awaiting RM x" / "Part paid · Bal RM x" / "Fully paid", with a bar (green = paid,
  amber = claimed-awaiting) once anything is claimed.
- **Commission Payout card** ([`PayoutCard.vue`](/resources/js/Components/Sales/PayoutCard.vue)) — the
  5th headline card on a project's Leads tab (`summary.payout`, over ALL the project's leads) and on
  the booking list (`headline.payout`, over the filtered set). `SalesProjectsController::
  payoutTotals()` sums the converted deals: `paid` / `net`, `awaiting`, `unclaimed` (net − claimed,
  so paid + awaiting + to-claim = net) and a deal count per state (in the card's tooltip). The
  amounts are not links: "to claim" includes the rest of part-claimed deals, so no one filter state
  lists exactly the deals behind it.
- **Payout filter** — `payout[]` on `BookingQueryRequest` (and so `ProjectLeadsQueryRequest`).
  The state is derived (price × rate, SST, claims), so `Engagement::idsInPayoutStates()` works it out
  in PHP over the converted set and the list takes `whereIn(id)` — fine at hundreds of converted
  deals; revisit if that set grows into the tens of thousands. The CEO suite's Property Closing list
  does not offer it.

### One payload, two builders

`Src\Engagement\Support\BookingCloseout::for($booking, $engagement)` builds the `closeout` key that
**both** booking serializers send:

- `SalesProjectsController::bookingPayload()` — the Sales list and the project Show page;
- `LeadsController::bookingCard()` — the Lead Show Pipeline tab.

Callers eager-load `...BookingCloseout::eagerLoads()` (`attendees.user.profile`, `commissionClaims`,
`loanOfferFiles` under `booking.`).

### "Absent = leave alone" — the save contract

The closeout rides the existing `PUT manage/bookings/{id}`, under the same rule as bankers and roles:

| payload | effect |
|---|---|
| key **absent** | untouched |
| `attendees: []` / `claims: []` | cleared (claims soft-deleted) |
| `is_sst_deducted` absent | untouched (the controller maps it only when `has()`) |

`BookingModal`'s `submit()` removes `claims` + `is_sst_deducted` whenever the Payout tab is not
shown (on create, or when a payload has no `closeout`). It also removes `attendees` when an edit
payload has no `closeout`. The status-change path (`ChangeEngagementStatus`) never sends any of
them.

The **create** form also shows the SPA & LO tab: the dates and attendees save through
`manage.engagements.bookings.store`, and the upload area says it opens once the booking is recorded.

### Where it shows

- **Status cell** ([`StatusCell.vue`](/resources/js/Components/Sales/StatusCell.vue)) — the red "!"
  sits beside the status pill. Its tooltip lists what is missing. It emits `paperwork`, and
  `EngagementTable` opens `BookingModal` with `initial-tab="paperwork"`.
- **Lead Show → Pipeline tab** — a red **"! Paperwork"** chip on the booking card does the same.
- **Dates cell** ([`BookingCells.vue`](/resources/js/Components/Sales/BookingCells.vue)) — a
  paperclip beside the LO date when a letter is on file.
- **Commission cell** ([`CommissionCell.vue`](/resources/js/Components/Sales/CommissionCell.vue)) —
  Converted deals only: the deal's payout chip (see *The DEAL's payout state*), plus a bar of
  paid / awaiting as shares of **net** once a claim exists. Read from the server's `row.payout`
  (it used to be computed in the browser and only appeared once a claim existed — so the deals
  nobody had claimed, the ones to chase, showed nothing).
- **Modal tab strip** — SPA & LO shows a red "!" (owed), a green tick (complete) or `n/4`. Payout
  shows "Bal RM x" or a tick. **The Payout tab only exists on a Converted deal's booking**
  (`BookingModal`'s `hasPayout`); hidden, it posts no `claims`, so claims an older Booked deal
  already holds are kept, not wiped.

## Related files

**Backend**
- [src/Engagement/Booking.php](/src/Engagement/Booking.php) — `PAPERWORK_REQUIRED_FROM`, `PAPERWORK_ITEMS`, `COLLECTION_LOAN_OFFER`, `SST_RATE`; `attendees()` / `commissionClaims()` / `loanOfferFiles()`; `requiresPaperwork()` / `paperworkMissing()` / `netCommission()`; `PAYOUT_STATES` / `payout()` / `payoutState()`
- [src/Engagement/BookingAttendee.php](/src/Engagement/BookingAttendee.php) · [src/Engagement/BookingCommissionClaim.php](/src/Engagement/BookingCommissionClaim.php)
- [src/Engagement/Support/BookingCloseout.php](/src/Engagement/Support/BookingCloseout.php) — the shared payload + `eagerLoads()`
- [src/Engagement/Repositories/BookingRepository.php](/src/Engagement/Repositories/BookingRepository.php) — `syncAttendees()` / `prepareClaims()` / `syncCommissionClaims()`
- [app/Http/Controllers/Manage/Engagements/BookingLoanOffersController.php](/app/Http/Controllers/Manage/Engagements/BookingLoanOffersController.php) · [UploadLoanOfferRequest.php](/app/Http/Requests/Manage/Engagements/Bookings/UploadLoanOfferRequest.php)
- [BookingsController.php](/app/Http/Controllers/Manage/Engagements/BookingsController.php) (maps the closeout keys) · [StoreRequest.php](/app/Http/Requests/Manage/Engagements/Bookings/StoreRequest.php) (`attendees`) · [UpdateRequest.php](/app/Http/Requests/Manage/Engagements/Bookings/UpdateRequest.php) (`is_sst_deducted`, `claims.*`)

**Frontend**
- [resources/js/Components/Sales/BookingPaperworkTab.vue](/resources/js/Components/Sales/BookingPaperworkTab.vue) · [BookingPayoutTab.vue](/resources/js/Components/Sales/BookingPayoutTab.vue) · [ClaimShareInput.vue](/resources/js/Components/Sales/ClaimShareInput.vue) (claim as % or RM) · [PayoutCard.vue](/resources/js/Components/Sales/PayoutCard.vue)
- [resources/js/utils/bookingCloseout.js](/resources/js/utils/bookingCloseout.js) (+ `bookingCloseout.test.js`)
- [BookingModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/BookingModal.vue) (four tabs, `initialTab`) · [StatusCell.vue](/resources/js/Components/Sales/StatusCell.vue) · [EngagementTable.vue](/resources/js/Components/Sales/EngagementTable.vue) · [PipelineTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/PipelineTab.vue)

**Migration** — `2026_09_21_100001_add_booking_closeout`. It adds `bookings.is_sst_deducted` and
creates `booking_attendees` + `booking_commission_claims`. Every step is guarded, so the migration
is order-independent.

## Not built (yet)

- **Export columns.** The booking export (`ProjectLeadsExport`) does not yet carry the paperwork
  state, SST, claimed / paid or balance.
- **A "paperwork missing" filter / count** on the booking list, so the whole backlog can be pulled up
  in one view instead of scanning for the "!".
