# The perimeter — routes, permissions, scoping, schema, callers

**Portal:** Manage · **The reference chapter.** Open this before adding a surface, changing a
column, or assuming who can see what.

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

## Routes

**56 routes across six name groups** (counted 2026-10-02: engagements 9 · bookings 5 · sales-projects
25 · closing-modes 8 · pipeline-roles 6 · leads.referrals 3), plus `manage.sales.pipeline` which this
module's controller also serves. It read 40 until 2026-10-02 — the zoom-closer, loan-offer, catalogue
pairing, buyer's-unit and Post-VP routes had been added without the table following. ⚠️ **Do not
trust that number** — re-derive it with
`php artisan route:list --name=sales-projects` (and the five other prefixes) rather than a figure
written here.

Every route below sits inside the `/manage` group (`auth` + `admin`). `{id}` is always a **uuid**,
resolved by an explicit `where('uuid', $id)->firstOrFail()` — never route-model binding
(GUIDELINES §3).

### `manage.engagements.*` — group gate `view-leads-*` (any)

| Verb + URI | Name | Action | Extra |
|---|---|---|---|
| `POST /manage/engagements` | `.store` | `EngagementsController@store` | `manage-leads` |
| `PUT /manage/engagements/{id}/status` | `.status` | `@updateStatus` | `manage-leads` |
| `PUT /manage/engagements/{id}/assign` | `.assign` | `@assign` | `manage-leads` |
| `PUT /manage/engagements/{id}/remark` | `.remark` | `@updateRemark` — corrects the LOST reason **without** re-stamping `lost_at` / `lost_stage`; 404 unless the deal is Lost | `manage-leads` |
| `PUT /manage/engagements/{id}/zoom-closer` | `.zoom-closer` | `@updateZoomCloser` — sets / clears `zoom_closer_id` (tracking only, never a pipeline role) | `manage-leads` |
| `GET /manage/engagements/{id}/payment-candidates` | `.payment-candidates` | `@paymentCandidates` | `manage-leads` |
| `POST /manage/engagements/{id}/reopen` | `.reopen` | `@reopen` | `manage-leads` |
| `DELETE /manage/engagements/{id}` | `.destroy` | `@destroy` | `manage-leads` |
| `POST /manage/engagements/{id}/bookings` | `.bookings.store` | `BookingsController@store` | `manage-leads` |

### `manage.bookings.*` — group gate `view-leads-*` (any)

| Verb + URI | Name | Action | Extra |
|---|---|---|---|
| `PUT /manage/bookings/{id}` | `.update` | `BookingsController@update` | `manage-leads` |
| `POST /manage/bookings/{id}/cancel` | `.cancel` | `@cancel` | `manage-leads` |
| `POST /manage/bookings/{id}/loan-offer` | `.loan-offer.store` | `BookingLoanOffersController@store` (JSON) | `manage-leads` |
| `GET /manage/bookings/{id}/loan-offer/{file}` | `.loan-offer.show` | `@show` — redirects to a short-lived signed URL | — |
| `DELETE /manage/bookings/{id}/loan-offer/{file}` | `.loan-offer.destroy` | `@destroy` (JSON) | `manage-leads` |

### `manage.sales-projects.*` — group gate `view-projects`

| Verb + URI | Name | Action | Extra |
|---|---|---|---|
| `GET /manage/sales-projects` | `.index` | `SalesProjectsController@index` | — |
| `POST /manage/sales-projects` | `.store` | `@store` | `manage-projects` |
| `POST import/preview` | `.import-preview` | `ProjectsImportController@preview` | `manage-projects` |
| `POST import` | `.import` | `ProjectsImportController@import` | `manage-projects` |
| `GET search` | `.search` | `@search` | — |
| `GET catalogue/search` | `.catalogue.search` | `@catalogueSearch` — candidates to PAIR a project with | — |
| `GET export` | **`.bookings.export`** | `@exportBookings` — the CROSS-PROJECT booking list | — |
| `POST {id}/bookings/import/preview` | `.bookings.import-preview` | `BookingsImportController@preview` | `manage-leads` |
| `POST {id}/bookings/import` | `.bookings.import` | `BookingsImportController@import` | `manage-leads` |
| `POST {id}/leads` | `.leads.store` | `@storeLead` | `manage-leads` |
| `POST {id}/highlight` | `.highlight` | `@highlight` | `manage-projects` |
| `GET {id}/export` | `.export` | `@export` — one project's Leads tab | — |
| `PUT {id}/post-vp/schedule` | `.post-vp.schedule.update` | `Manage\PostVp\ProjectSchedulesController@update` | `manage-projects` |
| `POST {id}/post-vp/start` | `.post-vp.start` | `ProjectSchedulesController@start` | `manage-projects` |
| `PUT {id}/post-vp/stages/{stage}` | `.post-vp.stages.update` | `ProjectSchedulesController@bulkStage` | `manage-projects` |
| `PUT bookings/{id}/unit-economics` | `.bookings.unit-economics.update` | `@updateUnitEconomics` — the NARROW booking writer (`{id}` is a **booking** uuid) | `manage-leads` |
| `POST {id}/layouts/assign-by-price` | `.layouts.assign-by-price` | `@assignLayoutsByPrice` (preview first) | `manage-leads` |
| `PUT {id}/layouts/{plan}/rent-basis` | `.layouts.rent-basis.update` | `@setRentBasis` (catalogue admin domain only, else 403) | `manage-projects` |
| `DELETE {id}/layouts/{plan}/rent-basis` | `.layouts.rent-basis.destroy` | `@clearRentBasis` | `manage-projects` |
| `GET {id}/catalogue/preview` | `.catalogue.preview` | `@cataloguePreview` (JSON — the Property Preview tab) | — |
| `POST {id}/catalogue` | `.catalogue.pair` | `@pairCatalogue` | `manage-projects` |
| `DELETE {id}/catalogue` | `.catalogue.unpair` | `@unpairCatalogue` | `manage-projects` |
| `GET {id}` | `.show` | `@show` | — |
| `PUT {id}` | `.update` | `@update` | `manage-projects` |
| `DELETE {id}` | `.destroy` | `@destroy` | `manage-projects` |

