# Closing Modes & Pipeline Roles (Setting → Closing Modes)

**Portal:** Manage · **Namespace:** `Src\Engagement` ·
**Routes:** `manage.closing-modes.*` (8) + `manage.pipeline-roles.*` (6) ·
**Permissions:** `view-closing-modes` to view · `manage-closing-modes` to write ·
**Nav:** **Setting → Closing Modes** — a tab of the Setting hub
([`SettingTabs.vue`](/resources/js/Components/SettingTabs.vue)), not a sidebar entry
(GUIDELINES §15)

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

## What it does

Two admin-editable tables replaced what used to be PHP constants on `Booking` and
`EngagementAssignment`, so the sales vocabulary can change at runtime without a deploy:

- **`pipeline_roles`** — **WHO** can work a deal. The role vocabulary a team is assigned by.
- **`closing_modes`** + **`closing_mode_roles`** — **HOW** a deal was closed, and therefore how its
  commission divides.

Three single-holder flags across those tables also drive the payment automation, which is why this
page is not merely cosmetic configuration: editing a mode moves real behaviour with it.

## How it works

### `pipeline_roles` — the role vocabulary

| Column | Type | Notes |
|---|---|---|
| `key` | `string(30)` **unique** | the **stable machine identifier** stored on `engagement_assignments.role` and `closing_mode_roles.role`. Generated once on create, **never rewritten** — renaming a role's display name must not orphan its assignment rows |
| `name` | `string(60)` | the display name |
| `short_label` | `string(12)` nullable | the avatar-strip label; **null means "derive it"** |
| `color` | `string(20)` nullable | one of `BadgePalette::COLORS` |
| `hint` | `string` nullable | shown in the Assign modal |
| `status` | `unsignedInteger` | `1 Active` / `2 Inactive` |
| `sort_order` | `unsignedInteger` | set only on create (append at `max + 1`) — **there is no reorder UI** |
| `is_roll_target` | `boolean` | single-holder; see the flags below |
| `default_admin_id` | `unsignedBigInteger` nullable | a **`users.id`**; the standing team |

`PipelineRole::uniqueKey($name)` generates the key as
`Str::slug(Str::limit($name, 26, ''), '_')`, checking **`withTrashed()`** so a soft-deleted role's
key is never reused, and suffixing `_2`, `_3`, … on collision. Pinned by
`ClosingModeSettingTest::test_role_key_is_generated_and_survives_rename`.

### `closing_modes` + `closing_mode_roles` — the pools

A mode is `name` / `color` / `status` / `sort_order` plus its two flags; its **pools** are child rows
of `closing_mode_roles`: `role` (a `pipeline_roles.key`, same `string(30)` convention so the two join
on identical keys) × `share decimal(5,2)` (that role's **% of the deal's total commission**) ×
`position`.

⚠️ `closing_mode_roles` has **no uuid, no blame columns and no soft delete** — they are template
child rows, replaced as a group on every save (`writePools()` hard-deletes then recreates in array
order). Pool history is not kept.

⚠️ **`position` is the order EVERY surface shows the mode in**, not just the Setting card:
`ClosingMode::roles()` is `orderBy('position')` and `poolMap()` preserves it, so the pool map's key
order IS the admin's arrangement. The frontend must follow it —
[`visibleRoles()`](/resources/js/utils/closingModes.js) sorts by the mode's pools and only falls back
to the role CATALOGUE's order for a role that is *held but unpooled* (which has no position to take).
Before 2026-08-12 it used the catalogue order outright, so a pool added later — a "VSL speaker"
slotted third — rendered LAST in the Assign modal's cards while `poolSummary()`, printed directly
above them, still read it third: one screen, two orders. The fix moved the row of avatars in the
list's Team column and the Lead Show page's owner rows into the same order for free. Pinned by
[`closingModes.test.js`](/resources/js/utils/closingModes.test.js), whose second case asserts the
cards and that summary line agree.

