# Commission — the amount, and the split

**Portal:** Manage · **Surfaces:** every engagement table's Commission cell, the Booking modal's
*Commission & Split* tab, the Assign modal, the Team Performance rail, the project Show headline
cards, and the Leads export

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

## What it does

Commission has **two independent halves, and they never touch each other**:

1. **The AMOUNT** — one money figure per **booking**: the derivation
   (`booking price × projects.commission_rate`) **plus** an optional signed adjustment that has to
   say why. **The figure itself is never stored.**
2. **The SPLIT** — a set of **percentages** per **engagement**, derived from the team
   (`engagement_assignments`) × the resolved closing mode's role pools (`closing_mode_roles`).
   **No money is ever stored in the split.** Every RM figure a person sees is
   `amount × percentage / 100`, computed at render time.

**There is no stored split table.** `booking_commission_splits` existed for four days (created
`2026_07_30_100006`, dropped `2026_08_03_100001`) and never gained a production row — a reader
tracing migrations will find the CREATE and should not go looking for the table.

---

## Part A — the AMOUNT

### The figure is DERIVED, then ADJUSTED — never replaced

[`Booking::commissionAt($rate, ?int $basis = null): ?string`](/src/Engagement/Booking.php):

```php
if ($this->commission !== null) {                       // ⚠️ legacy only — see below
    return number_format((float) $this->commission, 2, '.', '');
}
$auto = $this->autoCommissionAt($rate, $basis);         // base × rate, or null
if ($auto === null) {
    return null;
}
return number_format($auto + (float) ($this->commission_adjustment ?? 0), 2, '.', '');
```

**`autoCommissionAt()` is its own method on purpose**: every surface that lets an admin adjust the
figure has to show what it is adjusting FROM. An adjustment with the formula hidden is just the old
absolute override wearing a plus sign.

**Why it stopped being an absolute override.** `bookings.commission` used to answer *"what is the
number"*, which reads fine on the row and tells you nothing a month later: a deal showing RM 38,000
where the formula says RM 41,731 could be a negotiated rate, a rebate, a correction or a typo, and
nobody can tell which. **`commission_adjustment` + `commission_adjustment_reason` answer "what is the
number AND why is it not the formula"** — the derivation stays visible, the difference is explicit,
and the reason is **required** whenever the adjustment moves the figure. The exception becomes
auditable instead of merely present.

- **A null rate yields `null`, not `0`.** So does a null base. A rate of `0` (not null) yields
  `"0.00"` — a real zero, not a blank.
- **An adjustment with no base yields null** — there is nothing to adjust.
- ⚠️ **It returns a 2-dp decimal STRING**, matching the money casts. Anything that sums it must cast
  to float, and anything that exports it must cast or the spreadsheet column is unsummable text.
- ⚠️ **`bookings.commission` survives as a LEGACY absolute figure and still wins when set.**
  `2026_08_10_100001` converted every value it could into an adjustment (`manual − auto`) and nulled
  the column; a row whose auto figure cannot be computed (no project rate, or no price) has no
  formula to be an adjustment *on*, so it keeps its absolute value. **Nothing writes the column any
  more.** See [retired.md](/docs/modules_handbook/manage/engagement/retired.md).

### The base — two links, not three

[`Booking::commissionBaseAmount()`](/src/Engagement/Booking.php):

1. the price named by the project's **`commission_basis`** (`1 SPA` → `spa_price`, `2 Net` →
   `net_price`; the column is NOT NULL and defaults to Net);
2. **the other price**, so a booking with only one price still yields a commission;
3. → `null`.

⚠️ **There is no third "legacy `price`" link.** `bookings.price` was dropped by
`2026_07_31_100001` after its values were backfilled into `net_price` — which, being the default
basis, left the derivation unchanged. Older documentation describing a `price` fallback is wrong,
and so is any query written against it.

⚠️ When `$basis` is null it is read off the **loaded `project` relation**; if that relation is null
(unloaded, or the project soft-deleted so the global scope nulls it) it silently defaults to **Net**.

