# The Engagement lifecycle

**Portal:** Manage · **Model:** [`Src\Engagement\Engagement`](/src/Engagement/Engagement.php) ·
**Routes:** `manage.engagements.*` ·
**Surfaces:** the Lead Show **Pipeline** tab, and every row of both engagement tables in the
[Property Booking hub](/docs/modules_handbook/manage/engagement/sales-projects.md)

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

## What it does

An **engagement is one row per `(lead, project)`** — the whole sales cycle a lead runs for ONE
project, enforced by `unique(lead_id, project_id)`. It carries the lifecycle `status`, the terminal
marks (`won_at` / `lost_at` / `lost_stage` / `lost_reason`), a `last_activity_at` "when did we
last touch this" stamp, the agency `group_id` copied from the lead at creation, and an optional
`purchase_history_id` — the project-fee receipt that opened it.

Its **team** lives in child rows (`engagement_assignments`: engagement × role × admin), and its
**bookings** hang off it. Every write goes through
[`EngagementRepository`](/src/Engagement/Repositories/EngagementRepository.php) (facaded), each in a
`DB::transaction`; the ones that can move the lifecycle also re-sync `leads.status`.

## How it works

### The status ladder — and the three maps that describe it

```
APPOINTMENT   1 New → 2 Contacting → 3 Appointment Set
CLOSER        4 With Closer → 6 Booked
FOLLOW-UP     8 Converted (terminal win)
TERMINAL      9 Lost (records lost_stage + lost_reason + lost_reason_code)
              10 Swing (records swing_project_id — the customer moved to another of OUR projects)
```

**Swing (2026-10-05)** — `Engagement::STATUS_SWING = 10`, colour `fuchsia`, stage `swing`. The deal
ended on THIS project, but the customer is not lost to the company: they moved to another of our
projects. Its "reason" is that project, stored as **`engagements.swing_project_id`** (a `projects.id`,
`swingProject()` relation) and shown as **"Swing to {project}"** on the Status cell (a chip that
re-opens the picker) and the lead's Pipeline card. Rules:

- **The picker offers only STARRED projects** (`projects.is_highlighted`, the ⭐ on the Sales Projects
  list), group-scoped, minus the deal's own. They ride on the Swing entry as `projects`, added by
  **`Engagement::statusOptions($user)`** — every page that hands `StatusChangeModal` its statuses passes
  that instead of the bare `STATUSES` constant (no other prop). `StatusRequest` enforces the same:
  required on Swing, must exist, be starred and not deleted, in the viewer's group, and ≠ the deal's
  own project.
- **Swing ends the deal like Lost** — **`Engagement::ENDED_STATUSES = [LOST, SWING]`** (mirrored in
  `utils/engagementStatus.js`). It is not an open pipeline, earns no commission and no forecast, a
  booking on it is KEPT and marked cancelled (not released), the AI pipeline will not touch it, and
  the opportunity pickers (calls, showroom, recordings, conversations) skip it. It is NOT counted as
  Lost: it has its own chip band (`swing` in `STATUS_GROUPS`), its own `swing` headline count, and it
  does not stamp `lost_at` / `lost_stage` or close the lead's Project-webinar journey.
- `changeStatus()` writes `swing_project_id` on a Swing move and clears it on every other move.
- **How the swing is going** — `Engagement::swingTarget()` returns the same lead's engagement on the
  project it swung to (null = no pipeline there yet). Deal rows carry it as `swing_target`
  `{status, status_label, status_color}`; the Status cell shows it under the Swing chip as
  "There: Booked" (or "Not in pipeline yet"), and so does the lead's Pipeline card. One query per
  Swing row only.
- A deal cannot START at Swing (`StoreLeadRequest` refuses it; the Add Lead modal hides it).
- **Opening the pipeline on the target is ASKED, never automatic.** When the picked project has no
  pipeline for this lead, the modal asks "Also add this customer to the X sales pipeline?"
  (`swing_open_pipeline`, unticked by default); `ChangeEngagementStatus` then calls
  `EngagementRepository::open()` in the same transaction, so it starts at New with the standing team.
  When the lead is already there the modal only says so ("Already in the X pipeline — Booked") and asks
  nothing. The modal knows from the row's `lead_pipelines` (projects.id ⇒ status; loaded in ONE
  `loadMissing('lead.engagements')` per page on Show / bookings / paid pipelines) or, on the lead's
  Pipeline tab, from the tab's own pipelines (keyed by project uuid). A surface that did not load
  them sends `null`: the question is always asked there, and `open()` leaves an existing pipeline as
  it is.