⚠️ **`bookings.closing_mode_id` is an `unsignedInteger`, not an `unsignedBigInteger`**, pointing at a
`bigIncrements` primary key. It kept the type of the `closing_type` column it was renamed from. The
newer `engagements.closing_mode_id` — the column that actually holds the mode since 2026-08-12 — is a
correct `unsignedBigInteger`.

### The seed lives inside a MIGRATION — there is no seeder

`2026_08_03_200001_create_closing_modes_and_pipeline_roles.php::seedCurrentConfiguration()` ports the
constants that used to live on `Booking` / `EngagementAssignment`, so behaviour was identical before
any admin edit. **No `database/seeds/*` file and no factory mentions these tables**, so a fresh
database gets its vocabulary purely from that migration — which is also what
`ClosingModeSettingTest` and `CommissionShareTest` lean on after `RefreshDatabase`.

**Seeded roles** (`sort_order` 1–6):

| # | `key` | Name | Colour | Hint | Flag |
|---|---|---|---|---|---|
| 1 | `lead_gen` | Lead Gen | sky | Generated the lead (marketing / funnel). | — |
| 2 | `analyst` | Analyst | slate | Prepares the analysis for the buyer. | — |
| 3 | `appointment` | Appointment | amber | Qualifies the fresh lead and sets the appointment. | — |
| 4 | `closer` | Closer | violet | Works the appointment and books the unit. | — |
| 5 | `webinar_closer` | Webinar Closer | brand | Closed the deal in the webinar (payment funnel). | — |
| 6 | `follow_up` | Follow Up | indigo | Handles the post-booking process. | **`is_roll_target`** |

**Seeded modes** (both total exactly 100):

| Mode | Colour | Flag | Pools |
|---|---|---|---|
| **Webinar Closing** | brand | `is_payment_default` | `lead_gen 50` · `analyst 5` · `webinar_closer 25` · `follow_up 20` |
| **Non Webinar Closing** | violet | `is_manual_default` | `lead_gen 50` · `analyst 5` · `appointment 15` · `closer 25` · `follow_up 5` |

**The consequence that shapes the automation:** `appointment` and `closer` have **no pool** in
*Webinar Closing*. So when a payment closes a deal, their holders roll onto `follow_up` — the
"Appointment + Closer → Follow Up" line the help modal quotes. Pinned by
`CommissionShareTest::test_migration_seeds_the_previous_hardcoded_configuration`.

⚠️ **Never write a fixed role count into code or docs.** The vocabulary is admin-editable: six are
seeded, `assignmentCards()` iterates `PipelineRole::keys()`, and `ProjectLeadsExport` splats one
column per role. Any hard-coded number rots.

### The three single-holder flags

Each is held by **exactly one row**, promoted through a repository method that demotes every other
row in the same transaction (the `PaymentGateway::is_default` pattern) — never written through
`create()` / `update()`.

| Flag | Table | What it MEANS | What reads it |
|---|---|---|---|
| **`is_payment_default`** | `closing_modes` | *"this deal was closed on a paid project fee"* | `Engagement::resolvedClosingModeId()` (for a receipt-carrying deal), `BookingRepository::markPaymentClosing()`, `EngagementRepository::rollTeamForPayment()`, and `ApplyClosingChoice`'s mode ⟺ receipt rule |
| **`is_manual_default`** | `closing_modes` | the mode an unstamped, unpaid deal derives by | `Engagement::resolvedClosingModeId()` |
| **`is_roll_target`** | `pipeline_roles` | where the pre-payment team lands when a payment closes a deal | `EngagementRepository::rollTeamForPayment()` |

⚠️ **`is_roll_target` is labelled "After payment" in the UI, never "Roll target."** The flag names an
*event* the reader can recognise — when a payment closes a deal, teammates whose role has no pool in
the payment mode move to this role — where the internal term named only the mechanism. **The column
keeps its name.**

The promote/demote methods take `lockForUpdate()` and re-assert their precondition **under the
lock**, because the controller's guard is check-then-act:

- `makePaymentDefault()` / `makeManualDefault()` / `makeRollTarget()` re-assert the row is **ACTIVE**
  (409 otherwise) — the flag must never land on a row a concurrent write just deactivated or deleted.
- `inactivate()` and `delete()` re-assert the row is **flag-less** (409 otherwise) — the defaults
  must always point at an active, live row.

### Deactivate vs delete — the guard matrix

An in-use or flagged row can only be **deactivated**, never deleted. The controllers check first and
flash a specific message; the repositories re-check under a lock.

| Action | Blocked when | Message |
|---|---|---|
| Deactivate a mode | it holds either default flag | *"'{name}' is the payment/default mode — hand that flag to another mode first."* |
| Delete a mode | any **booking** (⚠️ `withTrashed()` — a soft-deleted booking still names its mode) references it | *"'{name}' is stamped on {n} booking(s) — it can only be deactivated, not deleted."* |
| Delete a mode | it holds either default flag | as above |
| Deactivate a role | it is the roll target | *"'{name}' is the roll-target role — hand that flag to another role first."* |
| Delete a role | any `engagement_assignments` row holds it | *"'{name}' is held on {n} engagement(s) — it can only be deactivated, not deleted."* |
| Delete a role | any mode's pool names it | *"'{name}' carries a pool in {n} closing mode(s) — remove it from those modes first."* |
| Promote any flag | the row is inactive | *"'{name}' is inactive — activate it before making it the …"* |

⚠️ **There is no guard on deleting a role that is somebody's `default_admin_id` target**, because
nothing references a role by that column — the standing team simply stops seeding it.

### Short labels — why they are stored, not derived

`PipelineRole::shortLabel()` returns the stored `short_label`, else `deriveShortLabel($name)`:

- **a two-word name** compresses to initial + slash + second word — *Webinar Closer* → `W/close`,
  *Lead Gen* → `L/gen`. That comes from the RULE, not from any table.
- **a single word** passes through unless it is longer than 8 characters, when it takes a
  `LABEL_ABBREVIATIONS` contraction (*Appointment* → `Appt`) or is clipped to 4.
- ⚠️ **3+ words** join the tail into one "word", so *Senior Sales Manager* → `S/sale` — the
  abbreviation table is never consulted for a multi-word tail.

⚠️ **It is stored rather than derived because derivation COLLIDES.** The clip turns both *Presenter*
and *Presentation* into `Pres`, and both *Negotiator* and *Negotiation* into `Nego` — two roles
printing the same thing under their avatars, which no contraction table can prevent, because a table
only knows the words somebody thought to add and roles are admin-named (including in Malay:
*Penyelaras* → `Peny`).

**The column is nullable and blank means derive**, so nothing needed backfilling and a role only
names one when the automatic answer is wrong.

⚠️ **This used to live in `closingModes.js` and should not have.** It is this module's vocabulary, so
by GUIDELINES §4 it belongs on the model as a constant plus a static helper — and while it sat in the
browser the server could not see it, so the controller shipped a NAME and every surface re-derived a
label from it. `SharesClosingConfig` now ships **`short_label` already resolved** (what to print)
alongside **`short_label_custom`** (the raw column: null = derived, which the form seeds from and the
roles table marks *auto*). **The frontend prints the value and never re-derives it**, and the roles
table **flags any two roles whose labels are identical** — the failure this exists to fix, shown
where it can be fixed. Pinned by `ClosingModeSettingTest` (`test_the_derived_label_is_the_models_own`,
`test_a_stored_label_beats_the_derivation`, `test_the_shipped_label_is_already_resolved`).

⚠️ **A derived label can never exceed the 12-char ceiling** (a single word ≤ 8, or `1 + 1 + 5 = 7`
for the two-word rule) — `LABEL_MAX` only bites the admin's own.

### Colour — a real signal, and three lists that must agree