### Estimated vs real

`engagementCard()` is the canonical rule:

```php
$baseAmount       = $booking?->commissionBaseAmount($basis);
$hasOwnCommission = $baseAmount !== null || $booking?->commission !== null;
$commissionValue  = $hasOwnCommission
    ? $booking->commissionAt($rate, $basis)
    : ($rate !== null && $facts['price_from'] !== null
        ? number_format((float) $facts['price_from'] * (float) $rate / 100, 2, '.', '')
        : null);
```

`estimated` is true **iff** a value exists AND the booking supplied neither a price nor a legacy
absolute figure. ⚠️ An adjustment alone does not make a figure real — there is no base to adjust. The estimate is the project's **canonical** `price_from × rate` — `catalog_projects.price_25`
for a catalogue-linked project, else `projects.price_from`. It is a stand-in until the real unit
price is recorded.

⚠️ **A priced booking on a rate-less project shows "—", never an estimate.** `hasOwnCommission` is
already true, so the estimate branch is skipped and `commissionAt(null)` returns null.

On screen an estimate wears a small amber **`est.`** tag ([`CommissionCell.vue`](/resources/js/Components/Sales/CommissionCell.vue));
the explainer `?` button exists only on the project Show page, because the explainer needs that
project's price band. In the export it gets **its own Yes/No column**, since a spreadsheet has no
tooltip and an unconverted forecast would otherwise be summed as money already banked.

### Every reader applies the adjustment

There are four independent implementations of `auto + adjustment` — the model, two raw SQL
expressions and one PHP loop — because each is answering a different question at a different layer.
**Change one and you must change all four.**

| Reader | Where |
|---|---|
| `Booking::commissionAt()` / the `commission_value` accessor | the model — every card, payload and PHP total goes through it |
| The booking-list **sort SQL** (`BOOKING_SORTS['commission']`) | correlated subquery, `coalesce(bk.commission, derivation + coalesce(bk.commission_adjustment, 0))` |
| The pipeline board's **column totals** | an aggregate over the same shape, plus the `price_from` estimate |
| `leadSummary()` | a PHP loop that adds `booking?->commission_adjustment` itself |
| `Manage\Sales\DashboardController::commissionTotal()` | its own SQL, now `coalesce(bookings.commission, derivation + adjustment)` |

⚠️ **`Sales\DashboardController::commissionTotal()` used to honour neither the override nor the
adjustment** — it recomputed basis × rate and nothing else, so its card quietly disagreed with every
list on any deal that carried one. That is fixed, but it still diverges in two ways nobody expects:
it uses `coalesce(p.commission_rate, 0)` (a null rate contributes 0, where every list reader yields
NULL), and it scopes by `bookings.status IN (ACTIVE, COMPLETED)` rather than by engagement status.

### The sort reproduces the displayed formula exactly

```sql
(select coalesce(bk.commission,
        (case when p.commission_basis = 1 then coalesce(bk.spa_price, bk.net_price)
              else coalesce(bk.net_price, bk.spa_price) end * p.commission_rate / 100)
        + coalesce(bk.commission_adjustment, 0))
 from bookings bk inner join projects p on p.id = engagements.project_id
 where bk.engagement_id = engagements.id and bk.deleted_at is null
 order by bk.id desc limit 1)
```

A **correlated subquery, never a join** — one engagement can have several booking rows behind it,
and a join would duplicate rows *and* inflate the paginator count. The latest booking wins.
`p.commission_rate` is deliberately **not** coalesced, so a null rate makes the whole expression
NULL and sorts with the blanks rather than pretending to be zero.

⚠️ **That inner `projects` join carries no soft-delete guard**, so a soft-deleted project still
supplies a rate to the sort while the *displayed* value is null (the eager-loaded relation is scoped
out). The pipeline column aggregate puts the guard inside its join clause on purpose; this one does
not.

### ⚠️ Three different "estimated commission" totals exist, and they are not meant to reconcile

Do not "fix" any of them to match another. Each answers a different question:

| Figure | Includes |
|---|---|
| The pipeline board's **column total** | booking figures **and** `price_from` estimates; null rate → contributes 0 |
| The project Show card **`summary.est_commission`** (`leadSummary()`) | `price_from` estimates, **every** deal that is neither Converted nor Lost (so New / Contacting deals each contribute a full estimate), nulls collapsed to 0, and retired statuses **not** folded |
| The booking list's **`bookingHeadline.est_commission`** | bookings only, Booked bucket only — no `price_from` estimate anywhere, so an unbooked deal contributes nothing. Labelled *"Booked Commission"* on screen |

⚠️ For the same reason, **the export's Commission column does not total the page's Est. Commission
card.** That difference is intended.

### How the adjustment is written and cleared

**One writer, two endpoints.** [`ApplyClosingChoice`](/app/Actions/ApplyClosingChoice.php) applies
the whole "how did this deal close, and what does it pay" decision — the mode, the receipt it
implies, and the adjustment — because the admin makes them in one place. Both
`BookingsController::update()` and `EngagementsController::assign()` hand it the same payload; there
is no second code path to drift.

- **The reason is required whenever the adjustment moves the figure** — `Rule::requiredIf` in the
  shared [`ValidatesCommissionAdjustment`](/app/Http/Requests/Manage/Engagements/Concerns/ValidatesCommissionAdjustment.php)
  concern, used by both requests. A tolerance (`abs >= 0.01`) rather than `!= 0`, because the value
  arrives as a string off a number input and `"0.00"` must not demand an explanation.
- **Zero is stored as NULL**, so "the formula stands" and "somebody deliberately adjusted by nothing"
  are not the same row.
- **Clearing the adjustment clears its reason** — a "why" left on a figure that has gone back to the
  formula describes something that is no longer there.
- ⚠️ **A null `$commission` argument means "the editor never rendered"** — not "cleared" — exactly
  like an absent `roles` payload. The modals strip the fields together via `form.transform()`.
- ⚠️ **The receipt and the adjustment have SEPARATE presence signals.** `apply()` settles the receipt
  unconditionally, so it takes a `$settleReceipt` flag: without it, entering the action to write an
  adjustment alone would drop a receipt link nobody touched.

⚠️ Unchanged and still true: **any partial `PUT manage.bookings.update` blanks the fields it omits**,
because the controller maps every booking field explicitly and `data_only()` keeps present-but-null
keys. The modal is the only intended client and always sends the full set.

---

## Part B — the SPLIT

### Which mode governs

[`Engagement::resolvedClosingModeId()`](/src/Engagement/Engagement.php) is **the single statement of
that inference**, and every reader mirrors it:

```php
return $this->closing_mode_id
    ?? ($this->purchase_history_id !== null ? ClosingMode::paymentDefault()?->id
                                            : ClosingMode::manualDefault()?->id);
```

The DEAL's explicit stamp wins; else a payment-opened deal takes the payment-default mode; else the
manual-default mode. Both defaults are admin-set — see
[closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md).

⚠️ **Read off `engagements.closing_mode_id` since 2026-08-12**, not the booking's. The mode is how
the DEAL closed, and keeping it on the booking made it un-choosable until a unit existed; a deal can
also hold several bookings. `bookings.closing_mode_id` is now a mirror nothing reads.

⚠️ **A NULL stamp still falls through to the defaults** — it means "derive it", not "no mode". So
changing a default on Setting → Closing Modes moves every un-stamped deal with it, live.

The JS twin is `resolveMode()` in [`closingModes.js`](/resources/js/utils/closingModes.js), which
keys off the serialized `payment` badge where PHP keys off `purchase_history_id`.

### The derivation

[`Engagement::commissionBreakdown()`](/src/Engagement/Engagement.php) — one row per (role, holder):

