# Platform Commercial & Accounting — Existing Surface Audit

**Branch:** `platform-commercial-accounting-stabilization` (from `origin/main` @ `71ee928`)
**Date:** 2026-05-11
**Scope:** Map what already exists across CRM/leads, sales workflow, merchant access management, collections, and platform accounting BEFORE writing any code. Identify reusable pieces, real gaps, and the minimal additive schema needed.

This is the read-only Phase 1 deliverable. No code changes land in this commit.

---

## Phase 0 baseline

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

---

## Audit matrix

Connected = FE hook hits a real BE endpoint. Risk levels: **L**ow / **M**edium / **H**igh — measured by blast radius if it regressed in production.

### 1. CRM / Leads

| Surface | Backend | Frontend | Connected | Tests | Status | Gap | Risk |
|---|---|---|---|---|---|---|---|
| Tenant CSM pipeline (post-signup) | `PlatformController::crmPipeline/MoveStage/Relationship` over `Company.crm_stage` + `PlatformCsmNote` + `PlatformCsmTask` + `PlatformNpsScore` | `crm/PipelinePage`, `crm/RelationshipPage` | ✓ | Smoke (`RetailCrmWiringTest`, `PlatformAuditFixesTest`) | OK | — | L |
| Tenant CSM tasks | `PlatformController::crmTasks/AddTask/PatchTask` over `PlatformCsmTask` | `crm/TasksPage` | ✓ | Smoke | OK | — | L |
| NPS | `PlatformController::crmNps` + `PlatformNpsScore` | `crm/NpsPage` | ✓ | None | OK (read-only) | — | L |
| **Pre-tenant LEADS pipeline** | **None** | **None** | **n/a** | **None** | **MISSING** | Entire feature absent — there is no way to track a prospect contact who has NOT yet filled out the signup form. Existing "CRM" is post-tenant CSM. | **H** for sales ops |

**Note:** `Company.crm_stage` (lead → qualified → proposal → closed) is misleading; it operates on rows in `companies` (real tenants), not prospects. A lead model is genuinely missing.

### 2. Sales / Marketing workflow

| Surface | Backend | Frontend | Connected | Tests | Status | Gap | Risk |
|---|---|---|---|---|---|---|---|
| Platform staff CRUD | `PlatformStaffController` (8 endpoints) | `StaffPage` | ✓ | Strong (`PlatformStaffControllerTest`, `PlatformRolesTest`) | OK | — | L |
| Role assignment for staff | `PlatformStaffController` uses fixed allow-list `PLATFORM_ROLES` | `StaffEditorDialog` | ✓ | Strong | OK | — | L |
| Assignment fields on CSM (per-tenant) | `PlatformCsmTask.assignee` (string) | `crm/TasksPage`, `RelationshipPage` | ✓ | Smoke | OK | — | L |
| **Assignment fields on LEADS** | **None** | **None** | **n/a** | **None** | **MISSING** | No lead-level "assigned to me", "today's follow-ups", "overdue follow-ups" because leads don't exist yet. | **M** |
| Pipeline ownership | n/a | n/a | n/a | None | n/a | Same gap | M |
| Activities timeline | `PlatformController::crmActivities` (audit + notes + tasks merged) | `crm/ActivitiesPage` | ✓ | Smoke | OK | — | L |

### 3. Merchant access management

| Surface | Backend | Frontend | Connected | Tests | Status | Gap | Risk |
|---|---|---|---|---|---|---|---|
| Tenant impersonation | `PlatformController::impersonateTenant`, `endImpersonation` | TenantDetailDrawer + `ImpersonationBanner` | ✓ | None | OK | Untested but works in prod | M |
| Tenant message | `PlatformController::messageTenant` | TenantDetailDrawer | ✓ | None | OK | Untested | L |
| Tenant suspend / reactivate (the COMPANY) | `PlatformController::suspendTenant`, `reactivateTenant` | TenantDetailDrawer | ✓ | Smoke (`PlatformAuditFixesTest`) | OK | — | L |
| **Force password reset on a MERCHANT user** | **None** | **None** | **n/a** | **None** | **MISSING** | Platform staff cannot reset a tenant user's password without DB access. The `PlatformStaffController::resetPassword` is for DAL SEEN's own employees, not tenant users. | **H** for support ops |
| **Suspend a single merchant user's login** | **None** | **None** | **n/a** | **None** | **MISSING** | The whole tenant can be suspended, but a single bad-actor user inside a tenant cannot be locked without touching the tenant DB. | **H** |
| **Reactivate a single merchant user** | **None** | **None** | **n/a** | **None** | **MISSING** | Mirror of above. | **M** |
| **Resend invitation / welcome email** | **None** | **None** | **n/a** | **None** | **MISSING** | After a sign-up flow goes stale or the merchant loses the email, support cannot resend. | **M** |
| **Update merchant owner_email with uniqueness check** | **None** | **None** | **n/a** | **None** | **MISSING** | `Company.owner_email` is the lookup key for impersonation + signup verification. There's no safe endpoint to change it. | **H** |