A mode's and a role's stored `color` is not decoration. It paints the mode's card dot, each role's
dot in the Pipeline Roles table, **that role's bar in every mode's pool chart**, and the swatch beside
its pool row in the mode form — so the colour picked on the role form is the colour read wherever the
role appears.

Three lists have to agree, and each exists for a different reason:

1. **`PALETTE`** in [`closingModes.js`](/resources/js/utils/closingModes.js) — the **Tailwind
   classes** (`dot` / `bar` / `chip` / `swatch`), written out in full and never interpolated, because
   Tailwind only emits classes it can find literally in the source (`bg-${color}-500` compiles to
   nothing).
2. **`COLOR_GROUPS`** beside it — what the picker **offers**, grouped by hue, plus the ten
   `QUICK_COLORS` shown without expanding.
3. **[`BadgePalette::COLORS`](/src/Engagement/BadgePalette.php)** — the 23 names the two
   `StoreRequest`s **accept** (`Rule::in`), so a payload outside the library is refused rather than
   silently rendering grey via `paletteOf()`'s slate fallback.

Edit them together. Both form modals pick from the shared
[`ColorPicker.vue`](/resources/js/Components/ColorPicker.vue) — a **swatch grid**, because a list of
colour *words* was a colour you could not see. A colour picked from the library stays pinned in the
top row, so editing never hides what is set.

### The mode ⟺ receipt rule — ONE decision

The split editor's first question is **"How was this closed?"**. The answer picks the role pools
*and* decides whether the deal carries a project-fee receipt, because the mode flagged
`is_payment_default` **means** "closed on a paid project fee". So:

- Choosing that mode reveals **"Which payment paid for this deal?"** — the project's confirmed
  payments, one click to link (`engagements.purchase_history_id`).
- Choosing any other mode **DROPS the link** on save. A deal that is no longer payment-closed must not
  keep a receipt, or `PurchaseFulfiller` goes on skipping replays for it.

⚠️ **That drop is the server's FENCE, not the everyday path.** The editor now **refuses** to select a
non-payment mode while a receipt is attached — the other cards are disabled, with the reason stated
beside them (*"This deal is closed on a linked project-fee payment. Unlink it below to close it any
other way."*). Dropping a payment link is a real bookkeeping change, and it should not happen as a
side effect of answering a different question. The admin unlinks in the receipt panel first, and the
mode cards open up. The server rule is unchanged and still holds for any payload that reaches it.

⚠️ **Unlinking is a real BUTTON**, beside the *Linked* badge. It used to be "click the selected card
again" — a shortcut that still works, but nothing on screen said so, and it is now the gate the mode
picker depends on. ⚠️ Tailwind v4's preflight gives `button` `cursor: default`, so every clickable
control in this editor carries an explicit `cursor-pointer`; without it an action reads as a label.

Both halves live in [`App\Actions\ApplyClosingChoice`](/app/Actions/ApplyClosingChoice.php), called by
the assign endpoint **and** the booking update, so the rule cannot drift between them. ⚠️ That action
now applies the **whole** money decision — the mode, the receipt it implies, and the commission
adjustment — because the admin makes all three in one place: the shared Commission & Split editor.
The receipt has its own `$settleReceipt` presence signal, so writing an adjustment alone can never
drop a link nobody touched.

⚠️ **The picker deliberately crosses LEADS, and that is the whole reason it exists.** The automatic
link matches a payment to its own lead; a buyer who types a different phone number at the gateway than
the CRM holds resolves to a *different* lead (or none), so the payment never finds its engagement.
[`PurchaseGrantLinker`](/app/Services/Payment/PurchaseGrantLinker.php) cannot repair that case — it
only ever offers rows of the payment's own lead. This lane runs the other way, **from the deal over
the PROJECT's payments**, which is why every option shows the **buyer's name and phone**: the admin
must see whose money they are claiming.

Fences that are not optional:

- **`candidateQuery()` is the ONE definition** of what may be claimed — the project's `STATUS_ACTIVE`
  payments that no OTHER engagement holds (its own current link stays offerable, so re-saving never
  drops it) — and it is **re-applied on WRITE**, not just to build the list, so a hand-written
  `purchase_history_id` cannot reach another project's money.
- **The receipt is settled only when its FIELD is present** in the request, never merely because a
  mode was sent. Every booking form echoes `closing_mode_id`, so acting on that alone would drop a
  link on a surface that never showed the picker. *Null means "no receipt"; absent means "never
  asked".*
- **`EngagementRepository::setPurchase()`** is the explicit-choice writer — it may REPLACE or clear,
  unlike `attachPurchase()`, whose never-overwrite rule protects the automated repair lane.
  Bookkeeping only: nothing here fulfils, revokes or rolls a team.
- Candidates are fetched **on demand** (`GET manage.engagements.payment-candidates`, JSON,
  `manage-leads`) when the payment mode is on screen — a list page must not ship every deal's
  payments to the browser.

### The mode lives on the DEAL (2026-08-12)

`engagements.closing_mode_id` is the home; **`bookings.closing_mode_id` is a mirror nothing reads**,
kept in step by `EngagementRepository::setClosingMode()` only so the column can be dropped in its own
change. `resolvedClosingModeId()` reads the engagement's stamp, else the payment default (when the
deal holds a receipt), else the manual default.

