# The Booking, the SPA state, and the status ⇄ booking invariant

**Portal:** Manage · **Model:** [`Src\Engagement\Booking`](/src/Engagement/Booking.php) ·
**Routes:** `manage.engagements.bookings.store` · `manage.bookings.{update,cancel}` ·
**Surfaces:** the `BookingModal` (opened from both engagement tables and the Lead Show Pipeline
tab) and the `StatusChangeModal`

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

## What it does

A **booking is the closer's outcome** — the unit a lead actually booked on a project. It belongs to
one engagement, and captures the unit **inline**: `unit_no`, `block`, `floor`, `built_up`, the two
prices (`spa_price` / `net_price`), the `booking_fee`, `booking_no` and `booking_date`, plus the SPA
paperwork (`spa_signed_at`, `lo_signed_at`, its **bankers**, `loan_margin`, `leader_review_result`) and an
optional `catalog_floor_plan_id` link to a unit *type*.

**There is no unit-inventory table in Phase 1.** A booking does not reserve a unit anywhere; it
records one.

## How it works

### Constants

| Set | Values |
|---|---|
| **`STATUSES`** (`bookings.status`) | `1 Active` (brand) · `2 Cancelled` (rose) · `3 Completed` (emerald) |
| **`SPA_STATUSES`** | `1 N/A` (slate) · `2 Pending` (amber) · `3 Signed` (emerald) · `4 Not Signed` (rose) |
| **`LEADER_RESULTS`** | `1 Saved` (emerald) · `2 Not Saved` (rose) |

⚠️ **`SPA_NA` (1) is unreachable** — `spaState()` can only return 2, 3 or 4. It survives so the map
is complete.

⚠️ **`STATUS_COMPLETED` (3) is import-only.** `create()` writes ACTIVE, `update()` cannot write
`status` at all, `markCancelled()` writes CANCELLED and `reactivate()` writes ACTIVE — so **only
`importLegacy()` ever produces a Completed booking**. A deal converted through the UI keeps its
booking on ACTIVE. Two readers outside this module special-case that by scoping
`whereIn('status', [ACTIVE, COMPLETED])`: `Sales\DashboardController` and the `ReapNoBooking`
command.

⚠️ **`leader_review_result` gates nothing.** It appears in the model accessor, the `update()`
whitelist, both payloads and the export — and nowhere else. Both the `Booking` class docblock and
the create-migration docblock claim "a booking cancelled after an unsaved leader review pushes its
engagement to Lost"; in code, **every** cancel pushes to Lost, review or not.

### The SPA state is DERIVED — nobody keys it in

```php
public function spaState(): int
{
    if ((int) $this->status === self::STATUS_CANCELLED
        || (int) ($this->engagement?->status ?? 0) === Engagement::STATUS_LOST) {
        return self::SPA_NOT_SIGNED;
    }

    return $this->spa_signed_at !== null
        || (int) ($this->engagement?->status ?? 0) === Engagement::STATUS_COMPLETED
            ? self::SPA_SIGNED
            : self::SPA_PENDING;
}
```

It was a stored field that only ever restated three facts the deal already carries — and, being
separately editable, could contradict them:

- the deal **died** (engagement Lost, or this booking cancelled) → the SPA was never signed;
- the deal **converted**, or a signing date is on file → signed. **Converted counts on its own**,
  because a converted sale *is* a signed one — the assumption the legacy importer already made when
  it stamped the old column, and **155 imported rows carry Signed with no date to back it**;
- otherwise → still pending.

Measured against the production snapshot the derivation reproduces the stored column on **494 of
495** bookings; the single exception is a dead deal left marked "Pending", which the derivation
corrects.

`spa_status_label` / `spa_status_color` read `spaState()`, so **every surface follows
automatically**. Because it is a view of the deal's CURRENT facts, it un-says itself: Booked → Lost
→ Booked reads Pending → Not Signed → Pending with nobody editing an SPA field.

⚠️ **The tone map that renders it must cover `emerald`.** A Converted deal derives to *Signed* with
no date on file, so a rose/amber-only map would print "Signed" in the pending colour. Pinned by
`SalesProjectsIndexTest::test_the_spa_line_round_trips_through_lost_and_back`.