### 4. Collections

| Surface | Backend | Frontend | Connected | Tests | Status | Gap | Risk |
|---|---|---|---|---|---|---|---|
| Collection status enum | `TenantInvoice.collection_status` (not_started / contacted / promised / escalated / paid / written_off) | `BillingDetailDrawer::CollectionsTab` Select | ✓ | Smoke (`PlatformCollectionsApiTest`) | OK | — | L |
| Collection notes | `CollectionNote` model + `PlatformController::listCollectionNotes/addCollectionNote` | `CollectionsTab::AddNoteForm` | ✓ | Smoke | OK | — | L |
| Follow-up date | `TenantInvoice.follow_up_date` | `CollectionsTab` date input | ✓ | Smoke | OK | — | L |
| Promise-to-pay date | `TenantInvoice.promise_to_pay_date` | `CollectionsTab` date input | ✓ | Smoke | OK | — | L |
| Collector assignment | `TenantInvoice.assigned_finance_user_id` + `PlatformController::assignCollection` (uses `useStaffList`) | `CollectionsTab` Select | ✓ | Smoke | OK | — | L |
| Aging buckets | `CollectionsService::agingSummary` → `current / 1-30 / 31-60 / 61-90 / 90+` | `BillingPage` aging chart | ✓ | Smoke (`PlatformCollectionsApiTest`) | OK | — | L |
| Write-off | `collection_status = 'written_off'`, gated by `platform.collections.write_off` permission | `CollectionsTab` Select option (hidden if no perm) | ✓ | Smoke | OK | — | L |
| Reminder send | `PlatformController::remindInvoice` calls `BillingService::recordReminder` | `BillingDetailDrawer` "Send reminder" button | ✓ | Strong (`PlatformBillingApiTest`) | OK | — | L |
| **Reminder COUNT** + last-reminded timestamp | **None** | **None** | **n/a** | **None** | **MISSING** | Operator cannot tell at a glance how many reminders have already been sent. The reminder fires fine but leaves no per-invoice trail. | **M** |
| **Standalone Collections page** | Endpoint exists (`/platform/collections/queue`) | `BillingPage` tab — no dedicated route | partial | — | partial | Operators land in /billing and have to switch to the queue tab. A `/platform/collections` route would surface the queue directly. | L |
| Auto-overdue sweeper | `OverdueSweeperService` + `SweepOverdueInvoices` artisan command | n/a | n/a | Smoke | OK | — | L |

### 5. Platform accounting & billing