**Why a deal was lost is also a CODE (2026-10-05)** — `engagements.lost_reason_code`,
`Engagement::LOST_REASONS`: 1 Not ready to buy · 2 No loan eligibility · 3 Bought elsewhere ·
4 Unreachable · 9 Other. A Lost move through the status change REQUIRES it beside the words
(`StatusRequest`); the picker is a chip row in `StatusChangeModal`, fed by the Lost entry's own
`reasons` key in `Engagement::STATUSES` — so every page that hands the modal its status map offers it
with no prop of its own. Deal rows carry `lost_reason_code` + `lost_reason_label`; the Status cell's
reason chip and the lead's Pipeline card show the label. The sales flow copies the code onto the lead's
Project-webinar journey when the deal closes it (`FunnelJourney::LOST_*` are the same codes). Optional
elsewhere: a booking cancelled from the bookings list (`BookingRepository::cancel`) still records its
words only, and deals lost before 2026-10-05 have no code.

⚠️ **There are THREE status maps on the model, and confusing them is the single most common mistake
in this module.** They exist because two statuses were retired from the workflow without deleting
the rows that already held them.

| Map | Entries | What it is for |
|---|---|---|
| **`STATUSES`** | **8** — `1 New` (brand), `2 Contacting` (amber), `3 Appointment Set` (indigo), `4 With Closer` (violet), `6 Booked` (**teal** — rendered SOLID bright green, white text), `8 Converted` (**green** — rendered SOLID deep emerald, white text; the darkest thing in the column), `9 Lost` (rose), `10 Swing` (fuchsia). The colour NAMES kept their old keys on 2026-10-05; the classes behind them live in `engagementTones.js` | The **selectable** set. It drives every dropdown, every filter, every chip, and — critically — `StatusRequest`'s `Rule::in(array_keys(Engagement::STATUSES))` |
| **`ALL_STATUSES`** | **10** — the above plus `5 Negotiating` and `7 Following Up` | **Display only.** `status_label` and `status_color` read THIS map, so a legacy row still renders a badge instead of a blank. ⚠️ Its colours reach the frontend through `pipelinesForLeads()` too (the Property Match view and the event Registrations tab), which is why every consumer must spread the shared [`ENGAGEMENT_TONES`](/resources/js/utils/engagementTones.js) rather than keep a copy |
| **`COLUMN_STATUS`** | `5 => 4`, `7 => 6` | The **fold**: which selectable column a retired status groups under, so no lead vanishes from a per-status count or the board |

**`5 Negotiating` and `7 Following Up` are retired, and the API refuses them** — not merely hidden
from the UI. `StatusRequest` validates against `STATUSES`, so a hand-written payload asking for
status 5 gets a 422. Existing rows keep their value and keep rendering.

⚠️ **One lane can still mint a retired status**: the legacy booking CSV importer builds its status
lookup from `ALL_STATUSES` on purpose (an importer ingests legacy exports) and calls
`changeStatus()` directly, bypassing `StatusRequest`. See
[imports.md](/docs/modules_handbook/manage/engagement/imports.md).

### The stage is derived, never stored

`Engagement::stageForStatus()` maps a status to one of six coarse `STAGES` — Appointment / Closer /
Follow-Up / Won / Lost / Swing. Note that **Booked sits in the CLOSER stage**, not Follow-Up: the closer owns
the deal until handover. An unknown integer falls back to Appointment rather than erroring.

`stage`, `stage_label` and `stage_color` are accessors over that function. The one place a stage IS
persisted is **`lost_stage`** — a snapshot of which stage the deal died in, stamped by
`changeStatus()` at the moment of death. *(The stage constant was renamed `caller` → `appointment` on
2026-08-02 — the business's own word — and the persisted `lost_stage` values were backfilled by the
`engagement_assignments` migration. ⚠️ The original migration's column comment still reads
"(caller/closer/followup)"; the value stored today is `appointment`.)*

### Assignment is rows, not columns

The team lives in **`engagement_assignments`** — one row per (engagement, role, admin), unique on
that triple. The three fixed owner columns (`caller_admin_id` / `closer_admin_id` /
`followup_admin_id`) were **dropped** on 2026-08-02 after their values were backfilled into rows.

