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

⚠️ **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`** | **7** — `1 New` (brand), `2 Contacting` (amber), `3 Appointment Set` (indigo), `4 With Closer` (violet), `6 Booked` (**teal**), `8 Converted` (**green**, the deepest thing in the column), `9 Lost` (rose) | The **selectable** set. It drives every dropdown, every filter, every chip, and — critically — `StatusRequest`'s `Rule::in(array_keys(Engagement::STATUSES))` |
| **`ALL_STATUSES`** | **9** — 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 five coarse `STAGES` — Appointment / Closer /
Follow-Up / Won / Lost. 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` and one in the assignments migration still say "an
  `EngagementAssignment::ROLE_*` key". **No such constants exist** — they are stale comments.
- **`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` | ❌ | ❌ |
| `rollTeamForPayment` | `(Engagement $e)` | `Engagement` | ❌ | ❌ |

**`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()`. `lost_reason` **is** — see below.

**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; }
elseif ($engagement->lost_at !== null)  { $lost_at = null;  $lost_stage = null; $lost_reason = null; }
if ($status === STATUS_COMPLETED)       { $won_at = now(); }
```

Leaving Lost by ANY route 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`.

**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 seven 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`) | ❌ |
| Appointments backfill | migration `2026_07_14_100004` — writes the model directly | ❌ | n/a |

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

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

It is stamped in **exactly four places**, all inside `EngagementRepository`: `open()`,
`changeStatus()`, `assign()`, `reopen()`.

⚠️ **Nothing else stamps it** — not the three receipt writers, 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) | 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) — five
write actions plus one JSON read, every one re-checking `LeadVisibility::allows()` against the
engagement's lead and returning `back()`.

| 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` |
| `assign` | `PUT manage.engagements.assign` | the team **and** the closing choice in ONE transaction |
| `paymentCandidates` | `GET manage.engagements.payment-candidates` | JSON list for the receipt picker |
| `reopen` | `POST manage.engagements.reopen` | back to New, loss record cleared |
| `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)

**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)
- [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`). 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)