⚠️ **`spaState()` lazy-loads `engagement`.** In a list, eager-load `engagement` (or
`booking.engagement`) or the SPA label is an N+1. And on a booking whose engagement is
**soft-deleted**, the relation resolves to null, the `?? 0` makes it neither Lost nor Completed, and
the state reads **Pending**.

⚠️ **`bookings.spa_status` still exists, is indexed, and is still written by `importLegacy()` — but
nothing reads it.** Pinned by `SalesProjectsIndexTest::test_the_stored_spa_status_column_is_ignored`.
See [retired.md](/docs/modules_handbook/manage/engagement/retired.md).

### The bankers are rows, not a column

A deal is routinely shopped to several banks, and the banker who actually got the loan approved is
the person the closer needs to ring. `bookings.bank` — one string, no name, no phone — could express
neither, so it was **dropped** on 2026-08-12 for
[`BookingBanker`](/src/Engagement/BookingBanker.php) (`booking_bankers`: `bank` / `name` / `phone` /
`remark`, one row per banker).

⚠️ **Dropped, not kept alongside.** Two places holding "which bank financed this" can only drift, so
every read path moved in the same change — the Bank filter, the Bank/Margin cell, the sort and the
export. The cell shows the FIRST banker's bank plus a `+N` when there are more (the row has space for
one line, and the count is what tells a reader to open the booking); the full list is in its `title`.

A CHILD row like `EngagementAssignment`: **no uuid and no soft delete**, because the list is re-cut
wholesale on save rather than addressed one row at a time from a URL.

**The phone is the shared [`PhoneInput`](/resources/js/Components/PhoneInput.vue)**, so a banker's
number is stored the way every other phone in the system is — digits with the country code and no
trunk zero (`60123456789`, per `Src\Common\Support\PhoneNumber`). Typing a local `012…` against
`+60` yields `6012…`, which is what makes the number dialable from a click and comparable to a
lead's. ⚠️ That component's country list used to open DOWNWARD only; it now flips up when there is
no room below, because the last banker card sits at the bottom of a scrolling modal and the list
would otherwise open into the cropped edge — the same fix the shared `ComboBox` already carried.

**`BookingRepository::syncBankers()` — the three rules that matter**

| Payload | Meaning |
|---|---|
| key **absent** (`null`) | "this form did not render bankers" — leave them alone |
| **empty array** | "the admin removed them all" — clear them |
| rows | replace the list with exactly these |

⚠️ The absent-vs-empty distinction is not cosmetic: without it every partial write — the inline
status-change booking, the legacy CSV importer — would silently wipe a banker list it never showed.
It is the same rule `BookingsController` applies to an absent `closing_mode_id` and `assign()` to
absent role keys. A row with **no bank is dropped**: the bank is what makes a banker findable, and a name alone
can be neither filtered nor rung. Pinned by `SalesProjectsIndexTest::test_a_booking_records_several_bankers_and_re_cuts_them_on_save`
and `::test_a_save_without_the_bankers_key_leaves_them_alone`.

**The picker's bank list is a SUGGESTION, not a whitelist.** It lives in
[`resources/js/utils/banks.js`](/resources/js/utils/banks.js) — deliberately NOT a PHP constant and
NOT a table, because nothing server-side validates, normalises or reads it: the request checks
`max:120`, not membership, so a foreign or newly licensed bank is never the reason a closer cannot
record who financed a deal. The `ComboBox` runs `allow-custom`, which adds a "Use …" row and commits
typed text on Enter or blur. It is a fixed list rather than "whatever has been typed before" because
offering the recorded values back would make the first typo a permanent option — and consistent
spelling is the whole reason the Bank filter works. Move it to `BookingBanker` the day the backend
needs it (normalising an imported "MBB" to "Maybank", say), and pass it as a prop rather than keeping
a copy on each side.

⚠️ **The filter reaches through TWO relations and still never joins** —
`whereHas('booking.bankers', …)`. A booking can have several bankers and an engagement several
bookings, so a join would return the same deal once per banker and inflate the paginator count.
The sort uses a correlated subquery for the same reason (a join *inside* a scalar subquery is safe —
it cannot multiply the outer row).