- **`role` is a `pipeline_roles.key`**, not a constant. The vocabulary is admin-defined at runtime —
  see [closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md). ⚠️ Two docblocks
  in `Engagement.php` (`assigneesFor()` / `firstAssignee()`) and one in the assignments migration
  still say "an `EngagementAssignment::ROLE_*` key", as if there were a family of them. There is
  exactly **one**: **`EngagementAssignment::ROLE_CLOSER = 'closer'`** — the seeded key every
  closer-crediting feature reads (the Leads Dashboard credits a booking's closing to its holders,
  since `bookings.closer_admin_id` is always blank). `CrmPipeline::AGENT_ROLE` is the same key. Every
  other role is a `pipeline_roles` row with no constant.
- **`admin_id` is a `users.id`**, NOT an `admins.id`. `ResolvesManageUserId` additionally refuses a
  non-manage user, so a customer's uuid silently drops out of the payload rather than 422-ing.
- **A role can be held by several admins at once**, and each row carries `role_share` — that
  holder's slice of THEIR ROLE'S pool (null = the pool divides equally). The row is therefore also
  the money side; see [commission.md](/docs/modules_handbook/manage/engagement/commission.md).
- The table has **no uuid and no soft delete** — assignment rows are hard-deleted when a role is
  re-cut.

**The standing team.** Every active pipeline role may carry a `default_admin_id` (set on Setting →
Closing Modes). When a pipeline is opened, each of those people is auto-assigned — but **only on a
genuinely new, present-day, template-wanting open**:

```php
if ($isNew && $withDefaultTeam && $createdAt === null) { … }
```

Each condition earns its place: an **existing or restored** engagement's team is history, not a
template; a **backdated** open is a historical import and today's staff never worked those deals;
and the **payment lane** passes `false` explicitly, because its own team roll would immediately
shuffle a template team it never asked for. Pinned by
`CommissionShareTest::test_opening_a_pipeline_assigns_each_roles_default_admin`.

### `EngagementRepository` — the complete surface

`open()` is the **single door**: no code path anywhere calls `Engagement::create()` or
`firstOrCreate()` directly.

| Method | Signature | Returns | Re-syncs `leads.status`? | Stamps `last_activity_at`? |
|---|---|---|---|---|
| `open` | `(array $input, ?CarbonInterface $createdAt = null, bool $withDefaultTeam = true)` | fresh `Engagement` | ✅ | ✅ (always — new, existing **and** restored) |
| `changeStatus` | `(Engagement $e, int $status, ?string $remark = null)` | fresh `Engagement` | ✅ | ✅ |
| `assign` | `(Engagement $e, array $roles)` | fresh `Engagement` | ❌ (deliberate) | ✅ |
| `reopen` | `(Engagement $e)` | fresh `Engagement` | ✅ | ✅ |
| `updateLostReason` | `(Engagement $e, string $reason)` | fresh `Engagement` | ❌ | ✅ |
| `delete` | `(Engagement $e)` | **`void`** | ✅ | n/a |
| `attachPurchase` | `(Engagement $e, int $purchaseHistoryId)` | `Engagement` | ❌ | ❌ |
| `setPurchase` | `(Engagement $e, ?int $purchaseHistoryId)` | `Engagement` | ❌ | ❌ |
| `detachPurchase` | `(Engagement $e)` | `Engagement` | ❌ | ❌ |
| `setClosingMode` | `(Engagement $e, ?int $closingModeId)` | `Engagement` (fresh when it wrote) | ❌ | ❌ |
| `markPaymentClosing` | `(Engagement $e, bool $overwrite = false)` | `Engagement` | ❌ | ❌ |
| `rollTeamForPayment` | `(Engagement $e)` | `Engagement` | ❌ | ❌ |
| `setZoomCloser` | `(Engagement $e, ?int $userId)` | fresh `Engagement` | ❌ | ❌ |

- **`setClosingMode()`** writes `engagements.closing_mode_id` (null clears it, so the deal derives
  again) and mirrors the value onto **every** booking of the deal (`bookings.closing_mode_id`, the
  legacy mirror). A no-op when the value is unchanged. See
  [closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md).