```php
$set = ClosingMode::byId($closingModeId ?? $this->resolvedClosingModeId())?->poolMap() ?? [];

foreach ($set as $role => $pool) {                        // in closing_mode_roles.position order
    $holders = $this->assignments->where('role', $role)->values();
    if ($holders->isEmpty()) { continue; }                // ← an UNHELD pool produces no row at all

    $explicit      = $holders->filter(fn ($h) => $h->role_share !== null);
    $implicitCount = $holders->count() - $explicit->count();
    $remainder     = max(0.0, 100.0 - (float) $explicit->sum('role_share'));
    $equal         = $implicitCount > 0 ? $remainder / $implicitCount : 0.0;

    foreach ($holders as $holder) {
        $within = $holder->role_share !== null ? (float) $holder->role_share : $equal;
        $rows[] = ['assignment' => $holder, 'role' => $role,
                   'percentage' => round((float) $pool * $within / 100.0, 2)];
    }
}
```

Read it as four rules:

- **Only roles in the mode's pool set produce rows.** A holder on a role outside the pools earns
  **nothing** — the editor flags them inline as *"Not in {mode} — earns 0%"*.
- **An unheld pool is skipped entirely**, so the breakdown can total less than 100. That gap is real
  money, and the editor's footer surfaces it as *Unclaimed*.
- **Blanks absorb the remainder equally.** An untouched team therefore splits each pool evenly and
  the roles always total their pools.
- **Rounding happens per (role, holder) row**, before any summing.

`commissionShares()` then rolls it up per `users.id`, **accumulating and rounding at every step**
(not summing then rounding once). `SalesProjectsController::commissionPayeeCards()` reproduces that
arithmetic line for line, so the table cell can never disagree with the per-admin figures Team
Performance reports.

⚠️ **`?? []` means an unresolvable mode yields an EMPTY breakdown** — no split at all, not an even
one.

⚠️ **`role_share = 0` is EXPLICIT, not blank** — the test is `!== null`. A holder keyed at 0 takes
nothing *and* does not absorb any remainder. The form's `min:0.01` rule is the only thing keeping
one out.

⚠️ **A sole holder must have a BLANK `role_share`.** The server reads an explicit share literally, so
a `60` left behind by a departed co-holder silently drops 40% of that pool on the floor. The client
guards this in two places (`hydrateHolders()` normalizes on load, `normalizeSole()` on removal) — and
**nowhere on the server**, so an API caller can still create the state.

⚠️ **Rounding can overshoot a pool by a cent.** Three holders splitting a 50% pool equally give
`round(50 × 33.3333/100, 2) = 16.67` each — 50.01 in total. That is by design: each person's own
figure stays stable, and the pool total is not re-normalised.

### The cell is ONE LINE PER PERSON, not per role

A teammate holding Lead Gen + Analyst + Appointment on one deal is still **one payee**, so
`CommissionCell` reads *"Wai Kit · 70% RM 34,541"* rather than three lines the reader has to add up.
The roles behind the figure are the line's tooltip. The per-role detail belongs to the Assign modal,
which is where it can be changed.

⚠️ `CommissionCell` prices the per-person lines even on an **estimated** row, whereas
`CommissionSplitEditor` deliberately refuses to show RM for an estimate — an estimate is not money to
divide, so there the percentages stand alone.

### An adjusted figure shows what it was adjusted FROM (2026-09-07)

The headline stays the resolved total (it is what the split divides), but under it an adjusted row
prints the derivation and the signed difference: *"RM 41,731.20 **− RM 13,910.40**"* — the minus in
**red**, a top-up's plus in **green** — with `commission_adjustment_reason` as the line's tooltip.
Without it, a row reading RM 27,820 on a 6 % project told the reader nothing about the RM 13,910 that
went missing; the whole point of the adjustment model is that the derivation stays on screen.

The cell reads `booking.commission_auto` / `commission_adjustment` / `commission_adjustment_reason`
off the same `bookingPayload()` the editor uses — no extra keys. It prints the line **only when
`auto + adjustment` equals the row's total** (to the cent): an estimate has no adjustment to apply,
and a legacy absolute `bookings.commission` ignores the column entirely, so on either a line would
explain a figure it never produced. `CommissionCell.test.js` covers the four cases.