It moved because the old home made the mode **un-choosable for most of a deal's life**. A deal with
no unit booked had nowhere to keep a pick, so three things followed, each of which looked like a
separate quirk: the split editor stated the mode read-only (*"Derived until a unit is booked"*), the
Booking modal offered the picker only on EDIT, and `ApplyClosingChoice` silently discarded any choice
but the two defaults, because those were the only ones the next render could reproduce. The Assign
modal is exactly where people are put into the pools that mode defines — so the team was being
assigned against a mode nobody had agreed to, and saying so was impossible. An engagement can also
hold SEVERAL bookings (`bookings()` is a hasMany, `booking()` merely the latest), which was never a
home for a fact a deal has one of.

Now: **any mode, at any stage, from either surface** — the Assign modal, the Booking modal's create
form and its edit form all mount the one [`Components/Sales/ClosingModePicker.vue`](/resources/js/Components/Sales/ClosingModePicker.vue).
The create form pre-selects the mode the deal would otherwise have derived, so confirming costs
nothing and changing it is one click; the receipt and the team split stay on edit, because a booking
that does not exist yet has no split to divide. Pinned by
`BookingClosingModeTest::test_the_assign_modal_can_set_the_mode_before_any_booking_exists` and
`CommissionShareTest::test_a_mode_chosen_on_a_deal_with_no_booking_sticks`.

⚠️ **The `assign` endpoint enters `ApplyClosingChoice` when ANY of the three controls was sent** —
mode, receipt or adjustment. The mode had to join that list: it is the only one of the three a
booking-less deal can carry, so the original receipt-or-adjustment gate meant the new picker saved
nothing on precisely the deals it was added for, with no error to notice.

⚠️ **The in-use delete guard counts ENGAGEMENTS, not bookings** (`ClosingModesController`), for the
same reason: a mode stamped on a pipeline that has booked nothing would otherwise be deletable out
from under it. Pinned by `ClosingModeSettingTest::test_a_mode_stamped_on_a_deal_cannot_be_deleted`.

Pinned by `CommissionShareTest` (`test_assign_links_a_project_payment_that_belongs_to_another_lead`,
`test_switching_off_the_payment_mode_drops_the_linked_payment`,
`test_payment_candidates_hide_a_receipt_another_engagement_holds`,
`test_a_payment_of_another_project_cannot_be_claimed`,
`test_a_save_without_the_receipt_field_keeps_the_linked_payment`).

### The automation the flags drive

**On a paid project fee**, `PurchaseFulfiller::fulfillProject()` opens the engagement with the
receipt, then runs two idempotent, fail-soft steps inside a `try/catch` (closing bookkeeping must
never block the money path):