- **`markPaymentClosing()`** moved here from `BookingRepository` on 2026-08-12, with the column it
  writes. It stamps the payment-default mode through `setClosingMode()`; a no-op when no payment
  default is configured, when the deal already carries that mode, or when the deal's latest booking
  is CANCELLED. ⚠️ **`$overwrite` decides whether a human's choice survives:** the manual repair lane
  (`PurchaseGrantLinker`) passes `false` (fill-blank only), the automatic lane (`PurchaseFulfiller`)
  passes **`true`** and replaces whatever mode the deal carried.
- **`setZoomCloser()`** writes `engagements.zoom_closer_id` — the teammate running the 1-1 sales Zoom
  after payment (CEO suite → Property Closing). Tracking only: not a pipeline role, no commission,
  and deliberately not part of `assign()` so it can never re-cut the team.
- **`assign()` cascades into the Appointment Engine.** When the payload carries the `closer` key
  (`CrmPipeline::AGENT_ROLE`), `syncAppointmentEngineAgent()` writes the first closer onto the
  matching engine lead's `ae_leads.assigned_admin_id` and onto that lead's still-unassigned
  appointments. Fail-soft (`report()`, never throws at the team save). See the
  [Appointment Engine handbook](/docs/modules_handbook/manage/appointment-engine/readMe.md).

**`open()` is idempotent and restores.** It resolves through
`Engagement::withTrashed()->firstOrNew(['lead_id', 'project_id'])` and restores a trashed row
**unconditionally** — which the manual lanes want, since re-adding a lead you removed should bring
its history back.

⚠️ **`open()` never resets the status of an existing or restored row.** Re-adding a lead to a project
returns the engagement exactly as it was, Lost included. Only `reopen()` and `changeStatus()` move
it — and `SalesProjectsController::storeLead()` deliberately does not restage an existing deal
(somebody's live work), flashing *"already on '{project}' — currently {status}. Nothing was
changed."*

**A RESTORED engagement starts over at `NEW`**, with `lost_at` / `lost_stage` cleared exactly as
`reopen()` does it:

```php
$wasTrashed = $engagement->trashed();
if ($wasTrashed) { $engagement->restore(); }
$isNew = ! $engagement->exists;
if ($isNew)            { …group, NEW, optional backdate… }
elseif ($wasTrashed)   { $status = STATUS_NEW; $lost_at = null; $lost_stage = null; }
```

⚠️ **That reset is load-bearing, and the reason is the invariant.** `delete()` soft-deletes the
bookings **along with** the engagement, and `open()` does not bring them back — so a restored row
that kept its old status would sit on `Booked` with **zero live bookings**, precisely the state
[booking.md](/docs/modules_handbook/manage/engagement/booking.md) exists to prevent. Re-adding a
lead you removed is starting the deal again, not resuming it.

⚠️ A **live** existing row is still returned completely untouched — that is somebody's work in
progress, and `storeLead()` refuses to restage it. Only the trashed case resets. Pinned by
`EngagementLifecycleTest::test_re_adding_a_removed_deal_starts_over_at_new`.

⚠️ `won_at` is **not** cleared by the reset, matching `reopen()`. ⚠️ **Neither is `lost_reason`** —
the restore branch clears `lost_at` + `lost_stage` only, and so does `reopen()`. The ONLY path that
clears the reason is `changeStatus()` leaving Lost (below). A restored or re-opened deal therefore
sits at New still carrying its old `lost_reason`: the UI hides it (the Status cell's reason chip
renders only on a rose / Lost row, and the Pipeline tab only when `status === LOST`), but it stays in
the row and prints in the export's **Lost Reason** column on a live deal. *(Corrected 2026-10-02 —
this line used to say the reason is cleared.)*

**The receipt is kept, never overwritten.** `open()` fills `purchase_history_id` only when the
column is currently null: a comped engagement later paid for gains its receipt, but a second payment
never rewrites the first one's record. ⚠️ The "don't restore a closed deal" rule lives in
**`PurchaseFulfiller`'s own guard**, not in `open()`. Moving it here would break every manual lane's
restore.

**`changeStatus()` — the terminal-mark rules.**

```php
$previousStage = Engagement::stageForStatus((int) $engagement->status);   // captured BEFORE the move
…
if ($status === STATUS_LOST)            { $lost_at = now(); $lost_stage = $previousStage; $lost_reason_code = $code; }
elseif ($engagement->lost_at !== null)  { $lost_at = null;  $lost_stage = null; $lost_reason = null; $lost_reason_code = null; }
if ($status === STATUS_COMPLETED)       { $won_at = now(); }
```