⚠️ **The two export names are easy to swap:** `.export` is the per-project file (`{id}/export`);
the cross-project file is **`.bookings.export`** (bare `export`).

⚠️ **Declaration order is load-bearing.** `search`, `catalogue/search`, `export`,
`{id}/bookings/import*`, `{id}/export` and the other `{id}/…` literals are all declared **before**
`GET {id}`, or their literal segments would be matched as a project uuid and 404.

### `manage.closing-modes.*` — group gate `view-closing-modes`

`index` (GET) · `store` · `update` · `activate` · `inactivate` · `payment-default` ·
`manual-default` · `destroy` — every write additionally gated on `manage-closing-modes`.

### `manage.pipeline-roles.*` — **the whole group** is gated on `manage-closing-modes`

`store` · `update` · `activate` · `inactivate` · `roll-target` · `destroy`.

⚠️ **There is no `GET /manage/pipeline-roles`** — the role list is rendered by
`ClosingModesController@index`. A `view-closing-modes`-only admin sees the roles and can touch none
of these endpoints.

### `manage.leads.referrals.*` — inside the **leads** group

`store` (`POST {id}/referrals`) · `destroy` (`DELETE {id}/referral-source`) ·
`asked` (`POST {id}/referral-asked`), all `manage-leads`.

⚠️ They live in the leads group because referral attribution is a **lead** write, and ⚠️ **`{id}`
means a different lead in each** — see
[referrals.md](/docs/modules_handbook/manage/engagement/referrals.md). The two-segment URIs cannot
collide with `DELETE {id}`.

### The one route outside those groups

`GET /manage/sales/pipeline` → `SalesProjectsController@pipeline`, `manage.sales.pipeline`, gated on
**`view-projects`** — the board reads engagements, not sales figures. `?view=pipeline` on the hub
302-redirects here.

## Permissions

| Constant | String | Gates |
|---|---|---|
| `VIEW_PROJECTS` | `view-projects` | the whole `sales-projects` group + the pipeline board + the Leads export |
| `MANAGE_PROJECTS` | `manage-projects` | project CRUD, highlight, the project CSV import |
| `MANAGE_LEADS` | `manage-leads` | every engagement / booking write, referrals, `storeLead`, the booking CSV import |
| `viewLeadsAny()` | `view-leads-all\|group\|team\|own` | the engagements + bookings groups |
| `VIEW_CLOSING_MODES` | `view-closing-modes` | the Closing Modes page + its Setting tab |
| `MANAGE_CLOSING_MODES` | `manage-closing-modes` | every closing-mode write + the entire pipeline-roles group |
| `VIEW_SALES` | `view-sales` | the Sales Dashboard, MLTA, the placeholder product lines, and **the `payments` prop on a project's Show page** |
| `MANAGE_PROPERTY_MATCH` | `manage-property-match` | the `canManageMatch` prop on `?view=match` |
| `SALES_EXECUTION` | `sales-execution` | ⚠️ **not a page gate** — it decides who appears in every assignee picker |

### Who is granted what

`RolesSeeder` gives **super-admin** everything, and the legacy **admin** role everything except role
and admin management. The agency roles come from `SeedCommonRolesAction`:

| Role | Gets |
|---|---|
| all three agency roles (shared) | `view-projects`, `manage-leads`, `sales-execution` |
| Group Super Admin + Sales Leader | ＋ `view-sales` |
| Group Super Admin only | ＋ `manage-projects` |
| Sales Leader | ＋ `view-leads-team` |
| Sales Agent | ＋ `view-leads-own` — **no `view-sales`, no `manage-projects`** |

⚠️ **`view-closing-modes` / `manage-closing-modes` are granted to NOBODY except super-admin and the
legacy admin role.** The commission configuration is super-admin-only out of the box — a reader
assuming a Sales Leader can edit it is wrong.

⚠️ **`SeedCommonRolesAction` is idempotent by refusal**: defaults apply only when the role was just
created or has zero permissions, so **adding a permission to `defaults()` does not back-fill existing
installs**.

### The assignee pool

`ResolvesAssignableManagers::assignableManagerQuery()` = active users holding a **manage role** with
the **`sales-execution`** permission, narrowed to the actor's group **through `admins.group_id`**.