**The export gets its own `Bankers` column** beside `Bank`: a spreadsheet has no hover and no modal,
so without it the file would claim a deal had one banker when it had three.

### Commission on the booking

Two methods, both derivations. The full treatment — including the readers, the estimate, and the
split between teammates — is in
[commission.md](/docs/modules_handbook/manage/engagement/commission.md); the model's half:

- **`autoCommissionAt($rate, ?int $basis = null): ?float`** — the DERIVED figure,
  `commissionBaseAmount($basis) × $rate / 100`. Its own method because every surface that lets an
  admin adjust the number must show what it is adjusting FROM.
- **`commissionAt($rate, ?int $basis = null): ?string`** — that figure **plus** the signed
  `commission_adjustment`. Returns a 2-dp decimal **string** (matching the money casts) or null.
  ⚠️ A legacy absolute `bookings.commission` still wins outright when set; nothing writes it any more.
- **`commissionBaseAmount(?int $basis = null): ?float`** — the project's `commission_basis` picks
  the primary price (`1 = SPA` → `spa_price`, `2 = Net` → `net_price`), **and the other price is the
  only fallback**, so a booking with just one price still yields a figure.

⚠️ **The chain is two links, not three.** There is no legacy `price` arm anywhere — the
`bookings.price` column was dropped by
`2026_07_31_100001_retire_legacy_price_and_dead_columns_on_bookings` after its values were
backfilled into `net_price`. ⚠️ `PipelineTab.vue` still renders `e.booking.price`, which is dead
markup: the payload no longer carries the key, so the line is permanently hidden.

### `BookingRepository` — eight public methods

| Method | Transaction | Engagement side effect | Notes |
|---|---|---|---|
| `create(Engagement, array)` | ✅ | **advances the engagement to `BOOKED`** | inherits `lead_id` / `project_id`; forces `status = ACTIVE`; **copies** `closing_mode_id` off the engagement (a mirror, never a decision) |
| `update(Booking, array)` | ✅ | none | the only writer of `leader_review_result` |
| `importLegacy(Engagement, array, ?CarbonInterface)` | ✅ | **none** — the importer sets the final stage itself | the only writer of `spa_status`, `status` and `legacy_ref`; backdates `created_at` |
| `cancel(Booking, ?string)` | ✅ | **pushes the engagement to `LOST`** | the reason goes to the ENGAGEMENT (`lost_reason`) — the booking holds none |
| `markCancelled(Booking)` | ✅ | none | the paperwork half of `cancel()`; status only |
| `reactivate(Booking)` | ✅ (skipped when not cancelled) | none | CANCELLED → ACTIVE |
| `delete(Booking)` | ✅ | none | **soft** delete — "release the unit" |

**Why `cancel()` and `markCancelled()` are two methods.** A status change to Lost needs exactly the
paperwork half: the deal died, so its booking is cancelled rather than deleted — but the *caller*
(`ChangeEngagementStatus`) owns the stage move and must apply it last. If it called `cancel()` the
status would be written twice and the ordering rule would break.

⚠️ **The closing mode is NOT a booking field** (2026-08-12). It belongs to the deal and lives on
`engagements.closing_mode_id`; both `BookingsController@store` and `@update` route it to
[`EngagementRepository::setClosingMode()`](/src/Engagement/Repositories/EngagementRepository.php),
inside the same transaction as the booking write, and that writer mirrors it onto every booking of
the deal so the old column can be dropped on its own. `bookings.closing_mode_id` is read by nothing.
A null in the payload still means "not chosen on this form", never "clear it" — so **there is still
no way to clear a closing mode through the API**. Full account in
[closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md).

⚠️ **A booking holds NO reason of its own.** `bookings.cancellation_reason` was **dropped on
2026-08-11** (`2026_08_11_100001`) and `engagements.special_remark` renamed **`lost_reason`** in the
same migration. They had been one fact written twice: a booking is cancelled *if and only if* its
engagement goes Lost — `cancel()` pushes the engagement to LOST itself, and `ChangeEngagementStatus`
cancels the booking on the way into LOST — so both lanes always wrote the same sentence into both
columns, and two copies of one fact can only drift.