Leaving Lost by ANY **status** move clears the loss record, because the dropdown can now leave Lost
directly rather than only through `reopen()` — **and the reason goes with it.** The column is called
`lost_reason`, so a live deal still carrying one reads as a deal that is somehow still dead, and the
Status cell's reason chip renders off exactly that value. Pinned by
`EngagementLifecycleTest::test_leaving_lost_clears_the_reason_with_the_rest_of_the_death_record`.

⚠️ That is `changeStatus()` only. **`reopen()` and `open()`'s restore branch do NOT clear
`lost_reason`** (they null `lost_at` + `lost_stage` and nothing else), so those two doors still leave
exactly the "live deal carrying a reason" state described above. The UI masks it (the chip needs a
Lost row); the data and the export do not. Not fixed as of 2026-10-02.

**The reason is editable; the death record is not.** A lost deal's `lost_reason` is required at
the moment it dies and used to be unreachable forever after — `needsStatusModal()` returns false when
`to === from`, so re-picking *Lost* opens nothing and a typo was frozen. `updateLostReason()` is the
way back in, and it has **its own route** (`PUT manage.engagements.remark`) for one reason:

⚠️ **Re-sending LOST through the status route would rewrite history.** `changeStatus()` re-stamps
`lost_at` to now and recomputes `lost_stage` from the CURRENT status — which is Lost — so the stage
the deal actually died in would be overwritten with `lost`. The wording is a correction; when and
where it died are not. Pinned by
`EngagementLifecycleTest::test_the_lost_reason_can_be_corrected_without_rewriting_the_death_record`.

It writes **one** column and no longer syncs anything: `bookings.cancellation_reason` was dropped on
2026-08-11 — see [booking.md](/docs/modules_handbook/manage/engagement/booking.md).

⚠️ One deliberate asymmetry remains: **`won_at` is never cleared** when moving off Converted, while
`lost_at` / `lost_stage` / `lost_reason` all are.

**`assign()` is a partial update by design.** Role keys **present** in the payload are replaced (an
empty array clears that role); role keys **absent** are left untouched. That is what lets the Booking
modal save a team without knowing about roles it never rendered — and it is why a frontend posting
`roles: {}` changes nothing rather than wiping everyone.

Both `assign()` and `rollTeamForPayment()` take `lockForUpdate()` on the engagement row before
delete-then-insert. Without it, two simultaneous saves gap-lock the `(engagement_id, role)` index
range against each other and one dies in a deadlock instead of the second simply winning.

**Three receipt writers, on purpose.** `attachPurchase()` **never overwrites** and deliberately works
on a **trashed** row without restoring it — that is how the automated repair lane records "this
payment already did its work on this closed deal". `setPurchase()` may **replace or clear**, because
there a human is explicitly saying which receipt paid for the deal. `detachPurchase()` is the undo,
and the payment then regains legacy semantics (a replay may reopen the engagement) — the caller's UI
carries that warning.

### The eight lanes that open an engagement

| Lane | Entry point | Standing team? | Backdated? |
|---|---|---|---|
| Lead Show → Open Pipeline | `EngagementsController@store` | ✅ | ❌ |
| Sales Project → Add Lead / Add Booking | `SalesProjectsController@storeLead` | ✅ | ❌ |
| Referral logging | `ReferralsController@store` | ✅ | ❌ |
| WhatsApp CTA link carrying a `project_id` | `ProcessInboundWhatsAppWebhook` (own try/catch, best-effort) | ✅ | ❌ |
| Legacy booking CSV import | `ImportLegacyBookingsAction` | ❌ (backdated) | ✅ |
| Paid project fee | `PurchaseFulfiller::fulfillProject()` — `open($data, null, false)` + the receipt | ❌ (explicit `false`) | ❌ |
| **Appointment Engine** | `CrmPipeline::ensureOpen()` — called by `CrmIdentity`, the runner's `Triggers`, and `php artisan ae:link-pipelines` (fail-soft) | ✅ | ❌ |
| Appointments backfill | migration `2026_07_14_100004` — writes the model directly | ❌ | n/a |

⚠️ **`CrmPipeline::ensureOpen()` refuses to resurrect a removed deal.** It checks
`Engagement::withTrashed()` for the (lead, project) pair first and returns when ANY row exists — a
trashed engagement means an admin removed the lead from that project, and the engine must not undo
that on every page view. It is the only lane besides `PurchaseFulfiller` with that guard; every
manual lane restores through `open()`.

