# Stage 3 — Referral & Repeat

**Portal:** Manage · **Read:** `GET /manage/sales-projects?view=referrals` and `?view=referral-chain`
(`view-projects`) · **Writes:** `manage.leads.referrals.{store,destroy,asked}` (`manage-leads`) ·
**Controller:** [`ReferralsController`](/app/Http/Controllers/Manage/Engagements/ReferralsController.php)
(writes) + `SalesProjectsController::referralView()` (reads)

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

## What it does

The first two stages list records that arrive on their own. **Stage 3 is a worklist**: its output is
new pipeline, so its **ORDER is the feature**. It answers *"who do I call today"*, not *"what
happened last"*.

There is **no `referrals` table**. Attribution is three nullable columns on `leads`, and the writes
live in the **leads** route group, because attributing a referral is a lead write.

## How it works

### Who counts as a customer

A lead with **at least one engagement at `WON_STATUSES` = `[6 Booked, 8 Converted]`**.

⚠️ **Lost is deliberately excluded** — the Sales stage lists it as an outcome, but a lost deal
produces nobody to ask. ⚠️ So is the retired `7 Following Up`, even though it is booking-bearing.

`customerQuery()` is the single definition, and `scopeToCustomers()` is split out of it so the
*filtered* row query can start from the QueryRequest's builder and still be scoped by the identical
rule — **the rows and the stage counters must never disagree** about who counts as a customer. Both
are `LeadVisibility`-scoped.

### The ranking

```
1. never asked   →  before anyone already chased
2. most recent purchase
3. largest spend
```

Each rung has a reason, and the docblock states them: the only irreversible waste on this page is a
customer **nobody ever asked**; goodwill decays, so the weeks after a handover are when a person
actually introduces their friends; and the same effort on a bigger spender reaches a larger circle.

It is stated once **above the table** rather than decorated onto every row — an order the reader
cannot explain is an arbitrary one — and each row states which rung it is ranked on.

```php
->orderByRaw('(leads.referral_asked_at is null) desc')
->orderByRaw("{$lastWonAtSql} desc")
->orderByRaw('total_value desc')
```

⚠️ **`referral_asked_at IS NULL` means "never asked"** — the top of the ranking. It must never be
read as a falsy check, and the model's own cast comment says so.

**`lastWonAtSql()`** is the newest `booking_date`, falling back to the newest won engagement's
`updated_at` for a customer booked before booking rows were captured. Both arms are **correlated
subqueries, never joins** — a lead with three bookings must not become three paginator rows.

⚠️ **Neither subquery filters `bookings.status`.** A booking cancelled when a deal went Lost is kept
rather than deleted, so it still feeds `last_won_at`, `total_value` and `last_project`.

### Two pivots

Both keep the same stage selected, on a quiet segmented control styled **below** the rail —
deliberately, because **a pivot is not a stage**.

| Pivot | `?view=` | Rows |
|---|---|---|
| **Customers** | `referrals` | the worklist: name / phone / email, `last_project`, `last_won_at` + `days_since`, `total_value`, `purchases`, `referrals_made`, `referrals_won`, `never_asked` / `asked_at`, `is_repeat` |
| **Referral Chain** | `referral-chain` | who introduced whom + the outcome: the referred lead, their `referrer_name`, `referred_at`, the lead's status badge, `purchases` and `open_pipelines`, newest first |

⚠️ **`select()` must come BEFORE `withCount()`** in the worklist query. Calling it after would
replace the column list Laravel had already appended the count subqueries to, and **every count
would silently come back 0**. The code carries that warning inline.

### The scoreboard

Six counters, all `LeadVisibility`-scoped:

| Stat | Definition |
|---|---|
| `customers` | leads with ≥1 won engagement |
| `never_asked` | of those, `referral_asked_at IS NULL` |
| `advocates` | of those, `has('referrals')` — somebody they introduced exists |
| `repeat` | of those, **two or more** won engagements |
| `referred_in` | leads with a `referred_by_lead_id` |
| `referred_won` | of those, the ones that went on to buy |

⚠️ **Counting repeat buyers uses a correlated subquery** (`wonEngagementCountSql() >= 2`), not
`whereHas(..., '>=', 2)` — the same rule GUIDELINES §14 states for relational columns, and it keeps
the count reading identically to the `purchases` figure on the row itself.

### Filtering — deliberately almost none

