# 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

**40 routes across six name groups**, plus `manage.sales.pipeline` which this module's controller
also serves. ⚠️ **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` | `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` |

### `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` | — |
| `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` | — |
| `GET {id}` | `.show` | `@show` | — |
| `PUT {id}` | `.update` | `@update` | `manage-projects` |
| `DELETE {id}` | `.destroy` | `@destroy` | `manage-projects` |

⚠️ **Declaration order is load-bearing.** `search`, `{id}/bookings/import*` and `{id}/export` 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 won this calendar year — lead name, phone, project. No `LeadVisibility`, no `GroupScope`, so a sales leader sees every group's converted buyers |
| `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)

- `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 nothing reads; see
  [closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md).
- `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) · `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) · `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)`) · `leader_review_result` (uint) · `closing_mode_id` (uint) ·
`legacy_ref` (64, idx) · `status` (uint, default Active, idx) · blame ·
timestamps · `deleted_at`

- ⚠️ `closing_mode_id` here is a **legacy MIRROR of `engagements.closing_mode_id`** since 2026-08-12
  — written by `EngagementRepository::setClosingMode()`, read by nothing, awaiting its own removal.
  It is also an **`unsignedInteger`**, not `unsignedBigInteger`, because it was renamed from
  `closing_type` rather than recreated.
- ⚠️ `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 and no soft delete**, matching `engagement_assignments`: the list is re-cut wholesale
  when a booking is saved, never addressed one row at a time from a URL.
- `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.
- Backfilled from `bookings.bank` before that column was dropped; the production snapshot had **1**
  such row out of 503 bookings.
- ⚠️ `cancellation_reason` was **dropped** by `2026_08_11_100001`. 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.

### `booking_bankers`

`id` · `booking_id` (idx) · `bank` (120, idx) · `name` (120) · `phone` (40) · `remark` (text) ·
`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. It replaced the single **`bookings.bank`** column (`2026_08_12_100001` backfills the
old value — soft-deleted bookings included — before dropping it), because a deal is routinely shopped
to several banks and one column could only ever record the last one named.

⚠️ **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.

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

### 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_08_12_100001_create_booking_bankers_table` | `booking_bankers` (bank / name / phone / remark per banker); backfills then **drops `bookings.bank`** |
| `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_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_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_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) |

## Jobs, commands, observers

⚠️ **There are no Eloquent observers, no events, no listeners and no dedicated queue jobs for
engagements or bookings.** Everything is synchronous through the repositories. 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.

## 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, 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 — but deliberately does **not** roll the team |
| `ProcessInboundWhatsAppWebhook` | the CTA-link lane's `open()` |
| **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()`.

⚠️ **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)