⚠️ **`EngagementsController::store()` applies no `GroupScope` check on the project**, unlike
`SalesProjectsController::storeLead()`, which does
`abort_unless(GroupScope::allows($user, $project->group_id), 403)`.

One more writer creates **bookings** (not engagements) on an existing engagement:
RevenueJourney's `BookingBridgeRepository::record()` calls `BookingRepository::create()`, which moves
the deal to Booked — see [booking.md](/docs/modules_handbook/manage/engagement/booking.md).

### The machine moves statuses too — the Appointment Engine

[`CrmPipeline::advanceStatus()`](/src/AppointmentEngine/Services/CrmPipeline.php) moves an engine
lead's engagement from the machine's side, through the same `ChangeEngagementStatus` door the
pipeline table uses (so `lost_at` / `won_at` and the `leads.status` roll-up behave identically). Its
rules (owner decision 2026-09-06):

- **Forward only** — a target at or below the current status is ignored.
- **Never touches** `6 Booked`, `7 Following Up`, `8 Converted` or `9 Lost` — those are people's
  territory.
- **May mark Lost only from `1 New` / `2 Contacting`** — i.e. while the machine still owns the deal.

| Caller | Moves the deal to |
|---|---|
| `Runner\Handlers\AiCall` | `2 Contacting` when it dials, `3 Appointment Set` when the call books |
| `Runner\WhatsappSender` · `Runner\Handlers\WhatsappAiTakeover` | `2 Contacting` |
| `Listeners\AppointmentEngine\RecordWhatsappBooking` (queued listener) | `3 Appointment Set` |
| `Runner\Handlers\EndStop` (a `mark_lost` end node) | `9 Lost`, reason *"Marked lost by the AI workflow (…)"* |
| `ae:link-pipelines` | `3` or `2`, when backfilling a bridged lead |

`CrmPipeline::syncCloser()` (called by `CloserRotation` and `ae:link-pipelines`) writes the engine's
assigned agent onto the **closer** role through `assign()` — the other direction of the cascade
described above. All of it is fail-soft.

### `last_activity_at` — five writers, and why that matters

It is stamped in **exactly five places**, all inside `EngagementRepository`: `open()`,
`changeStatus()`, `assign()`, `reopen()` and `updateLostReason()` (a corrected reason is still work
on the deal). *(This section said "four" until 2026-10-02 while the table above already marked
`updateLostReason` ✅.)*

⚠️ **Nothing else stamps it** — not the three receipt writers, not `setClosingMode()` /
`markPaymentClosing()`, not `setZoomCloser()`, not `rollTeamForPayment()`, and **not a booking-only
edit**. `BookingsController::update()` bumps it only when the same save carries a
`roles` payload (which routes through `assign()`). This matters because **both engagement tables
default to `last_activity_at desc`**, so editing a unit's price does not move that deal to the top.
⚠️ A code comment in `SalesProjectsController` claims "every write to the engagement touches it" —
that is not literally true.

### The status ⇄ booking hand-off

Moving between statuses is not a plain `update`, because three statuses (`6 Booked`, `7 Following
Up`, `8 Converted`) must always have a booking behind them.
[`ChangeEngagementStatus`](/app/Actions/ChangeEngagementStatus.php) wraps the booking write and the
stage move in ONE transaction; [`StatusRequest`](/app/Http/Requests/Manage/Engagements/StatusRequest.php)
is the validation gate. The booking half is written up in
[booking.md](/docs/modules_handbook/manage/engagement/booking.md); the two rules that belong here:

- **Entering a booking status with no booking on file requires `unit_no` + `spa_price`**
  (`Rule::requiredIf(fn () => $this->needsNewBooking())`), so a booked row can never be hollow.
  ⚠️ It is **`spa_price`**, not `price` — there is no `bookings.price` column.
- **The chosen status is applied LAST, and that ordering is load-bearing.**
  `BookingRepository::create()` advances the engagement to `BOOKED` as a side effect, so picking
  "Converted" on a first booking would silently land on "Booked" if the status were written first.
  This is the one rule in the module that cannot be re-derived from reading any single file.

### The `leads.status` roll-up

[`SyncLeadStatusFromEngagements`](/app/Actions/SyncLeadStatusFromEngagements.php) recomputes the
coarse lead status after any change that can move it. **Most-advanced wins:**