⚠️ **That is the ONLY group partition on the assignee list** — it does not go through
`GroupScope::apply()`.

## Scoping — two different rules

They are not interchangeable. **`GroupScope` partitions PROJECTS by agency; `LeadVisibility`
partitions LEADS by who may see the person.** Most surfaces need both.

### `GroupScope`

A **tenancy partition, not a permission level** — the module's view/manage permissions still gate
access on top.

| Method | Behaviour |
|---|---|
| `apply($query, $user)` | `groupId() ? where('group_id', groupId) : $query` — **a no-op for platform staff** |
| `allows($user, ?int $groupId)` | true when the actor has no group, or the groups match |
| `applyShared` / `allowsShared` | also admit `group_id IS NULL` rows |

⚠️ **This module only ever uses `apply()` and `allows()`**, never the `Shared` variants. A `projects`
row with `group_id = NULL` is therefore invisible to any group member.

Applied on: the projects index and its status counts, every `projectOptions` list (referral / match /
pipeline views), `search()`, `show()`, `export()`, `storeLead()`, `update()`, `destroy()`,
`highlight()`, both booking-import steps, the referral view's optional open-into-a-project leg, the
pipeline board's project filter, and the project importer's name dedupe.

### `LeadVisibility`

`LEVEL_ALL` → `LEVEL_GROUP` → `LEVEL_TEAM` → `LEVEL_OWN` → `LEVEL_NONE`, resolved from permissions
**plus actual membership**: a `view-leads-group` grant with no group **degrades to OWN** (least
privilege).

The same rules are expressed twice — `apply()` for queries, `allows()` for a single record — and at
group/team/own level **both** admit a lead through its engagement assignments, not only through
`leads.assigned_admin_id`.

⚠️ **`apply()`'s NONE arm is `whereRaw('1 = 0')`, not "no constraint."** Dropping the call from a
caller silently promotes an own-level agent to everything.

⚠️ **`allows()` at GROUP/TEAM level issues `->exists()` subqueries per lead** — fine for a single
`abort_unless`, an N+1 in a loop.

### ⚠️ Surfaces that apply NEITHER scope

A reader deciding where to add a surface needs these visible, not buried:

| Surface | Gate | Reads unscoped |
|---|---|---|
| `Manage\Sales\MltaController@index` | `view-sales` | **every** engagement at `6 Booked` / `8 Converted` whose **`won_at` falls in this calendar year** — lead name, phone, raw `projects.name`. No `LeadVisibility`, no `GroupScope`, so a sales leader sees every group's converted buyers. ⚠️ `won_at` is stamped only on a move INTO Converted (and never cleared), so a deal that is merely Booked never appears — despite `SalesTabs.vue`'s comment that Booked deals do — while one that was Converted and then moved back to Booked still does |
| `Manage\Insights\InsightsController` | `view-insights` | platform-wide booking counts + amounts and engagement stage/won/lost counts (by design, but unscoped) |
| `App\Actions\ComputeInfluencedPipelineAction` | (marketing) | `Booking::query()` filtered only by lead cohort |
| `App\Services\Marketing\FunnelDashboardService` | (funnel dashboard) | bookings by cohort, not by viewer |
| `ClosingModesController` / `PipelineRolesController` | closing-mode permissions | ⚠️ **`closing_modes`, `closing_mode_roles` and `pipeline_roles` have no `group_id` column at all** — the commission configuration is platform-global by design. Its usage counts are also cross-group, which is correct for a delete guard but does leak volume |

## Schema

**No schema-level foreign keys anywhere** (GUIDELINES §7) — every FK is a comment.

### `engagements`

`id` · `uuid` (unique) · `lead_id` (idx) · `project_id` (idx) · **`unique(lead_id, project_id)`** ·
`group_id` (nullable, idx) · `status` (uint, default `STATUS_NEW`, idx) · `lost_reason` (text,
nullable — renamed from `special_remark` by `2026_08_11_100001`) · `lost_stage` (string 20, nullable) · `last_activity_at` · `won_at` · `lost_at` ·
`created_by` / `updated_by` / `deleted_by` · timestamps · `deleted_at` · `purchase_history_id`
(nullable, idx) · **`closing_mode_id`** (`unsignedBigInteger`, nullable, idx) · **`zoom_closer_id`**
(`unsignedBigInteger`, nullable, idx — a `users.id`; `2026_09_28_190000`)

- `unique(lead_id, project_id)` is **the idempotency key** `open()` relies on.
- ⚠️ **`closing_mode_id` is HOW the deal closed, and this is its home** (2026-08-12). NULL is not
  "no mode" — it means "derive it" (`resolvedClosingModeId()` reads the configured default, live).
  The identically-named column on `bookings` is a mirror no calculation reads; see
  [closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md).
- `zoom_closer_id` is the teammate running the 1-1 sales Zoom after payment — written only by
  `EngagementRepository::setZoomCloser()` (`PUT .zoom-closer`), read by the CEO suite's Property
  Closing page and its export. Tracking only: not a pipeline role, no commission.
- `group_id` is copied from the lead **at creation and never re-synced**.
- ⚠️ The three owner columns (`caller_admin_id` / `closer_admin_id` / `followup_admin_id`) were
  **dropped** by `2026_08_02_100001` after backfilling into `engagement_assignments`.