### The two modals are ONE screen

[`CommissionSplitEditor.vue`](/resources/js/Components/Sales/CommissionSplitEditor.vue) owns **the
whole money decision** — what the deal pays, how it was closed, which payment paid for it, and who
splits it — and is mounted **unchanged by both** the Assign modal and the Booking modal's
*Commission & Split* tab. That is deliberate: the two used to show different subsets of the same
decision, so an admin had to know which modal to open to change what.

⚠️ **The editor owns its own headings.** A wrapper heading in either parent would be the one thing
making the two screens differ, which is why the Booking modal's *"Commission split — who worked this
deal"* panel heading was removed rather than copied into the Assign modal.

It **mutates the parent's `useForm` roles object in place**, so each parent submits it with its own
save, and it takes **`autoCommission` as a prop** rather than computing it: the Booking modal
recomputes the derivation live from the prices being typed, while the Assign modal reads the saved
one off `booking.commission_auto`. The editor adds the adjustment itself and prices every share off
the total, **so the split re-prices as the adjustment is typed.**

The adjustment control is a **−/+ segmented button plus a positive magnitude**, not a signed number
field: typing `-500` into a money input is a way to mean "minus" that people get wrong, and clearing
the field would otherwise forget which direction was chosen.

**It is COLLAPSED by default and opens by itself when the deal has one.** Most deals pay exactly the
calculated figure, and a permanently-open pair of inputs invites a number nobody needed to enter; a
deal that *does* carry an adjustment opens showing it, because that is the one thing about the figure
worth reading. **Remove** clears the amount and the reason together — leaving a "why" behind on a
figure that has gone back to the formula would describe something that is not there.

**Each role's person picker is the shared [`ComboBox`](/resources/js/Components/ComboBox.vue)**, so an
admin types a name instead of scanning a dropdown the length of the sales floor. ⚠️ It runs in
**local mode** — a new `options` prop added for this, which filters an array the page already ships
rather than round-tripping to re-learn what is already in the payload; `searchUrl` is now optional
and the remote consumers are untouched. The box is remounted by `:key` after every pick, because it
is an **add** control, not a bound value.

⚠️ **A role card must never be `overflow-hidden`** — that clipped the picker's dropdown to the card's
own edge. The card keeps its rounded corners by having the tinted rail round its **own** outer
corners instead of relying on the parent to crop it. `ComboBox` also **flips the panel above the
input** when there is less than ~280px below it, so the editor's last role card does not open its
list into the bottom of a scrolling modal.

⚠️ **On a deal with no booking the ADJUSTMENT is read-only** — it is a difference from a figure
derived off that unit's price and lives on `bookings.commission_adjustment`, so there is nowhere to
store one. It says so (*"Adjustable once a unit is booked"*) rather than offering a control that
would not stick. **The mode PICKER is live at every stage** (2026-08-12) — it moved to
`engagements.closing_mode_id` precisely because this editor is where people are assigned into the
pools that mode defines.

⚠️ The JS mirror requires **`rate > 0` and `base > 0`**, where PHP accepts `0` — a project with
`commission_rate = 0` renders *"Needs a commission rate on the project"* client-side while the server
would compute `0.00`.

### The basis: a label from the server, a code for the maths

**Never compare `commission_basis` against a literal to print a word** (GUIDELINES §5, and Primary
Directive 3 on magic numbers). Every payload carrying `commission_basis` ships
**`commission_basis_label`** beside it, resolved by
[`Project::commissionBasisLabel()`](/src/Property/Project.php) — the same rule a pipeline role's
`short_label` follows: the server decides the word, the frontend prints it.

The numeric code is still needed as **logic**, because the live commission preview has to pick which
price to multiply. That, and the option list on the Project form (which is *creating* the value, so
there is no payload to read), come from
[`utils/commissionBasis.js`](/resources/js/utils/commissionBasis.js) — one mirror of the PHP
constants, in the `utils/engagementStatus.js` idiom.

