# 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, and the payout bar under the Commission cell ·
**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.

## 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.

`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::commissionAt()        (basis price × rate ± adjustment — the Commission tab's figure)
− 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 gross stays **derived** and is never stored. The Payout tab reads the modal's
live `commissionValue`, so changing a price on Booking details changes the net straight away.

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.

### 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)) —
  once a claim exists: a thin bar showing paid as a share of **net**, plus "Bal RM x" or
  "Fully paid".
- **Modal tab strip** — SPA & LO shows a red "!" (owed), a green tick (complete) or `n/4`. Payout
  shows "Bal RM x" or a tick.

## 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()`
- [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)
- [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 "!".