| Surface | Backend | Frontend | Connected | Tests | Status | Gap | Risk |
|---|---|---|---|---|---|---|---|
| Invoice CRUD + lifecycle | `BillingService` + `PlatformController` (create / issue / cancel / refund / remind / record-payment / credit / auto-hold / pdf) | `BillingPage` + `BillingDetailDrawer` + `InvoiceCreateDialog` + `RecordPaymentDialog` | ✓ | **Strong** (`PlatformBillingApiTest`, `BillingServiceTest`) | OK | — | L |
| Tenant invoices per tenant | `PlatformController::tenantInvoices` | `BillingPage`, `TenantDetailDrawer` | ✓ | Strong | OK | — | L |
| Payment recording | `PlatformController::recordPayment` + `TenantPayment` | `RecordPaymentDialog` | ✓ | Strong | OK | — | L |
| Outstanding / aging | `PlatformController::billingOutstanding` mixes invoice list + aging buckets | `BillingPage` chart | ✓ | Strong | OK | — | L |
| DAL SEEN journal posting | `PlatformAccountingPoster` (postInvoiceIssue / postPayment / postRefund / postWriteOff) | n/a (server-side only) | ✓ | **Strong** (`PlatformAccountingPosterTest`) | OK | — | L |
| TB / P&L / BS / GL | `PlatformAccountingController` | `DalseenBooksPage` | ✓ | Strong (`PlatformAccountingApiTest`) | OK | — | L |
| Journals list | `PlatformAccountingController::journals` | `DalseenBooksPage` | ✓ | Strong | OK | — | L |
| Accounting settings | `PlatformAccountingController` GET/PATCH | `DalseenBooksPage` settings tab | ✓ | Strong | OK | — | L |
| **Dedicated AR-aging report endpoint** | Aging is INSIDE `/billing/outstanding`, not a standalone accounting view | `BillingPage` only | partial | — | partial | An accounting report endpoint should live under `/accounting`, not be coupled to billing — readers who want AR aging today have to fetch the entire invoice queue. | **L** |
| **Revenue summary report** | Inferable from P&L but no dedicated endpoint | None | n/a | None | partial | Sales/finance often wants a single "what did we book this month" report without computing it from P&L lines. | **L** |
| **Per-tenant AR balance** | Computable from `/tenants/{id}/billing` (invoice list) — no single-row summary | TenantDetailDrawer reads list | partial | None | partial | Today the drawer must walk the invoice list to compute outstanding. A summary endpoint would also make the existing UI cheaper. | **L** |

### 6. Shared safety rails (still binding)

| Surface | Status |
|---|---|
| `PermissionBoundary` (security lockdown) | **immutable** — every new role/permission mutation MUST route through it. |
| `EnsurePlatformStaff` middleware | **immutable** — every new platform route MUST sit behind it. |
| Cross-tenant 404 (TenantScope) | **immutable** — any new endpoint that crosses tenant boundaries must use the platform group, never the tenant group. |
| Audit log writes | every commercial mutation MUST `Audit::record` or call `$this->audit(...)`. |

---

## Decision points

| # | Decision | Rationale |
|---|---|---|
| 1 | Add a NEW `platform_leads` table — do NOT extend `Company.crm_stage`. | `Company.crm_stage` operates on real tenants. A lead may never become a tenant. Mixing the two would force every prospect into the `companies` table — a one-way data-model error. |
| 2 | Lead notes + status changes live in a single `platform_lead_activities` timeline table (not in `PlatformCsmNote/Task`). | The CSM models hard-bind `company_id`. Forcing them polymorphic is a larger refactor with risk of breaking existing CSM flows. A separate table is additive and zero-risk. |
| 3 | The pre-existing `TenantSignup` is the DOWNSTREAM record. A lead converts INTO a signup via a new `platform_leads.signup_id` FK. | Keeps the funnel explicit: Lead → Signup → Tenant. Conversion is a one-way pointer; the lead row stays for sales attribution. |
| 4 | Merchant user management lives at `/api/v1/platform/tenants/{tenant}/users/...` — NEW controller `PlatformMerchantUserController`. | Sits under the existing platform route group (already EnsurePlatformStaff + per-route permission gates). Mirrors the platform-staff route shape but operates on tenant users instead of platform users. |
| 5 | Add a NEW permission `platform.tenants.manage_users` rather than reusing `platform.tenants.impersonate`. | Reset-password / suspend / resend-invite are different operations from impersonation — different audit semantics, different blast radius. Separate permission lets finer-grained roles in future. |
| 6 | Collections "reminder count" gets two columns on `tenant_invoices`: `reminders_sent_count` (uint), `last_reminded_at` (datetime). | The `remindInvoice` endpoint already exists; today it just records audit and (per BillingService) maybe fires an email — the count is information the operator needs to throttle outreach. Two columns is the cheapest schema delta. |
| 7 | Accounting reports get THREE new endpoints under `/accounting/*` (not `/billing/*`): `ar-aging`, `revenue-summary`, `tenant-balance/{id}`. | Read-only summaries belong with reports. Existing `/billing/outstanding` keeps its current shape so the FE chart doesn't break. |
| 8 | No new FE routes for Sales Workboard — it's a query+filter mode on the new `/platform/leads` page. | "Workboard" = leads filtered by `assigned_to=me` + sorted by `next_follow_up_at`. Same data, same hooks, different default view. Adding a separate route would duplicate the page. |