| Any engagement at | → `leads.status` |
|---|---|
| `6 Booked` or `8 Converted` | Converted |
| `3 Appointment Set`, `4 With Closer`, `5 Negotiating`, `7 Following Up` | Qualified |
| `2 Contacting` | Contacted |
| `1 New` | New |
| none of the above (i.e. all Lost / Swing) | Lost |

**A lead with NO engagements is left untouched** — its legacy status stands, so accounts that never
entered a project pipeline are not clobbered.

⚠️ **`delete()` can therefore leave a stale roll-up.** Once the engagement is soft-deleted the lead
may have zero engagements, the action returns early, and `leads.status` keeps whatever it last was.

### Visibility — holding a role grants sight of the lead

[`LeadVisibility`](/src/Auth/Support/LeadVisibility.php) was extended so that **a lead is visible to
anyone holding ANY pipeline role on ANY of that lead's engagements**, even when the single
`leads.assigned_admin_id` is somebody else. Both halves honour it — the query scope `apply()` (via
`whereHas('engagements')` / `whereHas('engagements.assignments')`) and the object check `allows()` —
so "assigned the deal → can see the person" holds on lists and on single records alike.

The ladder is `LEVEL_ALL` → `LEVEL_GROUP` → `LEVEL_TEAM` → `LEVEL_OWN` → `LEVEL_NONE`, resolved from
the actor's permissions plus their actual group/team membership: a `view-leads-group` grant with no
group membership **degrades to OWN** rather than to everything. `LEVEL_NONE` compiles to
`whereRaw('1 = 0')`.

⚠️ **`engagements.group_id` is stamped once, at creation, from the lead — and never re-synced.** If a
lead later moves group, its engagements keep the old partition, which the GROUP branch reads.

Pinned by [`EngagementVisibilityTest`](/tests/Feature/Engagement/EngagementVisibilityTest.php)
(`test_engagement_closer_can_see_an_otherwise_hidden_lead`,
`test_agent_without_any_assignment_still_cannot_see_the_lead`).

### The controller

[`EngagementsController`](/app/Http/Controllers/Manage/Engagements/EngagementsController.php) — seven
write actions plus one JSON read, every one re-checking `LeadVisibility::allows()` against the
engagement's lead and returning `back()` (the JSON read returns JSON).

| Action | Route | Does |
|---|---|---|
| `store` | `POST manage.engagements.store` | opens a pipeline from the Lead Show tab |
| `updateStatus` | `PUT manage.engagements.status` | hands `bookingDetails()` to `ChangeEngagementStatus` |
| `updateRemark` | `PUT manage.engagements.remark` | corrects `lost_reason` only (`RemarkRequest`); **404 unless the deal is Lost** |
| `assign` | `PUT manage.engagements.assign` | the team **and** the closing choice in ONE transaction |
| `updateZoomCloser` | `PUT manage.engagements.zoom-closer` | sets / clears `zoom_closer_id` (`ZoomCloserRequest`); a uuid that is not a manage user is a **422**, not a silent clear |
| `paymentCandidates` | `GET manage.engagements.payment-candidates` | JSON list for the receipt picker |
| `reopen` | `POST manage.engagements.reopen` | back to New, `lost_at` / `lost_stage` cleared (⚠️ `lost_reason` kept — see above) |
| `destroy` | `DELETE manage.engagements.destroy` | soft-deletes the engagement + its bookings |

`assign()` shares one transaction with `ApplyClosingChoice` on purpose: nested repository
transactions become savepoints, so a failure rolls back the whole save rather than leaving the team
re-cut against a receipt that never moved. ⚠️ The closing choice is applied **only when the
`purchase_history_id` FIELD is present in the request** (`$request->has(...)`), never merely because
a mode was sent — every booking form echoes the mode, so acting on that alone would drop a link on a
surface that never showed the picker.

`{id}` is always the engagement **uuid**, resolved by an explicit `where('uuid', $id)->firstOrFail()`.

## Related files

**Backend — models**
- [src/Engagement/Engagement.php](/src/Engagement/Engagement.php) — the three status maps, `STAGES` + `stageForStatus()`, `resolvedClosingModeId()`, `commissionBreakdown()` / `commissionShares()`, `assigneesFor()` / `firstAssignee()` / `assigneeUserIds()`
- [src/Engagement/EngagementAssignment.php](/src/Engagement/EngagementAssignment.php) — one admin holding one role on one engagement, plus its `role_share`