⚠️ It is deliberately **not** in `utils/closingModes.js`, whose own header rule is that only
derivations that must be LIVE belong there. A lookup table does not qualify — the same reason a
role's short label was moved out of it and onto the model.

Each role is a card whose right rail states **that role's pool as a % of the whole commission** (plus
the RM), and every holder row states **the cut that person actually takes** (`pool % × their share`).
Around them:

- **The Pool-share input only appears when a role has 2+ holders** — a sole holder takes the pool, so
  there is nothing to divide.
- **A pool nobody holds** gets its own *"Unclaimed — nobody holds it"* card, and a role held but
  outside the resolved mode's pools is flagged as earning 0% — both where they can be fixed rather
  than as a footnote.
- A footer tallies **Assigned vs Unclaimed** against 100%, so a gap is visible instead of implied.
- **RM amounts render only when the commission is real and non-null.**

### One save writes both

The Booking modal's form carries a `roles` payload beside the booking fields, and
`BookingsController::update()` hands it to `EngagementRepository::assign()` inside the same
transaction as the booking write — so picking a closing mode, moving a person and correcting a price
is a single Save, not three. The rules live in
[`ValidatesRoleAssignment`](/app/Http/Requests/Manage/Engagements/Concerns/ValidatesRoleAssignment.php)
and the uuid→id mapping in
[`AssignsPipelineRoles`](/app/Http/Controllers/Concerns/AssignsPipelineRoles.php), both shared with
the assign endpoint, so the two entry points accept and refuse exactly the same payloads.

Two rules are load-bearing:

- **An ABSENT `roles` key leaves every assignment untouched.** A booking being created, or a mount
  without the closing-mode config, never renders the split — the modal strips `roles` via
  `form.transform()` rather than posting an empty team, which would read as "clear everyone".
- **Validation runs before the controller**, so a broken pool refuses the whole save; the booking is
  not half-written with the team rejected. Pinned by
  `CommissionShareTest::test_assign_endpoint_rejects_shares_that_break_the_pool`, which asserts
  **zero** assignments were written.

The share rules themselves: explicit shares may not exceed 100% of their pool, and **if every holder
is explicit the total must be exactly 100** (±0.01) — with no share-less holder left to absorb a
remainder, anything else silently over- or under-pays the role. A partial set may total less than
100; the blanks absorb it.

### Team Performance

`teamPerformance()` aggregates per admin over the surface's engagements:

- **Enrolment ≠ payment.** `deals` counts every assigned admin, Lost deals included; money only
  flows to admins who appear in `commissionShares()`, i.e. who hold a role **inside** the resolved
  mode's pools.
- A Converted deal's shares land in **earned**, anything else live lands in **est**, Lost pays
  nothing.
- **No `price_from` estimate here** — an unbooked deal pays nobody.
- Rounded at every accumulation step, matching `commissionShares()`.
- Retired statuses are folded through `COLUMN_STATUS` first.

Rendered by [`TeamPerformanceRail.vue`](/resources/js/Components/Sales/TeamPerformanceRail.vue),
which is **also the picker for the list's teammate filter** — a card narrows the rows to that
person's deals, and clicking the active one clears. That is why `teamPerformance()` ships each row's
**`uuid`** (never the sequential id). ⚠️ The rail is deliberately computed on an **assignee-blind**
set, so choosing somebody never removes the other cards; see
[sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md).