1. `BookingRepository::markPaymentClosing()` — stamps the payment-default mode, **fill-blank-only**
   and never onto a CANCELLED booking, so a human's explicit choice always wins and a dead sale's
   paperwork is not branded by a later payment.
2. `EngagementRepository::rollTeamForPayment()` — every admin on a role with **no pool in the
   payment-default mode** moves to the roll-target role, and their old rows are deleted. The keep-set
   is `array_keys($mode->poolMap()) + [$target->key]`, so it is **derived from the mode's
   configuration**: editing the mode moves the automation with it. A no-op when either the
   payment-default mode or the roll target is unconfigured.

⚠️ The manual repair lane, `PurchaseGrantLinker::linkProject()`, stamps the mode but **deliberately
does NOT roll the team** — it repairs ledgers, often for long-closed deals, and must not reshuffle a
live pipeline's people.

⚠️ `rollTeamForPayment()` moves people with `firstOrCreate(['role', 'admin_id'])` and **carries no
`role_share`**, so a carefully split pre-payment team lands as an equal split on the roll target.

**On every newly opened pipeline**, each active role's `default_admin_id` is auto-assigned — the
standing team. Three conditions all apply (`$isNew && $withDefaultTeam && $createdAt === null`); see
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md).

### Caching — three flush points

`ClosingMode::allCached()` and `PipelineRole::allCached()` are **per-request static caches** (the
tables are tiny and read on nearly every sales page). They are flushed in three places, and all three
are necessary:

1. every write in `ClosingModeRepository`;
2. every write in `PipelineRoleRepository`;
3. **`Queue::before()` in [`AppServiceProvider`](/app/Providers/AppServiceProvider.php)** — a
   long-running Horizon worker keeps statics across jobs, so without this an admin's edit would never
   reach the payment automation until a `horizon:terminate`.

⚠️ Tests must flush both caches in `setUp()` or the seeded config leaks between cases.

⚠️ `allCached()` includes **inactive** rows (a holder on an inactive role must stay visible) but
**not trashed** ones. `PipelineRole::keys()` therefore accepts inactive roles as an assignment
whitelist — the "no net-new holders on an inactive role" rule is enforced only at the request layer,
in `ValidatesRoleAssignment`.

### `SharesClosingConfig` — one serializer, five render sites

[`SharesClosingConfig`](/app/Http/Controllers/Concerns/SharesClosingConfig.php) ships both lists to
every page that renders the sales team UI. **Sending ALL rows including inactive ones is
deliberate**: an inactive mode or role must still label the historical rows that reference it, while
the `active` flag tells the UI what to offer for NEW picks.

| Prop | Keys |
|---|---|
| `closingModes` | `id` (the integer, compared against `bookings.closing_mode_id`), `uuid` (what the write routes use), `name`, `color`, `active`, `is_payment_default`, `is_manual_default`, `pools` |
| `pipelineRoles` | `uuid`, `key`, `name`, `short_label` (**already resolved**), `short_label_custom` (**the raw column**), `color`, `hint`, `sort_order`, `active`, `is_roll_target`, `default_admin` (`null` or `{uuid, name}`) |

⚠️ **`pools` is cast to `(object)`** — an empty pool map would otherwise serialize as a JSON array
`[]` instead of `{}`, and the frontend does `mode.pools?.[role.key]` everywhere.

⚠️ `pipelineRoleProps()` ships **no `id`** and **no `default_admin_id`** — the frontend only ever
sees uuids.

Consumers: `ClosingModesController@index`, `SalesProjectsController@bookingsView` and `@show`,
`LeadsController@show` and its quick-view JSON endpoint.

### The page

[`Pages/Manage/ClosingModes/Index.vue`](/resources/js/Pages/Manage/ClosingModes/Index.vue) takes
exactly three props — `closingModes` (+ `bookings_count`), `pipelineRoles` (+ `assignments_count`,
`pools_count`) and `adminOptions` — and renders two sections.