### `bookings`

`id` · `uuid` (unique) · `engagement_id` (idx) · `lead_id` (idx) · `project_id` (idx) ·
`closer_admin_id` (nullable, idx) · `catalog_floor_plan_id` (nullable, idx) ·
`catalog_floor_plan_uuid` (the catalogue-federation twin, `2026_08_18_100003`) · `unit_no` (60) ·
`block` (60) · `floor` (30) · `built_up` (uint) · `spa_price` · `net_price` · `booking_fee` ·
`commission` · **`commission_adjustment`** (all `decimal(12,2)`) ·
**`commission_adjustment_reason`** (255) · **`is_sst_deducted`** (bool, default false) · `booking_no`
(60) · `booking_date` (date) · `spa_status` (uint, default Pending, idx) · `spa_signed_at` (date) ·
`lo_signed_at` (date) · `loan_margin` (`decimal(5,2)`) · **`loan_amount`** (`decimal(14,2)`) ·
**`loan_tenure_years`** (small uint) · **`loan_rate`** (`decimal(5,2)`) · **`market_value_override`**
(`decimal(14,2)`) · **`owner_set_loan_at`** / **`owner_set_value_at`** (timestamps) ·
**`renovation_by`** (uint: 1 Self · 2 PropertyLab · 3 Partner) · `leader_review_result` (uint) ·
`closing_mode_id` (uint) · `legacy_ref` (64, idx) · `status` (uint, default Active, idx) · blame ·
timestamps · `deleted_at`

- ⚠️ The loan, owner and `renovation_by` columns (2026-09-23, all nullable) belong to **the buyer's
  unit after the sale** and are written mostly from OUTSIDE this module — the member's own My
  Properties page, the mobile API, Post-VP and Renovation (see *Cross-module callers* below).
  `is_sst_deducted` is the Payout tab's switch ([closeout.md](/docs/modules_handbook/manage/engagement/closeout.md)).
- ⚠️ `closing_mode_id` here is a **legacy MIRROR of `engagements.closing_mode_id`** since 2026-08-12
  — written by `EngagementRepository::setClosingMode()` (and copied by `BookingRepository::create()`),
  read by no calculation, awaiting its own removal. ⚠️ `SalesProjectsController::bookingPayload()`
  still echoes it as the booking's `closing_mode_id` / `closing_mode_label` (unused by the
  frontend) — delete those two lines with the column. It is also an **`unsignedInteger`**, not
  `unsignedBigInteger`, because it was renamed from `closing_type` rather than recreated.
- ⚠️ `cancellation_reason` was **dropped** by `2026_08_11_100001_rename_special_remark_to_lost_reason`.
  It was a second copy of `engagements.lost_reason` (a booking is cancelled iff its engagement goes
  Lost), NULL on all 204 cancelled bookings, and the engagement's column is the only one that also
  covers the 189 deals that died before they were ever booked.
- ⚠️ `price`, `floor_plan_id` and `commission_rate` were **dropped** by `2026_07_31_100001` (`price`
  backfilled into `net_price` first).
- ⚠️ `bank` was **dropped** by `2026_08_12_100001` for the `booking_bankers` child table below — a
  deal is shopped to several banks and one string could hold neither them nor anyone to ring.

### `booking_bankers`

`id` · `booking_id` (idx) · `bank` (120, idx) · `name` (120, nullable) · `phone` (40, nullable) ·
`remark` (text, nullable) · `created_by` / `updated_by` · timestamps

⚠️ **No uuid, no soft delete, no `deleted_by`** — a child row, exactly like an
`engagement_assignments` row, re-cut wholesale by `BookingRepository::syncBankers()` and hard-deleted
in one statement, never addressed one row at a time from a URL. It replaced the single
**`bookings.bank`** column (`2026_08_12_100001` backfills the old value — soft-deleted bookings
included — before dropping it; the production snapshot had **1** such row out of 503 bookings),
because a deal is routinely shopped to several banks and one column could only ever record the last
one named.

- `bank` is **free text with an index**, not an enum — the picker's list is a suggestion (see
  [booking.md](/docs/modules_handbook/manage/engagement/booking.md)), and the Bank filter reads
  DISTINCT values off this column.

*(This section appeared twice until 2026-10-02; the two copies were merged, and the
`cancellation_reason` note that had drifted in here moved back under `bookings`.)*

⚠️ **A row with details but NO bank is refused, not dropped.** `Bookings\StoreRequest` and
`StatusRequest` both carry `required_with:bankers.*.name,bankers.*.phone,bankers.*.remark` on `bank`;
the repository's own drop is the backstop for a wholly blank row — an "Add banker" the closer opened
and never filled. The bank is what makes a banker findable, so a name with no bank can be neither
filtered nor rung, and someone who typed one deserves to be told rather than watch it vanish.

⚠️ **An ABSENT `bankers` key means "this form did not render them"**, an empty array means "the admin
removed them all" — the same rule an absent roles payload follows. Without it every partial write (the
inline status-change booking, the legacy importer) would wipe a list it never showed.

### `booking_attendees` · `booking_commission_claims` (closeout, `2026_09_21_100001`)