**Backend — writes**
- [src/Engagement/Repositories/EngagementRepository.php](/src/Engagement/Repositories/EngagementRepository.php) · [its facade](/src/Engagement/Facades/EngagementRepository.php)
- [app/Actions/ChangeEngagementStatus.php](/app/Actions/ChangeEngagementStatus.php) — `BOOKING_STATUSES` + `takesBooking()`; the booking write and the stage move in one transaction
- [app/Actions/SyncLeadStatusFromEngagements.php](/app/Actions/SyncLeadStatusFromEngagements.php)
- [src/AppointmentEngine/Services/CrmPipeline.php](/src/AppointmentEngine/Services/CrmPipeline.php) — the engine's lane: `ensureOpen()`, `syncCloser()`, `advanceStatus()` (outside this module, but it writes engagements)

**Backend — HTTP**
- [app/Http/Controllers/Manage/Engagements/EngagementsController.php](/app/Http/Controllers/Manage/Engagements/EngagementsController.php)
- [app/Http/Requests/Manage/Engagements/StoreRequest.php](/app/Http/Requests/Manage/Engagements/StoreRequest.php) · [StatusRequest.php](/app/Http/Requests/Manage/Engagements/StatusRequest.php) · [AssignRequest.php](/app/Http/Requests/Manage/Engagements/AssignRequest.php) · [RemarkRequest.php](/app/Http/Requests/Manage/Engagements/RemarkRequest.php) · [ZoomCloserRequest.php](/app/Http/Requests/Manage/Engagements/ZoomCloserRequest.php)
- [app/Http/Requests/Manage/Engagements/Concerns/ValidatesRoleAssignment.php](/app/Http/Requests/Manage/Engagements/Concerns/ValidatesRoleAssignment.php) — the `roles` rules, shared with the booking update
- [app/Http/Controllers/Concerns/AssignsPipelineRoles.php](/app/Http/Controllers/Concerns/AssignsPipelineRoles.php) — uuid → `users.id`, refusing non-manage users

**Backend — visibility**
- [src/Auth/Support/LeadVisibility.php](/src/Auth/Support/LeadVisibility.php)

**Frontend**
- [resources/js/Pages/Manage/Leads/Partials/Tabs/PipelineTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/PipelineTab.vue) — one card per engagement
- [resources/js/Pages/Manage/Leads/Partials/Pipeline/OpenPipelineModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/OpenPipelineModal.vue) · [AssignEngagementModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/AssignEngagementModal.vue) · [StatusChangeModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/StatusChangeModal.vue)
- [resources/js/utils/engagementStatus.js](/resources/js/utils/engagementStatus.js) — the mirrored constants + `needsStatusModal()`

**Migrations** — `2026_07_14_100001_create_engagements_table` · `2026_07_14_100003_add_engagement_id_to_appointments_table` · `2026_07_14_100004_backfill_engagements_from_appointments` · `2026_07_31_000002_add_purchase_history_id_to_engagements_table` · `2026_07_31_000003_backfill_entitlement_purchase_links` · `2026_08_02_100001_create_engagement_assignments_table` (drops the three owner columns, backfills rows, rewrites `lost_stage`) · `2026_08_03_100001_merge_commission_split_into_assignments` (adds `role_share`, drops `booking_commission_splits`) · `2026_08_11_100001_rename_special_remark_to_lost_reason` · `2026_08_12_500001_add_closing_mode_to_engagements_table` · `2026_09_28_190000_add_zoom_closer_id_to_engagements_table`. Full column tables in [perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md).

**Tests**
- [tests/Feature/Engagement/EngagementLifecycleTest.php](/tests/Feature/Engagement/EngagementLifecycleTest.php) — open → status → booking → cancel → lost, the roll-up, the required-reason-on-lost rule, and the floor-plan-belongs-to-this-project guard
- [tests/Feature/Engagement/EngagementVisibilityTest.php](/tests/Feature/Engagement/EngagementVisibilityTest.php)
- [tests/Feature/Engagement/CommissionShareTest.php](/tests/Feature/Engagement/CommissionShareTest.php) — the standing team, the payment roll, and the assign endpoint's rules

## Related chapters
[booking.md](/docs/modules_handbook/manage/engagement/booking.md) ·
[commission.md](/docs/modules_handbook/manage/engagement/commission.md) ·
[closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md) ·
[perimeter.md](/docs/modules_handbook/manage/engagement/perimeter.md)