**The engagement is the one that must hold it**, because a deal can die BEFORE it is ever booked and
**189 of them have**; a booking-side column has nothing to say about those, while the reverse never
happens (a cancelled booking always has a Lost engagement to read the reason off). The duplicate was
never even populated: **204** cancelled bookings, `cancellation_reason` NULL on every one.

The name is the other half of the fix. `special_remark` said nothing about when it is filled or what
it means — `lost_reason` says both, and it is what the UI had always called it.

⚠️ **`reopen()` never touches the booking.** A re-opened deal sits at `NEW` carrying a CANCELLED
booking row, so `engagement.booking` is non-null at a non-booking status — which is what the
frontend's `releasesBooking()` predicate keys on.

### The status ⇄ booking invariant

**The rule the write path enforces:** a booking-bearing status — `6 Booked`, `7 Following Up`,
`8 Converted` (`ChangeEngagementStatus::BOOKING_STATUSES`) — always has a live booking behind it,
and only those statuses do. Everything below exists to hold it, which is why a status change is not
a plain `update`.

| Move | The booking | The status |
|---|---|---|
| **Enter** a booking status, none on file | `create()` — which itself advances the engagement to Booked | the chosen status is written **last** and wins |
| **Enter / hop** with a booking on file | `reactivate()` (no-op unless cancelled), then `update()` **only if a submitted field was non-empty** | `changeStatus()` |
| **Leave to Lost** | `markCancelled($booking, $remark)` — the unit, price and dates *are* the record of what was lost | `changeStatus()` also stamps `lost_at` + `lost_stage` |
| **Leave to anything else** | `delete()` — soft, no reason: the unit really is going back on the market | gated by `release_booking` being **`accepted`** |

- **Entering with no booking requires `unit_no` + `spa_price`** (`Rule::requiredIf(needsNewBooking())`),
  so a booked row can never be hollow — and its commission stops being an estimate.
- **A hop only rewrites the booking when something was filled** (`array_filter(...) !== []`), which
  is what makes Booked → Following Up safe.
- **Going Lost keeps the paperwork.** Marking a deal lost therefore asks for **the reason and
  nothing else**. *(Until 2026-08-05 this path soft-deleted the booking and demanded a
  `release_booking` tick to do it: recording an outcome cost you the paperwork, which is not a trade
  an admin should be offered.)*
- **The release is never implicit.** `release_booking` must be `accepted`, and `Rule::excludeIf`
  removes the field entirely when the move releases nothing — **including a move to Lost** — so
  ordinary changes carry no such field. ⚠️ That Lost exclusion is mirrored in **three** places
  (`StatusRequest::releasesBooking()`, `engagementStatus.js`'s `releasesBooking()`, and
  `ChangeEngagementStatus::handle()`); change one and you must change all three.
- **Ordering is load-bearing.** `BookingRepository::create()` advances the engagement to Booked as a
  side effect, so the *chosen* status is applied **last**; otherwise picking "Converted" on a first
  booking would silently land on "Booked". Pinned by
  `test_moving_to_converted_with_booking_details_records_the_booking_and_keeps_the_chosen_status`.