**`booking_attendees`** — `id` · `booking_id` (idx) · `user_id` (idx, a staff `users.id`) ·
`created_by` / `updated_by` · timestamps · `unique(booking_id, user_id)`. A child row like the
bankers: no uuid, no soft delete, replaced wholesale by `BookingRepository::syncAttendees()`.

**`booking_commission_claims`** — `id` · `uuid` · `booking_id` (idx) · `claimed_at` (date) ·
`claimed_amount` (`decimal(14,2)`) · `reference` (120) · `paid_at` (date) · `paid_amount`
(`decimal(14,2)`) · `remark` (500) · `created_by` / `updated_by` / `deleted_by` · timestamps ·
`deleted_at`. ⚠️ Unlike the other child rows it is a **money record**: uuid, full blame and soft
delete, matched by uuid on save (`syncCommissionClaims()`). Both tables are written up in
[closeout.md](/docs/modules_handbook/manage/engagement/closeout.md).

### `booking_stages` (Post-VP, `2026_09_21_200002`)

One row per (booking, stage) — `unique(booking_id, stage)` — the seven stages after vacant
possession (`Booking::stages()`). Owned by the [Post-VP handbook](/docs/modules_handbook/manage/post-vp/readMe.md),
listed here because it hangs off `bookings.id`.

### `zoom_meeting_opportunity_links` (`2026_08_06_200001`, widened `2026_08_14_100002`)

Pairs a recorded meeting / conversation with the deal it was about: `engagement_id` (nullable, idx —
a CONFIRMED link, `Engagement::zoomOpportunityLinks()`) and `suggested_engagement_id` (nullable, idx —
a suggestion, deliberately not a relation on `Engagement`). Owned by the Zoom module.

### `engagement_assignments`

`id` · `engagement_id` (idx) · `role` (string 30 — a `pipeline_roles.key`) · `admin_id` (idx, a
**`users.id`**) · `role_share` (`decimal(5,2)`, nullable) · `created_by` / `updated_by` · timestamps ·
**`unique(engagement_id, role, admin_id)`**

⚠️ **No uuid, no soft delete, no `deleted_by`.** Rows are hard-deleted when a role is re-cut.

### `closing_modes` · `closing_mode_roles` · `pipeline_roles`

Full column tables in
[closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md). In short:
`closing_modes` and `pipeline_roles` are soft-deletable blame-carrying uuid models;
`closing_mode_roles` is a plain child table (`closing_mode_id` · `role` · `share decimal(5,2)` ·
`position`, indexed on `(closing_mode_id, position)`) with **no uuid, no blame and no soft delete**.

### Sales-relevant columns elsewhere

**`projects`** — `group_id` (the `GroupScope` column) · `origin` (`catalog` / `custom`, NOT NULL,
idx) · `catalog_project_id` (nullable, idx — was `edgeprop_project_id`) · `commission_rate`
(`decimal(5,2)`, nullable) · **`commission_basis`** (`unsignedInteger`, **NOT NULL**, default Net) ·
`vp_at` (date) · `is_highlighted` (bool, NOT NULL, default false, idx) · `status` · `price_from` /
`price_to` (`unsignedBigInteger`).

⚠️ `projects.created_by` / `updated_by` / `deleted_by` are **`unsignedInteger`**, not
`unsignedBigInteger` — inherited from the 2026-06 create, unlike every newer table.

**`leads`** — `assigned_admin_id` · `group_id` · `referred_by_lead_id` (idx) · `referred_at` ·
`referral_asked_at` (idx) · `status` (the synced roll-up) · `distribution_status`.

**`appointments.engagement_id`** — nullable, indexed. An appointment keeps its own `lead_id` +
`project_id` (a scheduling event can predate the engagement), but when it belongs to one it is
grouped under that pipeline.

**`zoom_meetings.engagement_id`** (`2026_09_28_000001`) — nullable, indexed. Pairs a *Zoom Meeting
post Booking Payment* with its sales opportunity, so the project Leads tab's 1-1 Zoom / Sales Zoom
columns show only THIS project's calls (see [readMe.md](/docs/modules_handbook/manage/engagement/readMe.md)).
Two writers: the calendar's Zoom form at creation, and — since 2026-10-02 — the project Leads tab's
Sales Zoom modal, which pairs a meeting that already exists
(`ZoomMeetingRepository::linkEngagement()`, routes `manage.engagements.sales-zoom.*`).

**`renovation_jobs.booking_id`** · **`rental_estimate_submissions.booking_id`**
(`2026_09_23_150000`) — nullable, indexed. The renovation and rental boards' join back to the unit
that was sold.

### Migrations, in order

