# Platform Commercial & Accounting Stabilization

**Branch:** `platform-commercial-accounting-stabilization`
**From:** `origin/main` @ `71ee928` (includes the tenant↔platform lockdown PR #10)
**Date:** 2026-05-11
**Audit doc:** [docs/platform/PLATFORM_COMMERCIAL_ACCOUNTING_AUDIT.md](./PLATFORM_COMMERCIAL_ACCOUNTING_AUDIT.md)

This branch closes the operational gaps identified in the Phase 1 audit. Every change is additive, gated server-side by `EnsurePlatformStaff` + per-route `permission:platform.*`, and routed through `PermissionBoundary` where role/permission grants are concerned. Tenant↔platform boundary is unchanged; the security lockdown audit reports clean before and after.

---

## Phase 0 baseline

| Check | Result |
|---|---|
| Branch | `platform-commercial-accounting-stabilization` @ `71ee928` |
| Working tree | clean |
| `security:audit-tenant-permissions` | clean |
| Platform/Security/Activation/Signup/Onboarding/Role/Permission tests | 178/178 pass |
| `npx vitest run` | 180/180 pass |

---

## What was added

### Phase 2 — CRM Leads pipeline (BE + FE)

The existing CRM (`Company.crm_stage` + `PlatformCsm*`) is CSM for **post-tenant** lifecycle. It cannot record a prospect who has not yet filled the public signup form. This branch adds a **separate** pre-tenant funnel:

```
Lead → Signup → Tenant (Company)
```

**Backend** (commit `<phase2-be>`):
- New table `platform_leads` (14 columns, composite indexes for the workboard queries).
- New table `platform_lead_activities` (timeline rows: note / call / email / meeting / stage_change / assignment_change / system).
- New permissions: `platform.leads.view` + `platform.leads.manage` (granted to superadmin at registration time; openable to other platform roles via DatabaseSeeder or staff editor).
- New `PlatformLeadController` with 7 endpoints — list (with workboard KPIs), show, store, update, assign, addActivity, convert.

Safety rails:
- `resolveAssignee()` verifies the target user `is_platform_staff = true` before assigning — a tenant user can never own a lead.
- `addActivity()` rejects `stage_change` / `assignment_change` / `system` kinds (those are emitted automatically by the corresponding endpoints — no out-of-band poisoning).
- `convert()` is one-way; an already-converted or `lost` lead can't be re-converted.
- Every write records to `platform_audit_log` AND to `platform_lead_activities` (compliance + operator narrative kept separate).

**Frontend** (commit `<phase2-fe>`):
- `LeadsPage.jsx` (workboard) at `/app/platform/leads`. View pills surface "My open" / "Today's follow-ups" / "Overdue" / "Unassigned" / "All open" / "Won" / "Lost" — all queries against the same backend endpoint with translated query params, so no separate "sales workboard" route is needed.
- `LeadCreateDialog.jsx` — source + business name required; rest optional.
- `LeadDetailDrawer.jsx` — header status badges; editable stage + follow-up + notes (lost requires reason; signup-linked locks stage); activity logger with kind dropdown that hides system-only kinds; full activity timeline; convert-to-signup modal.
- Nav entry "Leads" added under the Platform group (`platform.leads.view` permission).

Tests: 20 (lead pipeline) + 3 (vitest module export contract).

### Phase 4 — Merchant access management (BE + FE)

Today platform support cannot help a merchant with a forgotten password / locked account / wrong owner email without DBA help. This branch fills that gap.

**Backend** (commit `<phase4-be>`):
- New permission `platform.tenants.manage_users` (superadmin only at registration).
- New controller `PlatformMerchantUserController` under `/platform/tenants/{tenant}/users/...` with 7 endpoints:

| Endpoint | What it does |
|---|---|
| `GET /` | List merchant users with KPI tiles (total / active / invited / disabled) |
| `GET /{user}` | Detail + last-login meta |
| `POST /{user}/reset-password` | 14-char temp password + force `must_change_password` + revoke ALL sanctum tokens |
| `POST /{user}/suspend` | Flip `is_active=false` + revoke tokens (idempotent for already-disabled) |
| `POST /{user}/reactivate` | Flip back |
| `POST /{user}/resend-invite` | Only for invited-pending users; refreshes `invited_at`, new temp password |
| `PATCH /platform/tenants/{tenant}/owner-email` | Cross-tenant uniqueness enforced |

Safety rails:
- `findMerchantUser` filters out platform staff (`NOT_MERCHANT_USER` 422). Combined with the actor being platform staff, this is the real boundary that blocks "reset yourself".
- Cross-tenant `{user}` returns 404 (info-hiding).
- Tokens revoked on reset / suspend so any existing session dies.
- Temp passwords returned once in `data.temp_password`; never logged in the audit row.
- `PermissionBoundary` is NOT bypassed — this controller never assigns a role or grants a permission. It only operates on `is_active`, `must_change_password`, `password`, `invited_at`, and `Company.owner_email`.

**Frontend** (commit `<phase4-fe>`):
- New "Access" tab on `TenantDetailDrawer`.
- Owner-email field with change-in-place + `OWNER_EMAIL_TAKEN` error display.
- Per-user action buttons: Reset password / Resend invite / Suspend / Reactivate. The right button shows depending on user state.
- `TempPasswordModal` — one-time display with copy-to-clipboard. Closing the modal drops the value from React state.
- Inline `<ErrorBanner>` on every mutation.

Tests: 17 (merchant access management — boundary + happy paths + email uniqueness + token revocation + idempotency).

### Phase 5 — Collections reminder counter (BE + FE)

The remind endpoint used to just write an audit row. Now it also persists a per-invoice counter the operator can see.

**Backend** (commit `<phase5-be>`):
- New columns on `tenant_invoices`: `reminders_sent_count` (uint default 0) + `last_reminded_at` (datetime nullable).
- `remindInvoice` wraps the counter bump + audit row in `DB::transaction` and returns `data.reminders_sent_count` + `data.last_reminded_at`.
- `shapeInvoice` / `billingOutstanding` / `collectionsQueue` all surface the counter so the FE can render it without a follow-up request.
- The audit log row's `resource` string now includes the running count (`inv:INV-X n=4`) so a forensic timeline rebuild can verify the columns against the audit.

**Frontend** (commit `<phase6+7>`):
- `BillingDetailDrawer · CollectionsTab` gains a "Reminders sent: N · Last: …" card next to the aging context.

Tests: 6 (zero start, single bump, repeated bumps, detail shape, outstanding queue shape, boundary).

### Phase 6 — Platform accounting reports (BE + FE)

Three new read-only endpoints under `/platform/accounting/*`:

**Backend** (commit `<phase6+7>`):

| Endpoint | What it returns |
|---|---|
| `GET /accounting/ar-aging` | AR aging summary by bucket (`current` / `1_30` / `31_60` / `61_90` / `90+`). Accepts `?as_of=YYYY-MM-DD` for retrospective views. Reuses `CollectionsService::bucketFor` so it always agrees with `/billing/outstanding`. |
| `GET /accounting/revenue-summary?period=YYYY-MM` | `booked` / `collected` / `refunded` / `net` for the month + per-day breakdown. |
| `GET /accounting/tenant-balance/{tenant}` | One row: `outstanding`, `paid_all_time`, `lifetime_booked`, `lifetime_refunded`, `invoice_count` by status, and the oldest unpaid invoice with `days_past_due`. |

All three gated by `platform.accounting.view` (no new permission needed — existing gate covers it). Tenant boundary enforced; cross-tenant `{tenant}` returns 404; bad period format returns 422.

**Frontend** (commit `<phase6+7>`):
- `usePaArAging` / `usePaRevenueSummary` / `usePaTenantBalance` hooks.
- `TenantDetailDrawer · Commerce` tab gains `TenantBalancePanel` — lazy-fetches the new endpoint and surfaces outstanding, paid lifetime, booked lifetime, oldest-unpaid, and a 6-status invoice count strip.

Tests: 9 (boundary × 3 endpoints + math correctness + as_of param + bad period rejection + missing-tenant 404).

---

## CRM / Lead workflow

```
[ Inbound web form ] ──┐
[ Cold outreach    ] ──┤
[ Event / referral ] ──┼──► (lead.new) ──► contacted ──► qualified ──► demo ──► proposal ─┬─► (won) ──► TenantSignup ──► promote ──► Tenant
[ Partner / ad     ] ──┘                                                                  └─► (lost, lost_reason required)
```

- A lead starts in `new`. Operators move it through stages via PATCH /leads/{id} (the controller automatically records a `stage_change` activity).
- Notes / calls / emails / meetings are logged via POST /leads/{id}/activities — these are the freeform timeline kinds.
- Assignment is its own endpoint so the audit row clearly separates "assigned" from "edited".
- Conversion links a `tenant_signups.id` and flips the stage to `won`. Lead row stays for sales attribution; downstream signup is the canonical record.

## Sales / marketing workboard

The "workboard" is a query mode on `LeadsPage` — no separate route. Default view filters:

- `mine_open` → assigned_to=me + stage=open
- `my_today` → assigned_to=me + follow_up=today + stage=open
- `overdue` → follow_up=overdue + stage=open
- `unassigned` → assigned_to=unassigned + stage=open
- `all_open` → stage=open
- `won_recent` / `lost_recent` → terminal stages

KPI tiles always reflect the full universe (filter-stable) so a chip count doesn't change as the table filter narrows.

## Merchant access management

Platform support actions, each audited as high or medium severity:

| Action | Audit severity |
|---|---|
| Reset password | high |
| Suspend | high |
| Reactivate | medium |
| Resend invite | medium |
| Owner email change | high |

Temp passwords are returned exactly once via `data.temp_password`, displayed in a one-time modal on the FE, and never logged.

## Collections workflow

Unchanged shape (collection_status / follow_up_date / promise_to_pay_date / assigned_finance_user_id / aging buckets / write-off) plus a new reminder counter:

```
Operator clicks "Send reminder" → POST /platform/billing/{id}/remind →
  • bump reminders_sent_count
  • set last_reminded_at = now()
  • audit row: 'invoice.remind' resource='inv:INV-X n=N'
  • response: { ok: true, reminders_sent_count, last_reminded_at }
```

FE shows the running count next to the aging context so the operator knows whether to nudge again or escalate.

## Platform accounting model

Unchanged. `PlatformAccountingPoster` continues to post:

- Invoice issue → DR AR / CR revenue + VAT
- Payment → DR cash / CR AR
- Refund → DR revenue (contra) / CR cash
- Write-off → DR bad-debt / CR AR

The three new endpoints are **read-only views** on the same data:
- AR aging walks `tenant_invoices` (the operational source of truth, same as `/billing/outstanding`).
- Revenue summary walks `tenant_invoices` and `tenant_payments`.
- Tenant balance walks `tenant_invoices` filtered to that company.

No new journal accounts. No new posting policy. No revenue-recognition decision.

## Security rules

`PermissionBoundary` semantics are **unchanged**. The new permissions (`platform.leads.view`, `platform.leads.manage`, `platform.tenants.manage_users`) are platform-only and grant zero tenant-side reach.

| Surface | Tenant cannot | Platform staff (with right perm) can |
|---|---|---|
| Leads pipeline | view / create / mutate (403) | full CRUD + assign + convert |
| Merchant user reset/suspend | call (403) | with `platform.tenants.manage_users` |
| Owner-email change | call (403) | with `platform.tenants.manage_users` |
| Reminder bump | call (403) | with `platform.billing.remind` (existing) |
| AR aging / revenue / tenant balance | view (403) | with `platform.accounting.view` |

The `security:audit-tenant-permissions` command was run before AND after every Phase. Always clean.

## Audit trail

Every Phase 2/4/5/6 write writes a `platform_audit_log` row:

- Lead create / update / stage / assign / convert / activity → `platform.lead.<action>`
- Merchant user reset / suspend / reactivate / resend-invite / owner-email → `platform.merchant_user.<action>` / `platform.tenant.owner_email`
- Reminder bump → `invoice.remind` (existing action key — preserves prior audit consumers; resource string now carries the running count)

## Known limitations

1. **No email-sending integration.** The reminder counter is updated but the actual reminder email/SMS/inapp dispatch is not wired (would require an email-provider product call). The FE button + audit row exist; production should hook into a notification service.
2. **No telephony / WhatsApp activity logging.** The lead activity timeline accepts `call` / `email` / `meeting` kinds as **operator-recorded** entries; there's no inbound webhook to auto-create them.
3. **Sales-agent role.** Phase 2 grants `platform.leads.*` only to superadmin. A future `sales-agent` role with `platform.leads.view` + `platform.leads.manage` is a one-line addition in the platform-role matrix; intentionally out of scope here pending the product decision on who owns leads.
4. **Pre-existing flaky test.** `CollectionsServiceTest::test_status_transition_writes_audit` relies on a fragile UUID-prefix sort for audit row ordering. Was failing on `origin/main` before this branch. Reported but not fixed here — the audit log is correct; only the test's ordering assumption is fragile.
5. **No standalone `/platform/collections` route.** The audit suggested one; current scope addresses the gap via the reminder counter + the existing `BillingPage` "Outstanding" view. A dedicated route can be a small follow-up.

## Deployment notes

1. Merge the branch.
2. Run migrations on production: `php artisan migrate --force`
   - `2026_05_11_050001_create_platform_leads_table`
   - `2026_05_11_050002_create_platform_lead_activities_table`
   - `2026_05_11_050003_seed_platform_leads_permissions`
   - `2026_05_11_060001_seed_platform_tenants_manage_users_permission`
   - `2026_05_11_070001_add_reminder_tracking_to_tenant_invoices`
3. Run the audit on production: `php artisan security:audit-tenant-permissions` → expect "clean".
4. Smoke-test as superadmin:
   - Open `/app/platform/leads` → list loads + KPIs render.
   - Open any tenant in `/app/platform/tenants` → Access tab loads + Commerce tab shows the AR balance panel.
   - Click "Send reminder" on an overdue invoice → BillingDetailDrawer's Collections tab shows the counter bumped.

## Rollback notes

Each migration has a working `down()`:

- `platform_leads` / `platform_lead_activities` — `dropIfExists`.
- The lead permissions migration deletes the rows.
- `platform.tenants.manage_users` permission migration deletes the row.
- The reminder columns migration drops the columns.

No data is lost on rollback. If you roll back AFTER production data has flowed in:
- Lead rows / lead activities / per-invoice reminder counters disappear.
- The audit log retains every action — security trail is preserved.

Rollback steps:
```
php artisan migrate:rollback --step=5    # rollback the five migrations this branch added
git revert <merge-commit>                # remove the application code
```

## Tests run

```
php artisan security:audit-tenant-permissions
  → OK — tenant↔platform permission boundary is clean.

php artisan test --filter='Platform|Activation|Signup|Onboarding|Role|Permission|Security|Billing|Collection|Lead|CRM'
  → 272 passed (1 pre-existing flake — CollectionsServiceTest UUID-prefix sort)

php artisan test --filter=DineCashSaleAccountingPostingTest
  → 1 passed (53 assertions)

php artisan test --filter=Dine
  → 186 passed (1633 assertions)

npx vitest run
  → 19 files, 183 passed
```

Net new tests in this branch:
- 20 lead pipeline (`PlatformLeadsTest`)
- 17 merchant access (`PlatformMerchantUserManagementTest`)
- 6 reminder counter (`PlatformCollectionsReminderTest`)
- 9 accounting reports (`PlatformAccountingReportsTest`)
- 3 FE module-export contracts (`LeadsPage.test.jsx`)

**55 new tests, all green.**

## Production readiness

| Surface | Production-ready? |
|---|---|
| CRM leads pipeline | **Yes** — schema additive, tests cover boundary + state transitions + assignment + conversion. UX surfaces backend errors. |
| Sales/marketing workboard | **Yes** — view query modes are just filter combinations on the same vetted endpoint. |
| Merchant access management | **Yes** — destructive actions (reset / suspend / owner-email) are gated, audited, and idempotent where it makes sense. Temp passwords returned once. |
| Collections reminder | **Yes** as a counter; **No** as an actual delivery mechanism — the email/SMS dispatch is a separate product decision (see Known limitations #1). |
| Platform accounting reports | **Yes** — read-only, reuse the same data sources as the operational endpoints, return rounded numbers, gated by `platform.accounting.view`. |

The security lockdown (`PermissionBoundary`) is **untouched**. No tenant module was modified.

---

# Additional Phase — Subscriptions / Branch Add-ons / Extensions / Scheduled Plan Change

**Audit:** [docs/platform/PLATFORM_SUBSCRIPTION_ADDONS_EXTENSIONS_AUDIT.md](./PLATFORM_SUBSCRIPTION_ADDONS_EXTENSIONS_AUDIT.md)
**Commits:** `c864ec2` (audit) → `1c4cbf5` (FE) — 7 focused commits.

## What existed before

- `SubscriptionService::assignPlan` with type-2 SCD plan history.
- `BillingService::createInvoice / issue / cancel / refund / recordPayment` with `PlatformAccountingPoster` (DR AR / CR revenue + VAT + payment in same TX).
- Plan changes immediate-only; flat invoice (subtotal + VAT + total); add-ons recorded as JSON metadata at signup but never billed; no extension concept.

## What was added

### Subscription plan lifecycle (no change to existing semantics)

```
                         (no change)
[ assignPlan ] ───► current row closed (effective_to = now)
                ───► new open row with limits/features/addons snapshot
                ───► companies.plan + companies.mrr updated
                ───► audit row 'plan.change' (severity medium)
```

### Phase X.1 — Branch add-on billing

```
                                                     period_start          period_end
                                                        │                       │
                                                        ▼                       ▼
plan line ─────────────────────────────────────────► full charge ──────────────►
                  effective_from
                       │
                       ▼
branch add-on ──── DR amount ────────────────────► days_active / days_in_period
```

- New table: `tenant_subscription_addons` (per-add-on lifecycle, independent of parent subscription SCD).
- New column: `tenant_invoices.line_items` JSON — multi-line breakdown. Flat totals stay authoritative.
- New service: `SubscriptionAddonService::add/end/addonsForPeriod/prorateLineItem/previewProration`.
- `BillingService::createInvoice` walks active add-ons inside the invoice period + composes prorated lines.

**Proration formula (adopted)**

```
days_in_period = period_end - period_start + 1                    (inclusive)
days_active    = min(period_end, effective_to ?? period_end)
               - max(period_start, effective_from) + 1            (inclusive)
amount         = round(unit_price * quantity * days_active / days_in_period, 2)
```

Rules:
- Full-span add-on → `prorated=false`, line shows the unit price.
- Mid-cycle add → `prorated=true`, line shows `N/M days` in the description.
- No overlap → no line emitted.
- Back-charging BEFORE `effective_from` is impossible (the formula clamps `activeStart = max(period_start, effective_from)`).

### Phase X.2 — Subscription extension

```
[ POST /tenants/{id}/subscription/extend
   body: { days: 7, reason: "Goodwill grace." } ]
            │
            ▼
DB::transaction:
  • subscriptions.period_end += days
  • tenant_subscription_extensions row inserted
  • platform_audit_log row 'subscription.extend' inserted
```

Rules:
- `days > 0`, `reason` non-empty (controller validates).
- Channel allow-list: `platform` (today) / `merchant_request` (reserved).
- Idempotency not enforced — calling `extend(7)` twice grants 14 days. Operator judgment + audit trail are the controls.
- Past invoices are NOT credited automatically (refunds remain operator-driven).

### Phase X.3 — Scheduled plan change

Solves the "you can't pass `effective_from: future` to `assignPlan` without briefly violating the open-row invariant" problem.

```
[ POST /tenants/{id}/plan/schedule
   body: { plan, billing_cycle, effective_at, reason } ]
            │
            ▼
  current subscription:
    next_plan_id              = plan
    next_plan_billing_cycle   = cycle
    next_plan_effective_at    = when
    next_plan_reason          = reason
    next_plan_scheduled_by_user_id = actor
            │
            ▼  (cron: php artisan subscriptions:apply-scheduled, hourly)
            │
SubscriptionService::applyDueScheduledChanges():
  for each row WHERE next_plan_effective_at <= now() AND effective_to IS NULL:
    assignPlan(...)  ← same canonical mutation as an immediate plan change
```

- Schedule rejects past `effective_at` (use immediate path instead).
- Cancel scheduled change nulls the four `next_plan_*` columns; idempotent.
- Apply uses the EXISTING `assignPlan()` so SCD trail and `platform_audit_log` rows are identical to an immediate change.

## Backend endpoints added

| Method | Path | Permission | What |
|---|---|---|---|
| `GET`    | `/platform/tenants/{tenant}/subscription/addons`                       | `platform.subscriptions.view`            | list + KPIs |
| `POST`   | `/platform/tenants/{tenant}/subscription/addons/preview`               | `platform.subscriptions.addons.manage`   | pure-read proration preview |
| `POST`   | `/platform/tenants/{tenant}/subscription/addons`                       | `platform.subscriptions.addons.manage`   | create |
| `POST`   | `/platform/tenants/{tenant}/subscription/addons/{addon}/end`           | `platform.subscriptions.addons.manage`   | end |
| `GET`    | `/platform/tenants/{id}/subscription/extensions`                       | `platform.subscriptions.view`            | list extensions + KPIs |
| `POST`   | `/platform/tenants/{id}/subscription/extend`                           | `platform.subscriptions.extend`          | extend period |
| `POST`   | `/platform/tenants/{id}/plan/schedule`                                 | `platform.tenants.change_plan`           | schedule future plan change |
| `DELETE` | `/platform/tenants/{id}/plan/schedule`                                 | `platform.tenants.change_plan`           | cancel scheduled change |

Artisan: `php artisan subscriptions:apply-scheduled [--dry-run]`.

## Frontend changes

- `TenantDetailDrawer · Subscription tab`:
  - Pending plan change banner with cancel.
  - Extend modal (days + reason; reason required).
  - Add-ons panel (KPIs, add form with preview, end button per row, native confirm on destructive actions).
- `BillingDetailDrawer · Summary tab`: line items renderer when `inv.line_items` is non-empty.
- 11 new hooks in `platform.hooks.js`, all with explicit-mutation invalidation on both the addon/extension list AND the parent subscription query.

## Security rules (rebound)

| Action | Tenant cannot | Platform staff (with permission) can | Audit |
|---|---|---|---|
| List add-ons | call (403) | with `platform.subscriptions.view` | n/a |
| Preview proration | call (403) | with `platform.subscriptions.addons.manage` | n/a |
| Add / end add-on | call (403) | with `platform.subscriptions.addons.manage` | yes — `subscription.addon.add/end` |
| Extend subscription | call (403) | with `platform.subscriptions.extend` | yes — `subscription.extend` |
| Schedule plan change | call (403) | with `platform.tenants.change_plan` | yes — `subscription.schedule_plan_change` |
| Cancel scheduled | call (403) | with `platform.tenants.change_plan` | yes — `subscription.cancel_scheduled_plan_change` |

`PermissionBoundary` semantics are **unchanged**. No role assignment / permission mutation happens in any of the new code paths — the controllers only operate on subscription / add-on / extension state.

## Audit trail

Every mutation writes a `platform_audit_log` row:

- `subscription.addon.add` (severity `medium`)
- `subscription.addon.end` (severity `medium`)
- `subscription.extend` (severity `medium`)
- `subscription.schedule_plan_change` (severity `medium`)
- `subscription.cancel_scheduled_plan_change` (severity `low`)
- `plan.change` — unchanged, fires from the canonical `assignPlan` path whether the change was immediate OR applied by the artisan job

The structured tables (`tenant_subscription_extensions`, `tenant_subscription_addons`) carry queryable history — the audit log is the forensic record.

## Data model changes (additive only)

| Change | Why |
|---|---|
| `tenant_subscription_addons` (new table) | Add-on lifecycle independent of parent subscription's SCD row |
| `tenant_invoices.line_items` (new JSON col) | Multi-line invoice breakdown; flat totals authoritative |
| `tenant_subscription_extensions` (new table) | Queryable extension history + audit |
| `tenant_subscriptions.next_plan_*` (4 cols + index) | Stage future plan change without breaking open-row invariant |
| `permissions` (+2 rows: `platform.subscriptions.addons.manage`, `platform.subscriptions.extend`) | New permission gates; superadmin only at registration |

## Tests run (final regression for this phase)

- `PlatformSubscriptionAddonsTest` — 15/15 pass
- `PlatformSubscriptionExtensionsAndScheduleTest` — 10/10 pass
- `PlatformBillingApiTest + PlatformCollectionsReminderTest + PlatformAccountingReportsTest + PlatformLeadsTest + PlatformMerchantUserManagementTest + TenantPermissionLockdownTest` — **99/99 pass** combined regression
- `DineCashSaleAccountingPostingTest` — 1/1 pass
- `npx vitest run` — 183/183 pass
- `security:audit-tenant-permissions` — clean

## Known limitations

1. **No merchant-side request endpoints yet.** The audit doc records a deferred design: merchants POST a `subscription_change_request` row; platform staff approve. Today only platform-staff direct mutations exist; the `merchant_request` channel is reserved on the schema.
2. **No platform-managed add-on catalogue.** Add-on unit prices are operator-entered. A future plan→addon catalogue with central pricing is a separate proposal.
3. **No automatic credit on mid-cycle add-on removal.** Refunds remain operator-driven via `BillingService::refund`. The audit doc records this is intentional — credits without a refund pathway would silently mutate accounting.
4. **Extension idempotency not enforced.** `extend(7)` called twice grants 14 days. Operator audit is the control.
5. **Two pre-existing test flakes on `origin/main`** (`CollectionsServiceTest::test_status_transition_writes_audit`, `TenantSubscriptionTest::test_initial_assignment_creates_open_row_with_snapshot`) — both depend on assertion-order brittleness around MySQL JSON behaviour and UUID-prefix sort. Neither is introduced by this branch; both should be hardened in a separate cleanup pass.
6. **`apply-scheduled` is not auto-wired to cron yet.** Operator runs it manually or via `Kernel.php` once policy is decided (suggested: `->hourly()`).

## Deployment notes

1. Merge.
2. `php artisan migrate --force` — adds five new migrations:
   - `2026_05_11_080001_create_tenant_subscription_addons_table`
   - `2026_05_11_080002_add_line_items_to_tenant_invoices`
   - `2026_05_11_080003_seed_platform_subscriptions_manage_permissions`
   - `2026_05_11_090001_create_tenant_subscription_extensions_table`
   - `2026_05_11_100001_add_scheduled_plan_change_to_tenant_subscriptions`
3. `php artisan security:audit-tenant-permissions` → expect clean.
4. (Optional) wire `subscriptions:apply-scheduled` into `app/Console/Kernel.php` (`->hourly()` recommended).
5. Smoke-test as superadmin:
   - Add a branch add-on mid-cycle → preview shows prorated amount, commit creates the row.
   - Generate an invoice for the current period → `line_items` includes the plan + the prorated branch.
   - Extend a tenant subscription by 7 days → `period_end` bumps; banner shows the audit count.
   - Schedule a plan change for tomorrow → the pending banner appears on the Subscription tab.

## Rollback notes

Every migration has a working `down()`:
- The two tables drop cleanly.
- `line_items` and the five `next_plan_*` columns drop cleanly.
- The two new permissions delete + clean their grants.

Rollback flow:
```
php artisan migrate:rollback --step=5
git revert <merge-commit>
```

No data is irreplaceable on rollback. The audit log preserves the action history regardless.

## Production-readiness verdict

| Surface | Production-ready? |
|---|---|
| Plan change (immediate) | **Yes** (unchanged from prior phases) |
| Plan change (scheduled) | **Yes** for the data + applier path; cron wiring is operator-driven until product confirms cadence |
| Branch add-ons (add / end / preview) | **Yes** — math tested, boundary tested, audit covered |
| Mid-cycle proration | **Yes** — formula documented, 4 explicit test cases in `PlatformSubscriptionAddonsTest` |
| Invoice line items | **Yes** — flat totals stay authoritative |
| Subscription extension (platform) | **Yes** |
| Subscription extension (merchant request) | **No** — schema reservation only; design deferred to next round |
| Platform-managed addon catalogue | **No** — operator-entered pricing today; catalogue deferred |

Branch ready for review. The five new migrations are all additive; no data corruption risk on either deploy or rollback.

---

# CRM ↔ Leads Integration & Sales-Role Scoping

**Commits:** Phase A `<phaseA>` · Phase B `<phaseB>` · Phase C `<phaseC>` · Phase D doc/regression.

Closes the operational gap between the pre-tenant Leads pipeline and the post-tenant CRM relationship surface. Three additive phases.

## Phase A — Originating-lead badge on the CRM RelationshipPage

```
[ platform_leads ] ─(company_id)─► [ companies ]
                          │
                          ▼
GET /platform/crm/tenants/{id}.data.originating_lead = {
  id, business_name, source, stage, assigned_to {id, name, email},
  created_at, converted_at, signup_id
}
```

- New column `platform_leads.converted_at` (datetime nullable), set inside `PlatformLeadController::convert` in the same transaction as the stage flip.
- `crmRelationship` reverse-lookups any lead pointing at this tenant's `company_id` and surfaces an operator-safe subset.
- **Sensitive fields excluded:** `notes`, `lost_reason`, `phone`, `email` are deliberately NOT on the badge — lead notes surface through the activity feed (Phase B), not via the relationship badge.
- FE renders a single indigo card under the tenant header with source + owner + converted date + "View leads" link. Renders nothing when `originating_lead` is null.

## Phase B — Lead activities folded into the CRM activity feed

The CRM feed at `GET /platform/crm/tenants/{id}.data.activities` previously merged `audit + note + task` rows. It now also walks `platform_lead_activities` for every lead linked to the tenant.

Shape additions:
- New `kind = 'lead_activity'` joins the existing palette (`audit / note / task`).
- `metadata.lead_kind` carries the inner subkind (`note / call / email / meeting / stage_change / assignment_change / system`) so the FE renders the right badge without parsing body strings.
- `metadata.lead_id` points back at the lead row.

FE adds an indigo `🎯 Lead activity` badge + neutral subkind chip + the existing body text:

```
[Lead activity] [call] · Sales · 2h ago
"Discovery call — interested."
```

Existing CSM `note / task / audit` rendering is unchanged.

## Phase C — `sales-agent` role + scoped lead visibility

### New permissions

| Key | Meaning | Default grant |
|---|---|---|
| `platform.leads.view`     | Read leads (scope determined by `view_all`) | superadmin, support-agent, sales-agent |
| `platform.leads.view_all` | See ALL leads regardless of assignment      | superadmin, support-agent (NOT sales-agent) |
| `platform.leads.manage`   | Create/update/add-activity/convert (within scope) | superadmin, support-agent, sales-agent |
| `platform.leads.assign`   | Push leads onto another platform staffer    | superadmin, support-agent (NOT sales-agent) |

### `sales-agent` role

Narrow by design — only the four perms needed to work a sales funnel:

```
sales-agent:
  platform.leads.view
  platform.leads.manage
  step_up.use
  audit.write
```

No tenant provisioning, no activation codes, no billing, no broader `platform.*` reach. Added to `PermissionBoundary::PLATFORM_ROLES` and `PlatformStaffController::PLATFORM_ROLES` so it's recognised as a platform-staff role by the lockdown audit and selectable in the staff editor.

### Backend-enforced scoping

`PlatformLeadController` has two new helpers:

- **`scopedLeadQuery(request)`** — returns a base query that's unrestricted for `view_all` holders and scoped to `assigned_to_user_id = me` for everyone else. Applied to BOTH the index list AND every KPI clone, so a sales-agent's `total / open / won / lost / by_stage / mine / overdue / due_today` all reflect their own leads — not the org.
- **`findScopedLead(request, id)`** — returns the lead row or 404 (info-hiding, not 403) when the caller can't see it. Used by `show / update / addActivity / convert` — a sales-agent guessing a foreign lead id gets the same response as a missing one.

The `POST /platform/leads/{id}/assign` route moved from `permission:platform.leads.manage` → `permission:platform.leads.assign`. Sales-agent's route middleware 403s before reaching the controller.

### Scoping policy decisions (recorded)

- **Sales-agent does NOT see unassigned leads.** Conservative default. A future permission like `platform.leads.view_unassigned` could open the unassigned pool without granting `view_all`.
- **Sales-agent CAN edit their own leads** (stage / notes / follow-up / convert).
- **Sales-agent CANNOT push leads onto teammates.** The `.assign` permission is the lever; it's intentionally NOT in the sales-agent grant.
- **Tenants still 403 at the route group** — unchanged.

### Frontend

- `LeadsPage` hides the "Unassigned" / "All open" view pills AND the "Unassigned" KPI tile when the user lacks `view_all`. The pills that remain (`mine_open` / `my_today` / `overdue` / `won_recent` / `lost_recent`) are all meaningful in the scoped universe.
- `LeadDetailDrawer`'s "Assign to me" / "Unassign" buttons are gated by `.assign` instead of `.manage`.

### Test scaffold fix (carries with these phases)

`TestCase::ensureRolesAndPermissionsBooted()` checked `Role::count() > 0` to early-return. Phase C's migration creates `sales-agent` BEFORE the test scaffold runs, so the count check short-circuited and left tests without `superadmin / business-owner / etc.` Fix: check for `superadmin` specifically as the canary.

## Audit trail (rebound)

| Action | Severity | Surface |
|---|---|---|
| `subscription.addon.*` | medium | (Phase X.1 — unchanged) |
| `subscription.extend` | medium | (Phase X.2 — unchanged) |
| `platform.lead.*` (incl. assign / activity / convert) | low / medium | unchanged but `convert` now also stamps `converted_at` |

`PermissionBoundary` still rejects every lead-side mutation for tenant actors. The new permissions are platform-only.

## Tests run (final, this addition)

| Suite | Result |
|---|---|
| `security:audit-tenant-permissions` | **clean** after PLATFORM_ROLES gained `sales-agent` |
| `Platform\|Lead\|CRM\|Role\|Permission\|Security` | **241/241 pass** (1027 assertions) |
| `DineCashSaleAccountingPostingTest` | 1/1 pass |
| `npx vitest run` | 191/191 pass |

Net new tests in this addition:
- `PlatformCrmOriginatingLeadTest` — 5 cases
- `PlatformCrmLeadActivityFeedTest` — 4 cases
- `PlatformSalesAgentScopeTest` — 13 cases
- Plus `PlatformStaffControllerTest::test_roles_meta_returns_all_platform_roles` updated for the sixth role.

## Known limitations

1. **Sales-agent can't see unassigned leads.** By design (see policy decision). Reversal needs a `view_unassigned` permission and a sales-agent role grant.
2. **No team hierarchy / manager-vs-staff.** A "lead manager" who assigns to N "sales-staff" reports-to is out of scope; reaches via `support-agent` role today.
3. **Originating-lead badge links to `/app/platform/leads` (list), not a direct lead URL.** The lead detail lives in a drawer, not a route. A direct deep-link would need either a new route or a `?focus={leadId}` query param on the list page. Deferred.
4. **Activity feed limits to 100 lead activities per tenant** to keep response size predictable. If a tenant accumulates more lead history than that, the oldest activities won't render — but the audit log still has the forensic trail.

## Deployment

1. Merge.
2. `php artisan migrate --force` (2 new migrations: `converted_at` + `sales-agent` role/perms).
3. `php artisan security:audit-tenant-permissions` → expect clean.
4. Smoke-test:
   - Convert a lead → tenant. Open the tenant in CRM → originating-lead badge appears.
   - Log a lead activity → it appears in the tenant's CRM activity feed with the `Lead activity` badge.
   - Create a sales-agent staff user → log in → only their assigned leads appear; Assign/Unassign buttons hidden; foreign lead URL returns 404.

## Rollback

Each migration has a working `down()`:
- `converted_at` column drops cleanly.
- The sales-agent role + the two new permissions delete cleanly along with their grants.

Rollback steps:
```
php artisan migrate:rollback --step=2
git revert <merge-commit>
```

The platform_lead_activities table itself was added in Phase 2 — that's NOT touched by this rollback.