Its empty state *explains how to populate it* ("Nobody is assigned on these deals yet. Click a row's
**Team** avatars…") rather than hiding — an empty rail with no explanation reads as a broken feature.

---

## A worked example, end to end

**Setup.** Project *Share Tower*: `price_from = 800,000`, `commission_rate = 3.00`,
`commission_basis = 2` (Net). Engagement at `6 Booked`, no `purchase_history_id`. Latest booking:
`spa_price = 1,200,000.00`, `net_price = 1,000,000.00`, `commission = NULL`,
and the engagement's own `closing_mode_id = NULL`. Team:

| role | admin | `role_share` |
|---|---|---|
| `lead_gen` | Ann | *(blank)* |
| `appointment` | Ben | *(blank)* |
| `closer` | Ann | `60.00` |
| `closer` | Cara | *(blank)* |
| `follow_up` | Ben | `30.00` |
| `follow_up` | Cara | `70.00` |
| `analyst` | *nobody* | — |

**1 — the base.** Basis is Net → primary is `net_price` = **1,000,000.00**. *(Had `net_price` been
null it would fall back to `spa_price` = 1,200,000.)*

**2 — the amount.** The derivation is `1,000,000 × 3 / 100` = **RM 30,000.00**. With no adjustment on
file that is also the total. *(A `commission_adjustment` of `−2,500` — "Rebate agreed with the buyer"
— would make it **RM 27,500.00**, and the editor would still show the RM 30,000 it was adjusted
from.)*

**3 — real or estimated.** A base exists, so `estimated = false`. *(With no price at all it would be
the estimate `800,000 × 3 / 100` = RM 24,000.00, `estimated = true` — and an adjustment cannot apply,
because there is no booking to carry one.)*

**4 — the mode.** No stamp on the deal, no receipt → **Non Webinar Closing** (the manual default), pools
`lead_gen 50 · analyst 5 · appointment 15 · closer 25 · follow_up 5`.

**5 — the breakdown**, pool by pool:

| Pool | % | Holders | Explicit Σ | Remainder | Equal | Rows (`round(pool × within / 100, 2)`) |
|---|---|---|---|---|---|---|
| `lead_gen` | 50 | Ann *(blank)* | 0 | 100 | 100 | Ann **50.00** |
| `analyst` | 5 | — | — | — | — | *skipped* → 5% unclaimed |
| `appointment` | 15 | Ben *(blank)* | 0 | 100 | 100 | Ben **15.00** |
| `closer` | 25 | Ann `60`, Cara *(blank)* | 60 | 40 | 40 | Ann **15.00**, Cara **10.00** |
| `follow_up` | 5 | Ben `30`, Cara `70` | 100 | 0 | — | Ben **1.50**, Cara **3.50** |

**6 — the shares**, accumulated and rounded per step: Ann `50.00 + 15.00` = **65.00**,
Ben `15.00 + 1.50` = **16.50**, Cara `10.00 + 3.50` = **13.50**, unclaimed **5.00**.

**7 — the money** at RM 30,000.00:

| Payee | % | RM |
|---|---|---|
| Ann | 65.00 | **19,500.00** |
| Ben | 16.50 | **4,950.00** |
| Cara | 13.50 | **4,050.00** |
| *Unclaimed (Analyst pool)* | 5.00 | *1,500.00* |

`CommissionCell` prints three lines; the editor's footer reads *Assigned 95% · RM 28,500.00* and
*Unclaimed 5% · RM 1,500.00*. Because the deal is Booked (not Converted), Team Performance puts all
three figures in **est**.

**If the booking is later stamped Webinar Closing** (`lead_gen 50 · analyst 5 · webinar_closer 25 ·
follow_up 20`) with the same team, `appointment` and `closer` are no longer pools: Ann drops to
50.00, Ben to 6.00, Cara to 14.00, and **30% (RM 9,000) is unclaimed**.
`EngagementRepository::rollTeamForPayment()` is what normally prevents that state on a payment-closed
deal — it moves holders of pool-less roles onto the roll-target role. ⚠️ It moves them 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.

---

## Related files

**Backend**
- [src/Engagement/Booking.php](/src/Engagement/Booking.php) — `commissionAt()`, `autoCommissionAt()`, `commissionBaseAmount()`, the `commission_value` accessor
- [app/Actions/ApplyClosingChoice.php](/app/Actions/ApplyClosingChoice.php) — the ONE writer for the mode, the receipt and the adjustment
- [app/Http/Requests/Manage/Engagements/Concerns/ValidatesCommissionAdjustment.php](/app/Http/Requests/Manage/Engagements/Concerns/ValidatesCommissionAdjustment.php) — the reason-required rule, shared by both requests
- [src/Engagement/Engagement.php](/src/Engagement/Engagement.php) — `resolvedClosingModeId()`, `commissionBreakdown()`, `commissionShares()`
- [src/Engagement/ClosingMode.php](/src/Engagement/ClosingMode.php) — `poolMap()` and the per-request caches
- [src/Property/Project.php](/src/Property/Project.php) — `COMMISSION_BASIS_*` / `COMMISSION_BASES`, `canonicalPriceFrom()`
- [app/Http/Controllers/Manage/Engagements/SalesProjectsController.php](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php) — `engagementCard()`, `pipelineCard()`, `leadSummary()`, `bookingHeadlineStats()`, `teamPerformance()`, `commissionPayeeCards()`, `bookingPayload()`, `BOOKING_SORTS`
- [app/Http/Requests/Manage/Engagements/Concerns/ValidatesRoleAssignment.php](/app/Http/Requests/Manage/Engagements/Concerns/ValidatesRoleAssignment.php) · [app/Http/Controllers/Concerns/AssignsPipelineRoles.php](/app/Http/Controllers/Concerns/AssignsPipelineRoles.php)
- [app/Http/Controllers/Concerns/SharesClosingConfig.php](/app/Http/Controllers/Concerns/SharesClosingConfig.php) — ships `closingModes` + `pipelineRoles` to every consuming page

**Frontend**
- [resources/js/utils/closingModes.js](/resources/js/utils/closingModes.js) — `resolveMode`, `visibleRoles`, `resolveShares` (the ONE copy of the blank-shares maths), `poolSummary`, `hydrateHolders`, `personInitials`, `PALETTE` / `paletteOf`, the colour groups. ⚠️ `splitPreviewRows` is **exported but never imported** — see [retired.md](/docs/modules_handbook/manage/engagement/retired.md)
- [resources/js/Components/Sales/CommissionSplitEditor.vue](/resources/js/Components/Sales/CommissionSplitEditor.vue) · [CommissionCell.vue](/resources/js/Components/Sales/CommissionCell.vue) · [TeamPerformanceRail.vue](/resources/js/Components/Sales/TeamPerformanceRail.vue)
- [resources/js/Pages/Manage/Leads/Partials/Pipeline/BookingModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/BookingModal.vue) · [AssignEngagementModal.vue](/resources/js/Pages/Manage/Leads/Partials/Pipeline/AssignEngagementModal.vue)

**Migrations** — `2026_07_17_100001` (adds `bookings.commission`) · `2026_07_18_100001` (adds `projects.commission_rate`) · `2026_07_26_100001` (adds `spa_price` / `net_price` and `projects.commission_basis`) · `2026_07_30_100006` (creates `booking_commission_splits` + `bookings.closing_type`) · `2026_07_31_100001` (drops `price` / `floor_plan_id` / `commission_rate`) · `2026_08_03_100001` (adds `engagement_assignments.role_share`, **drops `booking_commission_splits`**) · `2026_08_03_200001` (renames `closing_type` → `closing_mode_id`, seeds the modes + roles)

**Tests** — [CommissionShareTest](/tests/Feature/Engagement/CommissionShareTest.php) pins the seeded configuration, the equal-split and uneven-share derivations, the assign endpoint's persistence and its pool validation, the one-save-writes-both rule, and the adjustment model: `..._an_adjustment_moves_the_commission_and_must_say_why`, `..._the_assign_endpoint_saves_the_same_commission_adjustment` (the two-modals-one-payload contract), `..._clearing_the_adjustment_clears_its_reason`. ⚠️ **Nothing pins the sort SQL's adjustment term, nor the pipeline column total, nor the Sales dashboard's** — the three raw-SQL copies of the derivation are unguarded.

## Related chapters
[closing-modes.md](/docs/modules_handbook/manage/engagement/closing-modes.md) — where the pools come from ·
[booking.md](/docs/modules_handbook/manage/engagement/booking.md) — the price fields ·
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) — the team rows ·
[sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md) — the cards and the export