| Migration | What it did |
|---|---|
| `2026_07_14_100001_create_engagements_table` | the table + the unique key + the three (now dropped) owner columns |
| `2026_07_14_100002_create_bookings_table` | the table, incl. the now-dropped `price` + `floor_plan_id` |
| `2026_07_14_100003_add_engagement_id_to_appointments_table` | groups an appointment under its deal |
| `2026_07_14_100004_backfill_engagements_from_appointments` | seeds engagements from existing project appointments |
| `2026_07_17_100001_add_legacy_fields_to_bookings_table` | widens money to `decimal(12,2)`, adds `commission`, `commission_rate` (later dropped), `legacy_ref` |
| `2026_07_17_100003_retire_legacy_floor_plans` | adds `catalog_floor_plan_id` and bridges the legacy ids; **aborts** if one cannot be mapped |
| `2026_07_18_100001_add_commission_rate_to_projects_table` | the project rate |
| `2026_07_26_100001_add_booking_deal_fields_and_commission_basis` | `spa_price` / `net_price` / `lo_signed_at` / `bank` / `loan_margin` + `projects.commission_basis` |
| `2026_07_26_100002_add_is_highlighted_to_projects_table` | the pin |
| `2026_07_27_100002_add_referral_columns_to_leads_table` | stage 3's three columns |
| `2026_07_28_100001_make_booking_spa_signed_at_date_only` | `datetime` → `date` |
| `2026_07_30_100006_create_booking_commission_splits` | ⚠️ creates `bookings.closing_type` **and a table that no longer exists** |
| `2026_07_31_000002_add_purchase_history_id_to_engagements_table` | the project-fee receipt |
| `2026_07_31_000003_backfill_entitlement_purchase_links` | conservative one-to-one backfill; trashed rows included |
| `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_07_31_100002_add_vp_at_to_projects_table` | vacant possession |
| `2026_08_02_100001_create_engagement_assignments_table` | the table, backfills the three owner columns into rows, drops them, rewrites `lost_stage = 'caller'` → `'appointment'` |
| `2026_08_03_100001_merge_commission_split_into_assignments` | adds `role_share`, **drops `booking_commission_splits`** |
| `2026_08_03_200001_create_closing_modes_and_pipeline_roles` | the three config tables, renames `closing_type` → `closing_mode_id`, **and seeds the whole configuration** |
| `2026_08_03_300001_role_default_admins_replace_entry_closer` | adds `default_admin_id`, drops `is_entry` + `is_closer_role` |
| `2026_08_06_100001_add_short_label_to_pipeline_roles_table` | `short_label(12)`, nullable = derive |
| `2026_08_06_200001_create_zoom_meeting_opportunity_links_table` | `zoom_meeting_opportunity_links` (`engagement_id` / `suggested_engagement_id`) — Zoom module; widened by `2026_08_14_100002` |
| `2026_08_10_100001_replace_manual_commission_with_adjustment_on_bookings` | adds `commission_adjustment` + `commission_adjustment_reason`, converts every convertible absolute `commission` into an adjustment and nulls it |
| `2026_08_11_100001_rename_special_remark_to_lost_reason` | renames `engagements.special_remark` → **`lost_reason`**, drops `bookings.cancellation_reason` |
| `2026_08_12_100001_create_booking_bankers_table` | **`booking_bankers`** — the child table that replaced the single `bookings.bank`; backfills the old column (soft-deleted bookings included) before dropping it |
| `2026_08_12_500001_add_closing_mode_to_engagements_table` | **moves the closing mode to the deal** — adds `engagements.closing_mode_id` and backfills it from each engagement's latest live booking. `bookings.closing_mode_id` is deliberately kept (a money column earns its removal in its own change) |
| `2026_08_18_100003_add_catalogue_uuids_to_site_tables` | adds `bookings.catalog_floor_plan_uuid` (with the other catalogue-uuid twins) |
| `2026_09_21_100001_add_booking_closeout` | `bookings.is_sst_deducted` + `booking_attendees` + `booking_commission_claims`; every step guarded, order-independent |
| `2026_09_21_200002_create_booking_stages_table` | Post-VP's per-unit stages (with `_200000` / `_200003` / `_200004` — checklist templates, stage tasks, the default checklist) |
| `2026_09_23_110000_add_loan_terms_to_bookings` | `loan_amount` / `loan_tenure_years` / `loan_rate` |
| `2026_09_23_120000_add_owner_inputs_to_bookings` | `market_value_override` / `owner_set_loan_at` / `owner_set_value_at` |
| `2026_09_23_140000_add_renovation_by_to_bookings` | `renovation_by` |
| `2026_09_23_150000_link_renovation_and_rental_to_bookings` | `renovation_jobs.booking_id` + `rental_estimate_submissions.booking_id` |
| `2026_09_28_000001_add_engagement_id_to_zoom_meetings_table` | `zoom_meetings.engagement_id` — a post-payment Zoom paired with its deal |
| `2026_09_28_190000_add_zoom_closer_id_to_engagements_table` | `engagements.zoom_closer_id` |

*(Until 2026-10-02 the `2026_08_12_100001` row appeared twice, out of date order; merged.)*

## Jobs, commands, observers

⚠️ **There are no Eloquent observers, no events and no dedicated queue jobs OWNED by this module.**
Every write is synchronous through the repositories. ⚠️ That is not the same as "nothing
asynchronous writes here": other modules' listeners, runner steps and commands do (items 6–8 below
— the "no listeners" claim this section used to make was wrong by 2026-09). What does exist:

1. **A queue hook** — `Queue::before()` in
   [`AppServiceProvider`](/app/Providers/AppServiceProvider.php) flushes the two closing-config
   static caches, because a long-running Horizon worker keeps statics across jobs and an admin's
   Setting edit would otherwise never reach the payment automation until a `horizon:terminate`.