**Mode cards** (two-column grid; an inactive mode is dashed and dimmed). Each carries its colour dot,
name, an emerald **Payment** badge and/or a sky **Default** badge, the stamped-booking count, and
**the pool chart** — one row per pool: the role name, a bar **painted in the ROLE's colour**, and the
share. Actions: Edit · Set as payment mode · Set as default · Activate/Deactivate · Delete, the last
disabled with the reason in its title when the row is in use or flagged.

**The roles table.** Columns: **Role** (dot + name + hint, then the short-label chip, a grey **auto**
tag when nothing is stored, and an amber **"Same as another role"** warning when two labels collide) ·
**Default admin** (plus the indigo **After payment** pill on the roll target) · **In use**
(`{n} assignment(s) · {n} mode pool(s)`) · **Status** · **Actions**.

Both form modals and a static **help modal** (opened by the `?` buttons) hang off the page, plus two
`ConfirmModal`s for the deletes — never the native `confirm()` (GUIDELINES §14).

Details worth knowing before editing the modals:

- The mode form only offers **active** roles for a NEW pool row, but keeps a stored row whose role
  went inactive visible, so editing never silently drops it. Already-used roles disappear from the
  other rows' dropdowns.
- The role form seeds its short-label input from **`short_label_custom`** (the raw column) and uses
  the resolved `short_label` as the **placeholder**. ⚠️ That distinction is load-bearing: seeding
  from the resolved value would freeze today's derived label into the column the moment an untouched
  form is saved.
- The role form keeps the current default admin selectable even after they fall out of the offered
  pool (tagged *"(unavailable)"*) — a blank select would read as "no default" while one is stored.
- ⚠️ Both modals post to **hardcoded URL strings**, not Ziggy route names.

### Who can actually reach this

`RolesSeeder` grants both permissions to **super-admin** and to the legacy **admin** role.
⚠️ **`SeedCommonRolesAction` does not mention them at all**, so a Sales Leader or Agent created
through it gets neither — a reader assuming a sales leader can edit commission configuration is
wrong.

⚠️ **There is no `GET /manage/pipeline-roles`.** The whole pipeline-roles group is gated on the
**write** permission, because the role list is rendered by `ClosingModesController@index`. A
`view-closing-modes`-only admin therefore sees the roles but cannot touch any pipeline-role endpoint.

## Related files

**Backend — models**
- [src/Engagement/ClosingMode.php](/src/Engagement/ClosingMode.php) — `poolMap()`, `allCached()` / `activeCached()` / `byId()` / `paymentDefault()` / `manualDefault()` / `flushCache()`
- [src/Engagement/ClosingModeRole.php](/src/Engagement/ClosingModeRole.php) — a plain (hard-deletable) child row
- [src/Engagement/PipelineRole.php](/src/Engagement/PipelineRole.php) — `shortLabel()`, `deriveShortLabel()`, `LABEL_ABBREVIATIONS`, `LABEL_MAX`, `uniqueKey()`, `keys()`, `labelMap()`, `rollTarget()`
- [src/Engagement/BadgePalette.php](/src/Engagement/BadgePalette.php) — the 23 accepted colour names

**Backend — writes**
- [src/Engagement/Repositories/ClosingModeRepository.php](/src/Engagement/Repositories/ClosingModeRepository.php) · [facade](/src/Engagement/Facades/ClosingModeRepository.php)
- [src/Engagement/Repositories/PipelineRoleRepository.php](/src/Engagement/Repositories/PipelineRoleRepository.php) · [facade](/src/Engagement/Facades/PipelineRoleRepository.php)
- [app/Actions/ApplyClosingChoice.php](/app/Actions/ApplyClosingChoice.php)