- **Two doors, one room.** `BookingRepository::cancel()` (the Pipeline tab's *Cancel booking*)
  reaches the same end state from the other side. Either door leaves the same state, so an admin can
  use whichever they find first.

⚠️ **The invariant is what the WRITE PATH enforces, and one state still departs from the absolute
wording** — so a query written against "exactly one" will be wrong:

- **An engagement can hold MANY live bookings.** `booking()` is `latestOfMany()`, and the legacy
  importer creates one ACTIVE row per CSV line — a repeat buyer's rows all land on the same
  engagement. Every UI and every sort reads only the latest (`order by bk.id desc limit 1`), and
  booking-side filters use `whereHas('booking', …)` **never a join**, so the paginator count is not
  inflated.

The other direction — a booking-bearing status with **no** live booking — used to be reachable by
removing a Booked deal and adding the lead back, because `delete()` soft-deletes the bookings while
`open()` restores only the engagement. That is closed: **`open()` now resets a restored row to
`NEW`**. See [lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md).

### The two modals

**[`BookingModal.vue`](/resources/js/Pages/Manage/Leads/Partials/Pipeline/BookingModal.vue)** — two
tabs, both kept mounted with `v-show` so no field state is lost when flipping, each with its own
red-dot error indicator.

- **Booking details** — four sections: *Unit* (`floor_plan_id` select, shown only when the project
  has plans, then `unit_no` / `block` / `floor` / `built_up`), *Price* (`spa_price` / `net_price` /
  `booking_fee`), *Booking & SPA* (`booking_no`, the three dates, and `leader_review_result` **on
  edit only**), *Financing* (**bankers**, `loan_margin`). ⚠️ **There is no SPA-status field** — the state
  is derived.
- **Commission (& Split)** — the whole tab is now the shared
  [`CommissionSplitEditor`](/resources/js/Components/Sales/CommissionSplitEditor.vue), mounted with
  the SAME props the Assign modal passes: what the deal pays (the derivation, plus a signed
  adjustment that must say why), how it was closed, which payment paid for it, and who splits it.
  The tab header carries the live total as the price or the adjustment is typed. It mounts only when
  `isEdit && closingModes.length > 0` — a booking being created has no split to divide yet. See
  [commission.md](/docs/modules_handbook/manage/engagement/commission.md).
- **`submit()` strips `roles`, `purchase_history_id` and both commission-adjustment fields via
  `form.transform()`** whenever the editor was not on screen: the server treats an **absent** `roles` key as "leave every role
  alone", so posting an empty team would read as "clear everyone".

Both callers feed the modal from **one payload builder**,
`SalesProjectsController::bookingPayload()`, so the same modal cannot behave differently depending
on where it was opened.

⚠️ **Every payload builder must send all three commission keys** — `commission_auto` (what the
adjustment is measured from), `commission_adjustment` and `commission_adjustment_reason` — alongside
the resolved `commission_value`. The shared editor seeds itself from them and the save writes them
back, so a builder that omits them makes every edit opened from its surface hydrate blank and wipe
the adjustment the closer recorded. That is what `LeadsController::bookingCard()` did with the old
`commission` key until it was fixed; both builders now send the full set. Pinned by
`EngagementLifecycleTest::test_the_lead_pipeline_payload_carries_the_commission_and_its_adjustment`.

⚠️ **`booking_fee` is validated `numeric`, not `integer`, and must stay that way.** The column is
`decimal(12,2)` and the model casts it `decimal:2`, so the payload hands the form back the **string**
`"1500.00"` — which `integer` rejects (`filter_var("1500.00", FILTER_VALIDATE_INT)` is `false`),
failing any save where the admin had not retyped that one field. Legacy-imported rows are the ones
that trip it, because the 2026-07-17 migration widened the column precisely so the export's cents
would fit. The rule is mirrored in all three requests (`Bookings\StoreRequest`, `StatusRequest`,
`StoreLeadRequest`). Pinned by
`EngagementLifecycleTest::test_a_booking_fee_with_cents_round_trips`.

**[`StatusChangeModal.vue`](/resources/js/Pages/Manage/Leads/Partials/Pipeline/StatusChangeModal.vue)**
is the one place a status changes, shared by both surfaces so the rules cannot drift. Its form is
deliberately narrower — **no `commission`, no `leader_review_result`, no `closing_mode_id`, no
`roles`** — so a status save can never set a manual commission. It renders three conditional panels:
an amber *"The booking is kept"* when the move is to Lost, a red *"This removes the booking"* with
the release tick when the move releases, and the booking-details grid when the move enters a booking
status. The release tick is a **gate, not a warning**: the button stays dead until it is checked
(the server still enforces it — this is only the friendlier half).

⚠️ Its live commission preview does **not** fall back to the other price, unlike the model and the
BookingModal — a booking with only a net price under an SPA basis previews blank there but saves
with a real figure.

[`utils/engagementStatus.js`](/resources/js/utils/engagementStatus.js) holds the mirrored constants
and **`needsStatusModal()`** — the single predicate deciding when a move must stop to *collect* (a
Lost reason, a required booking) or *confirm* (the release) instead of firing immediately. Note
`STATUS.CONVERTED = 8`: the JS uses the display name, PHP uses `STATUS_COMPLETED`.

## Related files

**Backend**
- [src/Engagement/Booking.php](/src/Engagement/Booking.php) — the constants, `spaState()` + its two accessors, `commissionAt()` / `commissionBaseAmount()` / `commission_value`, and the `closingMode()` (`withTrashed`) + `floorPlan()` relations
- [src/Engagement/Repositories/BookingRepository.php](/src/Engagement/Repositories/BookingRepository.php) · [its facade](/src/Engagement/Facades/BookingRepository.php)
- [app/Actions/ChangeEngagementStatus.php](/app/Actions/ChangeEngagementStatus.php)
- [app/Http/Controllers/Manage/Engagements/BookingsController.php](/app/Http/Controllers/Manage/Engagements/BookingsController.php) — `store` / `update` / `cancel`; `update` shares ONE transaction across the booking write, the team assign and the receipt choice
- [app/Http/Requests/Manage/Engagements/Bookings/StoreRequest.php](/app/Http/Requests/Manage/Engagements/Bookings/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Manage/Engagements/Bookings/UpdateRequest.php) (adds `leader_review_result`, `purchase_history_id`, `roles`) — **both** validate `closing_mode_id` since 2026-08-12; the controller routes it to the engagement · [CancelRequest.php](/app/Http/Requests/Manage/Engagements/Bookings/CancelRequest.php) (the reason is required)
- [app/Http/Requests/Manage/Engagements/StatusRequest.php](/app/Http/Requests/Manage/Engagements/StatusRequest.php)

**Frontend**
- [resources/js/Pages/Manage/Leads/Partials/Pipeline/BookingModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/BookingModal.vue) · [StatusChangeModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/StatusChangeModal.vue)
- [resources/js/utils/engagementStatus.js](/resources/js/utils/engagementStatus.js)
- [resources/js/Components/Sales/BookingCells.vue](/resources/js/Components/Sales/BookingCells.vue) — the SPA line and its tone map

**Migrations** — `2026_07_14_100002_create_bookings_table` · `2026_07_17_100001_add_legacy_fields_to_bookings_table` (widens the money columns to `decimal(12,2)`, adds `commission` + `legacy_ref`) · `2026_07_17_100003_retire_legacy_floor_plans` (adds `catalog_floor_plan_id`, bridges the legacy ids, aborts if one cannot be mapped) · `2026_07_26_100001_add_booking_deal_fields_and_commission_basis` (`spa_price` / `net_price` / `lo_signed_at` / `bank` / `loan_margin`) · `2026_07_28_100001_make_booking_spa_signed_at_date_only` · `2026_07_31_100001_retire_legacy_price_and_dead_columns_on_bookings` (backfills `price` → `net_price`, then drops `price` / `floor_plan_id` / `commission_rate`) · `2026_08_03_200001_create_closing_modes_and_pipeline_roles` (renames `closing_type` → `closing_mode_id`) · `2026_08_12_100001_create_booking_bankers_table` (**`bookings.bank` becomes the `booking_bankers` child table** — a deal is routinely shopped to several banks, and one column could only ever hold the last one named; backfills before dropping). Full column table in [perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md).

**Tests** — [SalesProjectsIndexTest](/tests/Feature/Engagement/SalesProjectsIndexTest.php) pins the derived SPA state (incl. the round trip through Lost and the ignored stored column) and every transition of the invariant; [EngagementLifecycleTest](/tests/Feature/Engagement/EngagementLifecycleTest.php) pins the create/cancel side effects and the floor-plan-belongs-to-this-project guard.

## Related chapters
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) ·
[commission.md](/docs/modules_handbook/manage/engagement/commission.md) ·
[imports.md](/docs/modules_handbook/manage/engagement/imports.md) ·
[retired.md](/docs/modules_handbook/manage/engagement/retired.md)