2. **One scheduled command** —
   [`lead-dist:reap-no-booking`](/app/Console/Commands/LeadDistribution/ReapNoBooking.php), daily at
   **02:20** Asia/Kuala_Lumpur, `withoutOverlapping()`. It treats a booking in `(ACTIVE, COMPLETED)`
   as "it converted — nothing to recycle"; otherwise the lead goes back to the pool with
   `LeadAssignment::REASON_NO_BOOKING`. Guarded by `DistributionSettings::enabled()` **and**
   `noBookingEnabled()`, so it is inert unless distribution is configured.
3. **One queued writer** — the WhatsApp CTA lane in `ProcessInboundWhatsAppWebhook` calls
   `EngagementRepository::open()` inside its own try/catch.
4. **Payment fulfilment**, synchronous but re-entered from a queued webhook — see
   [closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md).
5. **A seeder** — `database/seeds/EngagementsSeeder.php`, registered in `DatabaseSeeder`. Idempotent;
   it skips when the demo projects already have engagements and warns out if no admins exist.
6. **The Appointment Engine moves deals from queued work** — the queued listener
   `Listeners\AppointmentEngine\RecordWhatsappBooking` (on `WhatsappBookingCaptured`) and the runner
   handlers `AiCall`, `WhatsappSender`, `WhatsappAiTakeover` and `EndStop` all call
   `CrmPipeline::advanceStatus()`; `CrmIdentity` and the runner's `Triggers` call
   `CrmPipeline::ensureOpen()`; `CloserRotation` calls `syncCloser()`. Rules in
   [lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md).
7. **`php artisan ae:link-pipelines [--dry]`** — manual (not scheduled): opens the engagement for
   every bridged engine lead, mirrors its agent onto the closer role, and catches the status up
   (`3` if it has an appointment, else `2` if it was contacted).
8. **`php artisan post-vp:open-rental-enquiries`** — manual (not scheduled): puts every live unit of
   every VP'd project on the rental and renovation boards (the same hand-off a project save makes —
   see [sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md)).

## Cross-module callers

This is the table that stops the next reform from breaking a caller nobody knew about.

### Who WRITES engagements or bookings from outside the module

| Caller | What it does |
|---|---|
| [`PurchaseFulfiller`](/app/Services/Payment/PurchaseFulfiller.php) | opens an engagement with a receipt (`$withDefaultTeam = false`), stamps the payment closing mode with **`markPaymentClosing($engagement, true)` — overwriting any mode the deal carried**, rolls the team; detaches the receipt when a payment is repointed; its **own guard** — not `open()`'s — is what stops a replay reopening a removed engagement |
| [`PurchaseGrantLinker`](/app/Services/Payment/PurchaseGrantLinker.php) | attaches a receipt to a **trashed** engagement without restoring it, and stamps the mode **fill-blank only** (`markPaymentClosing($engagement)`) — but deliberately does **not** roll the team |
| `ProcessInboundWhatsAppWebhook` | the CTA-link lane's `open()` |
| [`CrmPipeline`](/src/AppointmentEngine/Services/CrmPipeline.php) (Appointment Engine) | `ensureOpen()` → `open()` (never resurrects a trashed deal); `syncCloser()` → `assign()` on the `closer` role; `advanceStatus()` → `ChangeEngagementStatus` (forward-only, never 6/7/8/9, Lost only from 1/2). And the reverse: `EngagementRepository::assign()` writes a closer back onto `ae_leads.assigned_admin_id` |
| RevenueJourney [`BookingBridgeRepository::record()`](/src/RevenueJourney/Repositories/BookingBridgeRepository.php) | **creates bookings** through `BookingRepository::create()` on an existing engagement (moving it to Booked), and links them via `BookingLink` / `SalesLink`. ⚠️ Known gap — not fixed as of 2026-10-02: it sends **no `spa_price`**, bypassing the requirement every Form Request enforces. See the [Revenue Journey handbook](/docs/modules_handbook/shared/revenue-journey/booking-contract.md) |
| Member portal `Main\Portal\MyPropertiesController` · mobile API `Api\v1\MobileMyPropertiesController` | the BUYER edits their own unit through `BookingRepository::update()`: `renovation_by`, and (via `App\Actions\PostVp\SaveOwnerNumbers`) the loan trio + `market_value_override` with their `owner_set_*_at` stamps |
| `App\Actions\PostVp\SaveOwnerNumbers` | the shared writer for those owner numbers — touching any loan field rewrites all three |
| `Manage\Renovation\JobsController` | writes `bookings.renovation_by` from the renovation board (`BookingRepository::update()`) |
| `SalesProjectsController::update()` → `PostVp\RaiseRenovationEnquiry` / `OpenRentalEnquiry` | a project save with a `vp_at` hands every live booking to the renovation and rental boards (reads bookings, writes those boards) |
| **Lead merge** (`LeadRepository`) | ⚠️ the one place engagement rows are **hard-deleted** rather than soft-deleted: two leads with an engagement on the SAME project collide, one is dropped, and `bookings.engagement_id` + `appointments.engagement_id` are repointed onto the survivor. ⚠️ `IdentityChildMap` classifies `*_lead_id` columns only, so **nothing but `MergeEngagementCollisionTest` stands behind those two foreign keys** |
| `Lead` model delete hook | cascades to engagements + bookings — ⚠️ its `withTrashed()` is load-bearing, or already-trashed rows survive as orphans |