---

## Phase 2–9 — proposed scope (each lands as a focused commit)

| Phase | Surface | Backend changes | Frontend changes | Test surface |
|---|---|---|---|---|
| **2** | CRM Leads | New table `platform_leads` (~14 cols) + `platform_lead_activities` (timeline). 1 controller `PlatformLeadController`. 7–8 endpoints: list / show / create / update / change-stage / assign / add-note / convert-to-signup. 1 new permission `platform.leads.manage`. | `LeadsPage.jsx` + `LeadDetailDrawer.jsx` + `LeadCreateDialog.jsx` + hooks + nav entry. | ~10 tests covering boundary + CRUD + stage transition + convert. |
| **3** | Sales workboard | None (filter mode on Phase 2 list endpoint). | "My queue" / "Today" / "Overdue" / "Unassigned" filter buttons on LeadsPage. | Covered by Phase 2 tests. |
| **4** | Merchant access | New controller `PlatformMerchantUserController`. 5 endpoints under `/platform/tenants/{tenant}/users/...` + 1 endpoint for `PATCH /platform/tenants/{tenant}/owner-email`. New permission `platform.tenants.manage_users`. | New section in `TenantDetailDrawer`: Users tab with the action buttons + a confirm modal. | ~10 tests: tenant boundary × 5 endpoints, plus happy paths + email uniqueness + token revocation. |
| **5** | Collections | 2 new columns on `tenant_invoices`: `reminders_sent_count`, `last_reminded_at`. Patch `BillingService::recordReminder` to bump them. Optionally a new `/platform/collections` route binding to the existing `collectionsQueue`. | New page `/platform/collections` reusing `BillingPage`'s queue list + filter pills. `BillingDetailDrawer::CollectionsTab` shows "Reminders sent: N (last: …)". | Reminder count test + sweep regression. |
| **6** | Platform accounting | 3 new read endpoints: `/platform/accounting/ar-aging`, `/platform/accounting/revenue-summary`, `/platform/accounting/tenant-balance/{tenant}`. All in `PlatformAccountingController`. | `DalseenBooksPage` gains an "AR & Revenue" tab. | 6 tests: 3 endpoints × {happy path + tenant cannot access}. |
| **7** | UX | n/a | Sweep every page touched in 2/4/5/6: loading + empty + error states; confirm on destructive actions; refetch-on-success; permission-gated buttons hidden. | Covered by the page smoke tests and existing vitest contract checks. |
| **8** | Security | n/a | n/a | Boundary tests for every new endpoint: tenant cannot call → 403; non-superadmin platform staff respect per-permission gate. Also run `security:audit-tenant-permissions` and the full lockdown suite. |
| **9** | Doc | `docs/platform/PLATFORM_COMMERCIAL_ACCOUNTING_STABILIZATION.md` | n/a | n/a |

---

## Stop conditions encountered: NONE

- The existing CRM model does NOT conflict with the proposed lead model — they cover different funnel stages (CSM-post-tenant vs lead-pre-signup) and use separate tables.
- No platform accounting decision required — proposed work is read-only reports + 2 columns + 1 reminder tracker.
- No payment/revenue recognition policy change — `PlatformAccountingPoster`'s journal contract is untouched.
- No tenant accounting touched.
- PermissionBoundary is strengthened (more permission keys, same gate logic), not weakened.
- No destructive migration — every schema change is additive (`ADD COLUMN` with default, new tables).

---

## What this branch will NOT do

- Touch any tenant module (Retail / Dine / Pay / Accounting / HR / Ecom / Common).
- Modify `PermissionBoundary` semantics.
- Refactor the existing CSM (Company.crm_stage) flow — leads live alongside, not on top.
- Introduce email-sending, telephony, AI lead scoring, or marketing-campaign tools.
- Rewrite `PlatformController.php` — new features get their own controllers.
- Replace `useMut` factory across the codebase.

---

## What lands next

Phase 2 (leads pipeline). Each subsequent phase is a separate commit on this branch.