**Backend — HTTP**
- [app/Http/Controllers/Manage/Engagements/ClosingModesController.php](/app/Http/Controllers/Manage/Engagements/ClosingModesController.php) · [PipelineRolesController.php](/app/Http/Controllers/Manage/Engagements/PipelineRolesController.php)
- [app/Http/Requests/Manage/Engagements/ClosingModes/StoreRequest.php](/app/Http/Requests/Manage/Engagements/ClosingModes/StoreRequest.php) (pools 1–12 rows, each `min:0.01`, no duplicate role, total exactly 100) · [UpdateRequest.php](/app/Http/Requests/Manage/Engagements/ClosingModes/UpdateRequest.php)
- [app/Http/Requests/Manage/Engagements/PipelineRoles/StoreRequest.php](/app/Http/Requests/Manage/Engagements/PipelineRoles/StoreRequest.php) (⚠️ its `withValidator()` refuses a `default_admin` who is not a manage-portal user, because the controller's resolver would otherwise silently null it and read as "saved") · [UpdateRequest.php](/app/Http/Requests/Manage/Engagements/PipelineRoles/UpdateRequest.php)
- [app/Http/Controllers/Concerns/SharesClosingConfig.php](/app/Http/Controllers/Concerns/SharesClosingConfig.php) · [ResolvesAssignableManagers.php](/app/Http/Controllers/Concerns/ResolvesAssignableManagers.php) · [ResolvesManageUserId.php](/app/Http/Controllers/Concerns/ResolvesManageUserId.php)

**Frontend**
- [resources/js/Pages/Manage/ClosingModes/Index.vue](/resources/js/Pages/Manage/ClosingModes/Index.vue) · [Partials/ClosingModeFormModal.vue](/resources/js/Pages/Manage/ClosingModes/Partials/ClosingModeFormModal.vue) · [Partials/PipelineRoleFormModal.vue](/resources/js/Pages/Manage/ClosingModes/Partials/PipelineRoleFormModal.vue) · [Partials/ClosingModesHelpModal.vue](/resources/js/Pages/Manage/ClosingModes/Partials/ClosingModesHelpModal.vue)
- [resources/js/Components/Sales/ClosingModePicker.vue](/resources/js/Components/Sales/ClosingModePicker.vue) — the "How was this closed?" cards, mounted by BOTH forms that ask (the Booking modal's create form and `CommissionSplitEditor` on edit / assign), so the two offer the same modes under the same rule. Presentation plus that rule; the SIDE EFFECTS of a change (dropping the receipt, pre-fetching the payment list) stay with the caller, which is the only one that knows what else a change touches
- [resources/js/Components/ColorPicker.vue](/resources/js/Components/ColorPicker.vue) · [resources/js/utils/closingModes.js](/resources/js/utils/closingModes.js) (⚠️ `resolveMode()` reads `row.closing_mode_id` — the ENGAGEMENT's, not its booking's) · [resources/js/Components/SettingTabs.vue](/resources/js/Components/SettingTabs.vue)

**Migrations**
- `2026_08_03_200001_create_closing_modes_and_pipeline_roles` — creates all three tables, renames `bookings.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 written as a literal, GUIDELINES §7)
- `2026_08_12_500001_add_closing_mode_to_engagements_table` — **moves the mode to the deal**: adds `engagements.closing_mode_id` and backfills it from each engagement's latest live booking (ascending `id`, so the highest wins — the row `booking()` resolves to). `bookings.closing_mode_id` is deliberately NOT dropped here; a money column earns its removal in its own change, once this one has run in production

**Tests** — [ClosingModeSettingTest](/tests/Feature/Engagement/ClosingModeSettingTest.php) (the 100% pool rule, single-holder flags, both delete guards, key generation surviving a rename, and the five short-label cases) · [CommissionShareTest](/tests/Feature/Engagement/CommissionShareTest.php) (the seed, the receipt fences, the payment roll)

## Related chapters
[commission.md](/docs/modules_handbook/manage/engagement/commission.md) — what the pools are used for ·
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) — assignment and the standing team ·
[booking.md](/docs/modules_handbook/manage/engagement/booking.md) — where the mode is stamped ·
[retired.md](/docs/modules_handbook/manage/engagement/retired.md) — `is_entry` / `is_closer_role`