### Who READS them

`Sales\DashboardController` (revenue + its own SQL copy of the commission derivation) ·
`Sales\MltaController` (unscoped) · `Insights\InsightsController` (unscoped) ·
`FunnelDashboardService` and `ComputeInfluencedPipelineAction` (marketing attribution) ·
`PropertyMatchAdminQuery` (the workbench's CRM strip) · `LeadsController` (the Pipeline tab and the
quick-view modal) · `CatalogFloorPlan::bookings()` (⚠️ `withTrashed()`, because the catalogue's
deletion/merge guards must see trashed rows) · `Appointment` · `PurchaseHistory` · `Project` ·
`Lead::propertyRecords()` · `Manage\Ceo\PropertyClosingController` (through
`SalesProjectsController::paidPipelinesPayload()` / `PaidPipelines`) ·
`Manage\AppointmentEngine\ProjectsController` (renders `SalesProjectsController::show()` with
`suite = 'appointment-engine'`) · `EngagementOpportunityValue` (the Zoom opportunity's deal value) ·
`CrmPipeline::scopeIncompleteTeam()` (the engine's "team not fully staffed" queue, an SQL copy of
`resolvedClosingModeId()`) · `LeadsController::listRowsForLeads()` (the project Leads tab's
lead columns).

⚠️ **The Meta Pixel / Conversions API does NOT read `Booking` or `Engagement`.** The "fire a
`Purchase` event on a paid sale" note in the pixel handbook is correctly labelled *not built yet*.

### `IdentityChildMap` classification

| Column | Merge | Purge |
|---|---|---|
| `bookings.lead_id` | REPOINT | DELETE_BY_LEAD |
| `engagements.lead_id` | **SPECIAL** (`unique(lead_id, project_id)` — merged per project) | DELETE_BY_LEAD |
| `engagement_assignments.admin_id` | SKIP (staff link; rows follow the engagement) | SKIP |
| `bookings.closer_admin_id` | SKIP | SKIP |

## The test perimeter

**8 files, ~74 test methods in `tests/Feature/Engagement/`** — `SalesProjectsIndexTest` (the
largest), `BookingImportDecisionsTest`, `CommissionShareTest`, `ClosingModeSettingTest`,
`ProjectLeadsExportTest`, `EngagementLifecycleTest`, `EngagementVisibilityTest`,
`SalesPipelineCanonicalTest` — plus `tests/Unit/BookingImport/LegacyBookingRowMapperTest`.

⚠️ **At least a dozen more tests in OTHER modules' suites pin this module's invariants**, and a
change here can break them: `LeadDeletionGuardTest` (the cascade must see trashed rows; an engagement
blocks a lead delete), `MergeEngagementCollisionTest` (the hard-delete collision path),
`PurchaseFulfillerTest` + `PurchaseGrantLinkTest` (the receipt and the no-restore guard),
`LeadShowProjectCanonicalTest`, `AuditRedesignTest` (a booking's floor plan must belong to its
project's catalogue), `CatalogueSchemaCleanupMigrationTest` (the floor-plan bridge),
`CtaLinkTest` (the WhatsApp lane's idempotency), `FunnelDashboardTest`, `InsightsDashboardTest`,
`MergeMovesEverythingTest` (the referral repoint).

### ⚠️ The coverage holes, stated plainly

- **Stage 3 has ZERO dedicated tests** — every guard, counter and ranking rule in
  [referrals.md](/docs/modules_handbook/manage/engagement/referrals.md).
- **The project delete guard** and **`highlight()`** — untested.
- **`hubStageCounts()`, `teamPerformance()`, `bookingHeadlineStats()`, `commissionPayeeCards()`** and
  the booking filter option lists — untested.
- **`BookingQueryRequest`'s filters** (status / project / date range) — untested; only the
  default sort is pinned. The **bank** filter IS pinned, by
  `SalesProjectsIndexTest::test_the_project_leads_tab_filters_by_bank_from_the_drawer`.
- **The pipeline board** and `PipelineQueryRequest` — untested.
- **The manual-commission precedence itself**, the commission sort SQL, and the pipeline column
  total — untested.
- **Every Vue component except `LeadCell`** — `EngagementTable`, `CommissionSplitEditor`,
  `StatusCell`, `TeamRolesCell`, `BookingCells`, `CommissionCell`, `TeamPerformanceRail`,
  `AddLeadToProjectModal`, `closingModes.js`, `engagementStatus.js` are all untested in JS.
- **`ClosingModeRepository::makeManualDefault()` / `activate()` / `inactivate()`** and
  **`PipelineRoleRepository::makeRollTarget()` / `activate()` / `inactivate()`** — only
  `makePaymentDefault` and the two delete guards are pinned.

⚠️ **There are no model factories for this module**, so "add a test" means hand-building rows — and
several existing tests lean on the **migration's** seeded closing modes and pipeline roles surviving
`RefreshDatabase`.

## Related chapters
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) ·
[booking.md](/docs/modules_handbook/manage/engagement/booking.md) ·
[closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md) ·
[sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md) ·
[retired.md](/docs/modules_handbook/manage/engagement/retired.md)