[`ReferralQueryRequest`](/app/Http/Requests/Manage/SalesProjects/ReferralQueryRequest.php) has
**`search` and nothing else**, and its docblock says why: the stage's ORDER is the feature, and a
filter bar invites people to slice a list whose whole job is to be worked from the top down. Search
exists only to answer *"did I already log this person's referral?"*.

⚠️ The customer scope itself is **not** in the QueryRequest — it lives in the controller's
`scopeToCustomers()`, so the rows and the counters cannot diverge. ⚠️ The phone arm of the search
only fires when the term contains digits: stripping them from `"lim"` leaves `''`, and
`phone like '%%'` matches everyone.

### Attribution lives on `leads`

`2026_07_27_100002_add_referral_columns_to_leads_table` adds three nullable columns, and they sit on
**opposite sides of the same relationship**, which is why both live on `leads`:

| Column | On which lead | Meaning |
|---|---|---|
| `referred_by_lead_id` | the lead who was **INTRODUCED** | points at the customer who introduced them. **One introducer per lead**, deliberately |
| `referred_at` | same | when the referral was logged |
| `referral_asked_at` | the lead who was **ASKED** | when this customer was **last** asked. NULL = never asked |

Both `referred_by_lead_id` and `referral_asked_at` are indexed, because every stage-3 aggregate
counts or groups on them. No schema-level foreign key (GUIDELINES §7) — the relationship is
Eloquent's, via `Lead::referredBy()` (belongsTo self) and `Lead::referrals()` (hasMany self).

**A dedicated `referrals` entity only earns its keep once referrals carry rewards or multi-touch
credit**, neither of which exists yet.

### The three writes

All three live in the **leads** route group and are gated by `manage-leads`; each re-checks
`LeadVisibility::allows()` and returns `back()`.

⚠️ **`{id}` means a DIFFERENT lead in each of the three** — the route file carries this warning, and
it is the easiest thing in this stage to get wrong:

| Route | `{id}` is | Does |
|---|---|---|
| `POST manage.leads.referrals.store` | the **REFERRER** | logs that this customer introduced somebody |
| `DELETE manage.leads.referrals.destroy` (`{id}/referral-source`) | the **REFERRED** lead | clears that lead's attribution |
| `POST manage.leads.referrals.asked` (`{id}/referral-asked`) | the customer who was asked | toggles the asked stamp |

`asked` defaults to **true**, so a bare POST marks the customer asked; send `asked=0` to put them
back on the not-yet-asked list.

### The guards, and what they do not cover

[`LeadRepository::linkReferral()`](/src/Lead/Repositories/LeadRepository.php) refuses two links
outright rather than silently ignoring them, because **both would corrupt the referral counts**:

- **self-referral** — *"A lead cannot refer themselves."*
- **a two-step cycle** (A introduced B, now B is said to have introduced A) — *"That customer was
  themselves referred by this lead."* Each would look like the other's source.

Both messages are surfaced verbatim to the admin.

⚠️ **A longer cycle is NOT detected.** A→B→C→A passes: nothing walks the chain.
⚠️ **Re-attributing an already-referred lead silently overwrites** the previous referrer and
`referred_at`; no history is kept.
⚠️ **Logging a referral also stamps the referrer's `referral_asked_at` when it is blank**, in the
same transaction — logging one *is* proof they were asked, so the worklist stops ranking them as
never-asked. `unlinkReferral()` deliberately leaves that stamp alone: they *were* asked, and undoing
the attribution does not undo that.
⚠️ **`markReferralAsked()` overwrites the timestamp** — it is "last asked", not "first asked".

### Logging a referral closes the loop

[`LogReferralModal.vue`](/resources/js/Pages/Manage/SalesProjects/Partials/LogReferralModal.vue)
defaults to **typing the person in**, with search as the fallback — the opposite of
[`AddLeadToProjectModal`](/resources/js/Components/Sales/AddLeadToProjectModal.vue), because a
referral is captured mid-call from a name and a number.

[`StoreReferralRequest`](/app/Http/Requests/Manage/SalesProjects/StoreReferralRequest.php) is
therefore **deliberately looser** than `StoreLeadRequest`'s new-lead branch, in two ways:

| | `StoreLeadRequest` (new lead) | `StoreReferralRequest` (new referral) |
|---|---|---|
| mode key | `lead_uuid` | `referred_lead_uuid` |
| `email` | **required** | **nullable** — a customer passing on a friend's contact gives a name and a number; demanding an email would make the honest answer un-enterable, and the person would be logged nowhere instead |
| `source` | required | **absent** — no `lead_funnels` attribution row is written for a referral-created lead |
| a phone that already belongs to someone | **refused** — *"Search for that lead above instead of creating a duplicate."* | **allowed** — it means the friend is already in the database, which is the normal case in an active market, so `LeadLinker` links that existing lead rather than minting a duplicate |
| extra | booking fields + the open-at-stage invariant | `project_uuid` — opens the referral straight into a project's pipeline |

⚠️ **A consequence of having no identity `withValidator()`:** a typed phone belonging to **staff** is
not rejected with a helpful message. `LeadLinker` returns a staff outcome with no lead, and the admin
sees the generic *"That phone number could not be made into a lead — please check the details."*

Picking a project opens the referral into its pipeline — **landing back in stage 1**. That open runs
through the same idempotent `EngagementRepository::open()` as every other lane, so logging the same
referral into the same project twice never resets an existing engagement's status, and the new lead
gets the standing team.

⚠️ The success flash prints the project's **raw `name`**, while the select the admin picked from
shows the **canonical** one — they differ on a catalogue-linked project.

### ⚠️ Nothing in this stage is pinned by a test

`grep -rn "referr" tests/` finds only `Referrer-Policy` headers, the word *preferred*, an unrelated
user-created `referral_partner` pipeline role, and one merge assertion. `SalesProjectsIndexTest`'s
49 test methods cover the catalogue, the booking list, imports and status moves — **none** hits
`?view=referrals`, `?view=referral-chain`, `referralStats`, `referralRows` or any
`ReferralsController` route.

So `linkReferral()`'s two guards, the ranking SQL, all six counters, the modal's two shapes, the
`LeadLinker` path and the asked toggle are **entirely uncovered**. The two that would break silently
if changed:

- **the self-referral and two-step-cycle refusals** — remove either and the advocate/referred counts
  start double-counting the same relationship;
- **`select()` before `withCount()`** — invert them and every count on every row reads 0, with
  nothing failing.

The one referral assertion in the whole suite is
[`MergeMovesEverythingTest::test_people_the_loser_introduced_still_point_at_the_survivor`](/tests/Feature/People/MergeMovesEverythingTest.php),
which pins that an account merge repoints `referred_by_lead_id` — otherwise the introducer's chain
dead-ends at a retired account.

## Related files

**Backend**
- [app/Http/Controllers/Manage/Engagements/ReferralsController.php](/app/Http/Controllers/Manage/Engagements/ReferralsController.php) — `store` / `destroy` / `asked`
- [app/Http/Controllers/Manage/Engagements/SalesProjectsController.php](/app/Http/Controllers/Manage/Engagements/SalesProjectsController.php) — `referralView()`, `customerQuery()`, `scopeToCustomers()`, `customerWorklistRows()`, `referralChainRows()`, `wonEngagementCountSql()`, `lastWonAtSql()`, `customerRow()`
- [app/Http/Requests/Manage/SalesProjects/StoreReferralRequest.php](/app/Http/Requests/Manage/SalesProjects/StoreReferralRequest.php) · [ReferralQueryRequest.php](/app/Http/Requests/Manage/SalesProjects/ReferralQueryRequest.php)
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — `linkReferral()`, `unlinkReferral()`, `markReferralAsked()`; the only callers of the three columns outside the controller
- [src/Lead/Lead.php](/src/Lead/Lead.php) — `referredBy()` / `referrals()`
- [src/Lead/Support/IdentityChildMap.php](/src/Lead/Support/IdentityChildMap.php) — how a merge/purge treats the attribution

**Frontend**
- [resources/js/Pages/Manage/SalesProjects/Partials/ReferralRepeatView.vue](/resources/js/Pages/Manage/SalesProjects/Partials/ReferralRepeatView.vue) — the scoreboard, the pivot switch and both tables
- [resources/js/Pages/Manage/SalesProjects/Partials/LogReferralModal.vue](/resources/js/Pages/Manage/SalesProjects/Partials/LogReferralModal.vue)

**Migration** — `2026_07_27_100002_add_referral_columns_to_leads_table`

## Related chapters
[sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md) — the page these two pivots live on ·
[lifecycle.md](/docs/modules_handbook/manage/engagement/lifecycle.md) — the `open()` that closes the loop back to stage 1 ·
[Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md) — the identity gate a typed referral goes through
