# Platform Backend ↔ Frontend Wiring Audit

**Branch:** `platform-backend-wiring-audit` (off `origin/main` at `d09ce3c`)
**Scope:** read-only audit of every `/api/v1/platform/*` and platform-
adjacent route against its FE caller in `app/src/modules/platform/`.
**Companion docs:** `PLATFORM_SUBSCRIPTION_REVIEW.md`,
`PLATFORM_OPERATOR_SAFETY.md`, `MODULE_ENTITLEMENT_COMPLETION.md`,
`README_SYSTEM_ARCHITECTURE.md`.

---

## 1 · Executive summary

The Platform admin surface is **broadly connected end-to-end** — every
significant entity (tenants, billing, subscriptions, leads, CRM, support,
onboarding, staff, plans, accounting, activation codes, marketplace,
terminals) has at least one FE caller, mutation hooks invalidate the
right query keys in the common cases, and destructive UX gained
typed-confirmation modals in the operator-safety phase.

But the audit surfaced one **HIGH-severity wiring break** that affects
every step-up-gated operator action shipped in
`platform-operator-safety`:

> **The backend now requires step-up on refund / credit / cancel /
> record_payment / plan-change / plan-schedule / subscription-extend /
> write-off. The frontend never requests a step-up ticket and never
> passes the `X-Step-Up-Ticket` header for these calls. In production
> with `X-Skip-Step-Up` blocked, every one of those operator buttons
> will return 419 STEP_UP_REQUIRED, and the FE will surface it as a
> generic mutation error (no PIN modal, no retry).**

This is not a "stale-cache" bug — it's an operator workflow bug. The
backend correctly says "no," and the FE has no UX path forward.

Other notable gaps (lower severity): a small set of stub endpoints
masquerade as success on the FE (`/billing/{id}/remind`,
`/billing/{id}/auto-hold`, several legacy `/support/*` aliases,
`/health/{id}/page`, `/health/{id}/silence`, `/releases/{id}/rollback`),
a recurring FE runtime regression in `AppShell.jsx` (the
`hasModule`-in-NavList issue keeps coming back through merges), and a
handful of pages where mutation invalidation could be tightened.

**Top priority follow-up:** wire the FE step-up flow before the next
deploy. Without it, the operator-safety phase 1 deploy creates a worse
UX than before — operators will see 419 errors with no path forward.

---

## 2 · Baseline results

| Check | Result |
|---|---|
| `php artisan security:audit-tenant-permissions` | ✅ clean |
| `php artisan test --filter='Platform\|Billing\|Subscription\|Collection\|Impersonation\|Security\|Activation\|Entitlement\|Lead\|CRM\|Support\|Audit'` | **395 pass · 1 fail** |
| 1 failure | `TenantSubscriptionTest > initial assignment creates open row with snapshot` — pre-existing MySQL JSON-key-order flake, documented in `RETAIL_AUDIT.md §13`. Not new. |
| `npx vitest run` | ✅ **221 / 221** |

**Working tree:** clean. No new failures introduced.

---

## 3 · Complete platform route inventory

A full route map — grouped by area. Status legend:

- **impl** — real backend logic; persists state and side effects.
- **partial** — returns real data but some operations are stubs / placeholders.
- **stub** — returns `{ ok: true }` after validate + audit but does no real work.
- **501** — explicitly returns `BACKEND_NOT_IMPLEMENTED`.
- **dangerous** — write path with material impact (revenue, access, GL).

### 3.1 Billing & collections

| Method | URI | Permission | Step-up | Status |
|---|---|---|---|---|
| `GET` | `/platform/billing/outstanding` | `platform.billing.view` | — | impl |
| `POST` | `/platform/billing` | `platform.billing.create_invoice` | — | impl · dangerous |
| `GET` | `/platform/billing/{id}` | `platform.billing.view` | — | impl |
| `POST` | `/platform/billing/{id}/issue` | `platform.billing.issue` | — | impl · dangerous |
| `POST` | `/platform/billing/{id}/cancel` | `platform.billing.cancel` | ✅ `platform.billing.cancel` | impl · dangerous |
| `POST` | `/platform/billing/{id}/remind` | `platform.billing.remind` | — | **stub** (counter only — no email/SMS) |
| `POST` | `/platform/billing/{id}/refund` | `platform.billing.refund` | ✅ `platform.billing.refund` | impl · dangerous |
| `POST` | `/platform/billing/{id}/credit` | `platform.billing.credit` | ✅ `platform.billing.credit` | **501** (BACKEND_NOT_IMPLEMENTED) |
| `POST` | `/platform/billing/{id}/auto-hold` | `platform.billing.auto_hold` | — | **stub** (audit only) |
| `GET` | `/platform/billing/{id}/payments` | `platform.billing.view` | — | impl |
| `POST` | `/platform/billing/{id}/payments` | `platform.billing.record_payment` | ✅ `platform.billing.record_payment` | impl · dangerous |
| `GET` | `/platform/billing/{id}/pdf` | `platform.billing.view` | — | impl |
| `GET` | `/platform/tenants/{id}/billing` | `platform.billing.view` | — | impl |
| `GET` | `/platform/collections/queue` | `platform.collections.view` | — | impl |
| `PATCH` | `/platform/billing/{id}/collection` | `platform.collections.work` | ✅ **conditional** (write-off only) | impl · dangerous |
| `GET` | `/platform/billing/{id}/notes` | `platform.collections.view` | — | impl |
| `POST` | `/platform/billing/{id}/notes` | `platform.collections.work` | — | impl |
| `POST` | `/platform/billing/{id}/assign` | `platform.collections.assign` | — | impl |

### 3.2 Tenants & subscriptions

| Method | URI | Permission | Step-up | Status |
|---|---|---|---|---|
| `GET` | `/platform/tenants` | `platform.tenants.view` | — | impl |
| `POST` | `/platform/tenants` | `platform.tenants.create` | — | impl · dangerous |
| `GET` | `/platform/tenants/export` | `platform.tenants.export` | — | impl |
| `GET` | `/platform/tenants/{id}` | `platform.tenants.view` | — | impl |
| `POST` | `/platform/tenants/{id}/suspend` | `platform.tenants.suspend` | — | impl · dangerous |
| `POST` | `/platform/tenants/{id}/reactivate` | `platform.tenants.reactivate` | — | impl · dangerous |
| `POST` | `/platform/tenants/{id}/plan` | `platform.tenants.change_plan` | ✅ `platform.tenants.change_plan` | impl · dangerous |
| `POST` | `/platform/tenants/{id}/impersonate` | `platform.tenants.impersonate` | — | impl · dangerous |
| `POST` | `/platform/tenants/{id}/message` | `platform.tenants.message` | — | **stub** (audit only — no send) |
| `GET` | `/platform/tenants/{id}/subscription` | `platform.subscriptions.view` | — | impl |
| `GET` | `/platform/tenants/{id}/subscription/history` | `platform.subscriptions.view` | — | impl |
| `GET` | `/platform/tenants/{tenant}/subscription/addons` | `platform.subscriptions.view` | — | impl |
| `POST` | `/platform/tenants/{tenant}/subscription/addons/preview` | `platform.subscriptions.addons.manage` | — | impl |
| `POST` | `/platform/tenants/{tenant}/subscription/addons` | `platform.subscriptions.addons.manage` | — | impl · dangerous |
| `POST` | `/platform/tenants/{tenant}/subscription/addons/{addon}/end` | `platform.subscriptions.addons.manage` | — | impl · dangerous |
| `GET` | `/platform/tenants/{id}/subscription/extensions` | `platform.subscriptions.view` | — | impl |
| `POST` | `/platform/tenants/{id}/subscription/extend` | `platform.subscriptions.extend` | ✅ `platform.subscriptions.extend` | impl · dangerous |
| `POST` | `/platform/tenants/{id}/plan/schedule` | `platform.tenants.change_plan` | ✅ `platform.tenants.change_plan` | impl · dangerous |
| `DELETE` | `/platform/tenants/{id}/plan/schedule` | `platform.tenants.change_plan` | — | impl |

### 3.3 Leads / CRM / Support / Onboarding / Audit

| Method | URI | Permission | Step-up | Status |
|---|---|---|---|---|
| `GET` | `/platform/leads` | `platform.leads.view` | — | impl |
| `POST` | `/platform/leads` | `platform.leads.manage` | — | impl |
| `PATCH` | `/platform/leads/{id}` | `platform.leads.manage` | — | impl |
| `POST` | `/platform/leads/{id}/assign` | `platform.leads.assign` | — | impl |
| `POST` | `/platform/leads/{id}/activities` | `platform.leads.manage` | — | impl |
| `POST` | `/platform/leads/{id}/convert` | `platform.leads.manage` | — | impl |
| `GET` | `/platform/crm/pipeline` | `platform.crm.view` | — | impl |
| `PATCH` | `/platform/crm/pipeline/{id}` | `platform.crm.write` | — | impl |
| `GET` | `/platform/crm/tenants/{id}` | `platform.crm.view` | — | impl |
| `POST` | `/platform/crm/tenants/{id}/note` | `platform.crm.write` | — | impl |
| `POST` | `/platform/crm/tenants/{id}/task` | `platform.crm.write` | — | impl |
| `GET` | `/platform/crm/tasks` | `platform.crm.view` | — | impl |
| `PATCH` | `/platform/crm/tasks/{id}` | `platform.crm.write` | — | impl |
| `GET` | `/platform/crm/nps` | `platform.crm.view` | — | impl |
| `GET` | `/platform/crm/activities` | `platform.crm.view` | — | impl |
| `GET` | `/platform/support` | `platform.support.view` | — | impl |
| `POST` | `/platform/support/{id}/messages` | `platform.support.reply` | — | impl |
| `POST` | `/platform/support/{id}/notes` | `platform.support.internal_note` | — | impl |
| `POST` | `/platform/support/{id}/assign` | `platform.support.assign` | — | impl |
| `POST` | `/platform/support/{id}/status` | `platform.support.status` | — | impl |
| `POST` | `/platform/support/{id}/resolve` | `platform.support.resolve` | — | **stub** (legacy alias — no message persisted) |
| `POST` | `/platform/support/{id}/reply` | `platform.support.reply` | — | **stub** (legacy alias — no message persisted) |
| `GET` | `/platform/onboarding` | `platform.onboarding.view` | — | impl |
| `POST` | `/platform/onboarding/{id}/nudge` | `platform.onboarding.work` | — | **stub** (no channel send) |
| `POST` | `/platform/onboarding/{id}/advance` | `platform.onboarding.work` | — | impl |
| `GET` | `/platform/audit/{id}` | `platform.audit.view` | — | impl |

### 3.4 Activation codes / staff / marketplace / public site

All `impl`. No step-up. Routes:
`/platform/activation-codes` (CRUD + deactivate/reactivate/revoke/usages),
`/platform/staff` (CRUD + deactivate/reactivate/reset-password),
`/platform/marketplace/listings` (approve/reject/update),
`/platform/public-site` and `/platform/public-plans`.

### 3.5 Pay terminals (cross-tenant fleet)

| Method | URI | Status |
|---|---|---|
| `GET / POST / GET{id} / POST{id}/reissue / decommission / support` | `/platform/pay/terminals/*` | impl |
| `POST` | `/platform/pay/support/{id}/resolve` | **stub** |
| `POST` | `/platform/health/{id}/page` | **stub** |
| `POST` | `/platform/health/{id}/silence` | **stub** |
| `POST` | `/platform/releases/{id}/rollback` | **stub** |

### 3.6 Platform accounting (DAL SEEN's own books)

All `impl` read paths. No writes:
`/accounting/summary | trial-balance | profit-loss | balance-sheet | journals | gl/{code} | ar-aging | revenue-summary | tenant-balance/{tenant}`,
plus `PUT /accounting/settings`.

### 3.7 Platform-adjacent (auth + /me)

`POST /auth/mfa`, `POST /auth/logout`, `POST /auth/step-up` (issues
ticket), `POST /me/pos-pin` (set PIN), `POST /auth/impersonate/end`
(revoke session + token), `GET /me` (full payload including
`entitlements`), `GET /me/entitlements`. All impl.

---

## 4 · FE caller inventory (summary)

Full per-route table is in agent reports; this summary captures
coverage:

- **Total Platform API functions exported:** ~150.
- **Wrapped by hooks in `platform.hooks.js`:** all of them.
- **Orphan hooks (exported but no UI consumer):** spot-check turned up
  no fully dead exports — every hook is imported in at least one
  page / drawer / dialog.
- **Pages:** `TenantsPage`, `BillingPage`, `PlansPage`, `SignupsPage`,
  `OnboardingPage`, `LeadsPage`, `SupportPage`, `ActivationCodesPage`,
  `StaffPage`, `PublicWebsitePage`, `MarketplaceListingsPage`,
  `DalseenBooksPage`, `HealthPage`, `CompliancePage`, `IncidentsPage`,
  `ReleasesPage`, `AuditPage`, `PayTerminalsPage`, plus CRM
  `PipelinePage` / `RelationshipPage` / `TasksPage` / `NpsPage` /
  `ActivitiesPage`.
- **Drawers:** `TenantDetailDrawer`, `BillingDetailDrawer`,
  `LeadDetailDrawer`, `OnboardingDetailDrawer`, `IncidentDetailDrawer`,
  `ReleaseDetailDrawer`, `FrameworkDetailDrawer`, `ServiceDetailDrawer`,
  `TicketDetailDrawer`, `AuditDetailDrawer`.
- **Shared dialogs:** `ConfirmDestructiveDialog`,
  `ActivationCodeCreateDialog`, `PlanEditorDialog`, `StaffEditorDialog`,
  `InvoiceCreateDialog`, `RecordPaymentDialog`, `MessageDialog`,
  `TempPasswordModal`.
- **Step-up modal:** `app/src/components/StepUp.jsx` —
  `useStepUp()` imperative API + `<StepUpProvider>` global modal.
- **Ticket cache:** `app/src/core/auth/stepUpTickets.js` — in-memory
  map keyed by permission, 300s TTL.
- **HTTP interceptor:** `app/src/core/api/realHttp.js:81-86` —
  attaches `X-Step-Up-Ticket` header iff caller passes `opts.stepUpFor`.

---

## 5 · Connected routes (the happy paths)

The following sets are correctly wired end-to-end (FE button → hook →
API fn → backend → mutation invalidation → list/detail refetches):

- **Tenants list / detail / create / message / suspend / reactivate / impersonate / plan-change UI.** Note: the *backend* enforces step-up on plan-change, but the FE doesn't request a ticket — see §8.
- **Subscription history / extensions / scheduled changes / add-ons.** Reads work; writes have the same FE step-up gap (§8).
- **Signups list / detail / promote / reject.** ✓
- **Onboarding list / advance step.** Read + advance are wired. Nudge is a stub (§9).
- **Leads list / detail / create / update / assign / activities / convert.** ✓ Sales-agent row-scoping is correctly applied at read; CRM tenant 360 walks lead → tenant lineage.
- **CRM pipeline / tasks / NPS / activities.** ✓
- **Support inbox / ticket detail / messages / internal notes / assign / change-status.** ✓ (legacy `/resolve` and `/reply` aliases are stubs but not consumed — see §9.)
- **Plans list / create / edit / delete.** ✓
- **Activation codes** (every CRUD operation including deactivate / reactivate / revoke / usages). ✓
- **Staff** (CRUD + deactivate / reactivate / reset-password). ✓ Temp credential envelope correctly preferred over legacy top-level key.
- **Marketplace listings** (approve / reject / update). ✓
- **Public site CMS** (`/platform/public-site` + `/platform/public-plans` editors). ✓
- **Platform accounting reads** (`DalseenBooksPage` tabs: summary, trial balance, P&L, balance sheet, journals, GL detail, AR aging, revenue summary, tenant balance). ✓
- **Audit log search + detail drawer.** ✓
- **Pay terminals** (issue / reissue / decommission / open-support). ✓
- **Health / Incidents / Releases reads.** ✓
- **Impersonation start/end** (with `EnsureImpersonationActive` middleware blocking stale sessions). ✓
- **`/me` + `/me/entitlements`** (FE session store consumes both; `hasModule` selector works against `/me.modules`). ✓ — but see runtime regression in §13.

---

## 6 · Backend-only endpoints (no FE caller)

A focused scan turned up **no fully dead backend endpoints** in the
Platform surface. Every route mapped above has at least one FE consumer.

A few are touched only by indirect callers:
- `GET /platform/staff/_meta/roles` — used inside `StaffEditorDialog`, not paged. ✓
- `GET /platform/leads/{id}` and lead-activity list — `LeadDetailDrawer` reads through the list cache, not a dedicated detail query. Not broken, but the drawer can show slightly stale data after an activity is logged.

**Worth flagging:** several **report endpoints** in
`PlatformAccountingController` (revenue-summary, tenant-balance, ar-aging)
are wired to `DalseenBooksPage` tabs but **not exposed in the main
Billing dashboard KPI strip**, even though they would give operators
the missing "Refunded MTD / Written-off MTD / Net revenue" view called
out in `PLATFORM_SUBSCRIPTION_REVIEW §6.4`. Data exists; not rendered
where it matters.

---

## 7 · Frontend-only or broken callers

No platform FE function currently targets a missing backend route. The
mock-API adapter (`useMockApi`) does ship in dev, but every shape is
also implemented in the real backend.

The closest match to a "broken caller" is **`useCreditInvoice`**, which
points at an existing route that returns `501`. No UI surfaces this hook
today, and the platform.hooks.js declaration has a prominent deprecation
comment. So it's not strictly broken — it's gated.

---

## 8 · Step-up mismatch findings — **HIGHEST PRIORITY**

The platform-operator-safety branch added `stepup:permission.key`
middleware to eight platform routes:

```
POST /platform/billing/{id}/refund        stepup:platform.billing.refund
POST /platform/billing/{id}/credit        stepup:platform.billing.credit
POST /platform/billing/{id}/cancel        stepup:platform.billing.cancel
POST /platform/billing/{id}/payments      stepup:platform.billing.record_payment
POST /platform/tenants/{id}/plan          stepup:platform.tenants.change_plan
POST /platform/tenants/{id}/plan/schedule stepup:platform.tenants.change_plan
POST /platform/tenants/{id}/subscription/extend stepup:platform.subscriptions.extend
PATCH /platform/billing/{id}/collection   (conditional, write-off path only)
```

The backend tests for this all use `X-Skip-Step-Up: 1` via the
`TestCase::bypassStepUp()` helper, so they pass. **But the production
FE has no equivalent.** Concretely:

- `app/src/modules/platform/platform.api.js` does NOT pass
  `stepUpFor: '...'` on any of these calls. Spot check at lines
  44-50 (`cancelInvoice`, `refundInvoice`, `creditInvoice`,
  `recordPayment`) confirms it.
- `realHttp.js:81-86` only attaches `X-Step-Up-Ticket` when
  `opts.stepUpFor` is passed. So no header is sent.
- The mutation hooks in `platform.hooks.js` do NOT call `useStepUp()`
  before firing.
- There is **no 419 interceptor** in `realHttp.js`. The
  `RequireStepUp` middleware throws `ApiException 419 STEP_UP_REQUIRED`;
  the FE surfaces this as a generic mutation error.

### Per-route status

| FE caller | Backend gate | FE step-up handling | Result on click |
|---|---|---|---|
| `useRefundInvoice` | `stepup:platform.billing.refund` | ❌ no ticket | 419 → generic error |
| `useCreditInvoice` | `stepup:platform.billing.credit` | ❌ no ticket | 419 (then 501 if it ever got past) — but UI doesn't wire the button anyway |
| `useCancelInvoice` | `stepup:platform.billing.cancel` | ❌ no ticket | 419 → generic error |
| `useRecordPayment` | `stepup:platform.billing.record_payment` | ❌ no ticket | 419 → generic error |
| `useChangeTenantPlan` | `stepup:platform.tenants.change_plan` | ❌ no ticket | 419 → generic error |
| `useScheduleTenantPlanChange` | `stepup:platform.tenants.change_plan` | ❌ no ticket | 419 → generic error |
| `useExtendSubscription` | `stepup:platform.subscriptions.extend` | ❌ no ticket | 419 → generic error |
| `useUpdateCollection` (when status='written_off') | conditional in controller | ❌ no ticket | 419 → generic error |

**Severity: HIGH.** Production deploys of `platform-operator-safety`
without an accompanying FE patch are guaranteed to break operator
workflows for refunds, cancellations, payments, plan changes, plan
schedules, subscription extensions, and write-offs.

### Two fix options

1. **Per-callsite (smallest diff):** add `stepUpFor: '...'` to the
   relevant API functions and wrap each mutation with a `useStepUp()`
   prompt that runs before `mutate(...)`.
2. **Generic interceptor (defense in depth):** teach `realHttp.js` to
   catch 419 `STEP_UP_REQUIRED`, read `error.fields.permission` from
   the response, prompt `useStepUp(permission)`, then auto-retry the
   request with the ticket attached.

Option 2 is the same as the existing pattern for SESSION_EXPIRED. It
fixes the entire class without touching every mutation. The risk is
infinite retry loops if the user cancels the PIN modal — easy to
guard by attaching the ticket only on retry and surfacing the original
error if the prompt is dismissed.

---

## 9 · Stub / 501 exposed findings

Backend-stub endpoints with at least one FE consumer:

| Route | Status | FE consumer | Visible signal? |
|---|---|---|---|
| `POST /platform/billing/{id}/remind` | stub (counter only, no send) | `BillingDetailDrawer` "Send reminder" button | ❌ shows success toast. Counter increments; FE renders "Last nudged 3d ago · 4 reminders sent" — **misleading**. |
| `POST /platform/billing/{id}/auto-hold` | stub (audit only) | No FE consumer in current UI. ✓ |
| `POST /platform/billing/{id}/credit` | 501 BACKEND_NOT_IMPLEMENTED | No FE button (hook exists with deprecation comment). `ErrorBanner` renders 501 as friendly "Coming soon" tone if a future caller wires it. ✓ |
| `POST /platform/tenants/{id}/message` | stub | `MessageDialog` in `TenantDetailDrawer` | ❌ shows success toast — operator thinks merchant got the message. |
| `POST /platform/onboarding/{id}/nudge` | stub | `OnboardingDetailDrawer` "Nudge" button | ❌ shows success toast — operator thinks tenant got the nudge. |
| `POST /platform/health/{id}/page` | stub | `ServiceDetailDrawer` | ❌ shows success — on-call never paged. |
| `POST /platform/health/{id}/silence` | stub | `ServiceDetailDrawer` | ❌ shows success — alerts continue firing. |
| `POST /platform/releases/{id}/rollback` | stub (updates DB only, no deploy call) | `ReleaseDetailDrawer` | ❌ rollback marked in UI; no actual deploy change. |
| `POST /platform/support/{id}/resolve` | stub (legacy alias) | Not wired in current FE (new path is `changeTicketStatus`). ✓ |
| `POST /platform/support/{id}/reply` | stub (legacy alias) | Not wired in current FE (new path is `postMessage`). ✓ |
| `POST /platform/pay/support/{id}/resolve` | stub | Possibly wired via `useResolveSupport`. Inspect on next pass — low-traffic surface. |

**Severity:** MEDIUM. None of these will lose money or corrupt data,
but they tell operators a thing happened that did not.

### Recommended FE-side mitigation (small fix candidates)

- **`remind`**: show a different toast — "Reminder logged (counter
  incremented). Email integration pending — see `PLATFORM_SUBSCRIPTION_REVIEW
  §3.5`." OR hide the button until the real integration ships.
- **`messageTenant`**: same — "Message logged" with explicit note.
- **`onboarding/nudge`**: same.
- **`health/page`** / **`health/silence`** / **`releases/rollback`**:
  add a "simulated" badge in the drawer or remove the button until
  the underlying integrations land.

These are FE-only changes — no backend logic touched.

---

## 10 · Data returned but not rendered

Spot findings where backend payload carries more than the UI surfaces:

| Endpoint | Field missing in UI |
|---|---|
| `/me/entitlements` | `module_access[*].upgrade_required` is set; `ModuleLocked` doesn't always render an upgrade CTA. (Already flagged in `PLATFORM_SUBSCRIPTION_REVIEW §6.7`.) |
| `/platform/tenants/{id}/subscription` | `next_plan_id` + `next_plan_effective_at` are returned; `PendingPlanChangeBanner` does render them but `TenantDetailDrawer`'s Subscription tab could also surface them more prominently. |
| `/platform/billing/{id}` | `reminders_sent_count` + `last_reminded_at` are rendered, but the counter increments without a real send (see §9). |
| `/platform/billing/{id}/payments` | Each payment row has `method` and `received_by` — surfaces in detail table. ✓ |
| `/platform/tenants/{id}/users/{user}/reset-password` | New `credential` envelope is correctly preferred over legacy `temp_password`. ✓ |
| `/platform/billing/outstanding` (KPIs) | No "Refunded MTD" / "Written off MTD" KPI on `BillingPage` — already flagged. |
| Audit log rows | `severity` is rendered as a badge, but `before / after` payload (when present on `crm` / `lead.activity` rows) isn't shown in `AuditDetailDrawer`. |

**Severity: LOW.** All informational / cosmetic — no security impact.

---

## 11 · Cache invalidation risks

Spot checks confirm that the main mutation hooks invalidate sensibly:

- `useSuspendTenant` / `useReactivateTenant` → `['platform','tenants']`. ✓
- `useChangeTenantPlan` → `['platform','tenants',id]` + subscription queries. ✓ — but the success only fires when the call succeeds; with the step-up gap, the cache is never invalidated because the call 419s.
- `useRefundInvoice` / `useCancelInvoice` / `useIssueInvoice` → `['platform','billing',id]`. ✓
- `useRecordPayment` → `['platform','billing',id]` + `['platform','billing',id,'payments']`. ✓
- `useUpdateCollection` / `useAddCollectionNote` / `useAssignCollection` →
  `['platform','collections']` + `['platform','billing',id,'notes']`. ✓
- `useExtendSubscription` → `['platform','tenants',id,'subscription*']`. ✓
- `useImpersonateTenant` → does not invalidate (correct — it swaps session).

**No structural invalidation gaps** at the hook level. The user-visible
risk is that **mutations that 419 (see §8) don't fire onSuccess at all**,
so caches stay correct but the action also didn't happen. Operators
will need to retry through the FE step-up flow once it lands.

---

## 12 · Destructive-action safety gaps

The operator-safety phase added good coverage:

- ✅ `TenantDetailDrawer`: suspend / reactivate / impersonate all gated by `<ConfirmDestructiveDialog>` requiring typed slug.
- ✅ `PlanChangeDialog`: gains commercial-impact summary banner and required acknowledgment checkbox.
- ✅ `BillingDetailDrawer`: refund + cancel modals already had warning + amount confirmation.

Remaining gaps (lower priority):

| Action | Confirm? | Severity |
|---|---|---|
| `useRevokeActivationCode` (`ActivationCodesPage`) | Inline confirm modal | LOW (acceptable) |
| `useDeletePlan` (`PlansPage`) | Confirm dialog with warning about active tenants | LOW (acceptable) |
| `useDeactivateStaff` / `useReactivateStaff` (`StaffPage`) | Inline confirm | LOW (acceptable) |
| `useEndSubscriptionAddon` (`TenantDetailDrawer` AddonsPanel) | Inline confirm | LOW (acceptable) |
| `useApproveMarketplaceListing` / `useRejectMarketplaceListing` | Confirm modal with reason | LOW |
| `useCancelScheduledTenantPlanChange` | Confirm modal | LOW |
| `useToggleAutoHold` (no FE consumer) | n/a | n/a |

**Severity: LOW.** No destructive operator action is one-click unconfirmed
in the current UI.

---

## 13 · Recurring FE regression — `hasModule` in `NavList`

This is a separate issue from the wiring audit but surfaced during the
audit's baseline check.

`app/src/app/AppShell.jsx` currently has the broken pattern again:

- Line 27: `const hasModule = useSessionStore((s) => s.hasModule);` hooked in `AppShell` scope.
- Line 60: `<DesktopSidebar ... />` — does NOT pass `hasModule`.
- Line 213: `function DesktopSidebar({ t, locale, isPlatform, can, user, onSignOut })` — no `hasModule`.
- Line 232: `<NavList t={t} isPlatform={isPlatform} can={can} variant="responsive" />` — no `hasModule`.
- Line 264: `function NavList({ t, isPlatform, can, variant, onNavigate })` — no destructured `hasModule`.
- Line 318: `if (it.module && !g.platformOnly && !hasModule(it.module)) return false;` — references undefined `hasModule`.

The correct fix (proven on the `module-entitlement-completion` and
`platform-operator-safety` branches): hook `hasModule` directly inside
`NavList` via `const hasModule = useSessionStore((s) => s.hasModule);`.
This eliminates the prop-threading bug class.

The regression keeps coming back through merges because the existing
vitest assertion only checks "does `hasModule` appear in
`AppShell.jsx`?" — which is true (it's in `AppShell` scope) but
useless. The stricter regression pin from the operator-safety branch
asserts:
- `NavList` body must reference `useSessionStore((s) => s.hasModule)`.
- `NavList` must NOT destructure `hasModule` from props.
- `<NavList />` render sites must NOT pass `hasModule=` (would be dead).

That stricter pin was lost in the same merges as the fix.

**Severity: HIGH on the FE** — every authenticated user sees a render
error in the browser console. Vitest doesn't catch it because there's
no DOM-rendering test.

**This is the single safe FE-only fix that's clearly within audit
scope. Will apply at the end of this branch.**

---

## 14 · Test coverage gaps

| Area | Backend test | FE test | Gap |
|---|---|---|---|
| Step-up gates (`platform-operator-safety`) | ✅ 14 tests in `PlatformOperatorStepUpTest` | ❌ no FE integration test for the step-up modal flow | HIGH — backend says yes, FE says nothing |
| Module entitlement direct-URL | ✅ 16 tests in `ModuleEntitlementCompletionTest` | ✅ `navFilter.test.js` (when not regressed) | LOW |
| Impersonation session enforcement | ✅ 6 tests in `ImpersonationSafetyTest` | ❌ no FE test for the 401-IMPERSONATION_ENDED handling | MEDIUM |
| Temp credential envelope | ✅ 4 tests in `PlatformMerchantTempCredentialTest` | ❌ no FE assertion that `TempPasswordModal` reads `credential.value` | LOW |
| Credit-note 501 | ✅ asserted in `PlatformPermissionGatingTest` | ❌ no FE test of `ErrorBanner` rendering "Coming soon" tone | LOW (no UI consumer) |
| Subscription extensions | ✅ `PlatformSubscriptionExtensionsAndScheduleTest` | ❌ no FE test for the extend dialog | LOW |
| Scheduled plan change | ✅ same | ❌ no FE test | LOW |
| Add-ons (preview / add / end) | ✅ `PlatformSubscriptionAddonControllerTest` (likely) | ❌ no FE test | LOW |
| Collections write-off | ✅ existing CollectionsService tests | ❌ no FE test for the conditional step-up trigger | MEDIUM (linked to §8) |
| AR aging / revenue summary | ✅ accounting tests | ❌ no FE test | LOW |
| Tenant balance | ✅ accounting tests | ❌ no FE test | LOW |
| Stub endpoints' misleading success | n/a | ❌ no FE test asserting "operator sees a warning, not success" | MEDIUM |

The biggest test gap is **the absence of any FE integration test for
step-up flows.** Even a logic-only `vitest` would catch the missing
`stepUpFor` parameter on the API functions.

---

## 15 · High-risk issues (ranked)

1. **HIGH** — Step-up FE wiring missing on 7 mutations (§8). Refund, cancel, record_payment, plan-change, plan-schedule, subscription-extend, write-off all 419 in production. Operators have no path forward.
2. **HIGH** — `hasModule`-in-`NavList` runtime regression (§13). Every authenticated user sees a `ReferenceError` in the browser console; the sidebar fails to render. Caught only by manual testing.
3. **MEDIUM** — Stub endpoints exposed as success in UI (§9). Five stubs (`remind`, `messageTenant`, `onboarding/nudge`, `health/page`, `health/silence`, `releases/rollback`) tell operators an action happened that did not.
4. **MEDIUM** — No FE step-up integration tests (§14). Even a logic-only test would catch the missing `stepUpFor` parameter.
5. **MEDIUM** — `BillingPage` KPI strip lacks "Refunded MTD / Written-off MTD / Net revenue" cards. Backend endpoints exist; UI doesn't render them. (Cross-ref `PLATFORM_SUBSCRIPTION_REVIEW §6.4`.)
6. **LOW** — `AuditDetailDrawer` doesn't render `before / after` payload for crm/lead activity rows. Data exists; not shown.
7. **LOW** — `EnsureImpersonationActive` 401 returns are handled generically (SESSION_EXPIRED → reset → /login). Acceptable; no distinct UI for the cause.

---

## 16 · Safe fix order

A. **Immediate (this branch — one tiny FE-only fix per audit rules):**
   1. Re-apply the `hasModule`-in-`NavList` fix and the stricter
      regression pin in `navFilter.test.js`. Document the merge
      regression so it stops coming back.

B. **Next branch (`platform-stepup-fe-wiring`, 1–2 days, HIGH priority):**
   2. Add a 419 interceptor to `realHttp.js` that catches
      `STEP_UP_REQUIRED`, reads `error.fields.permission`, prompts
      `useStepUp(permission)`, and retries the request with the
      ticket attached. This closes §8 for every gated route at once.
   3. Add a vitest source-grep that asserts `realHttp.js` handles 419,
      so the same gap doesn't return through a future refactor.
   4. Add a smoke FE test for `useRefundInvoice` (or any one
      step-up-gated mutation) that proves the 419-retry flow works
      with a stubbed ticket.

C. **Follow-up branch (`platform-stub-honesty`, 1 day, MEDIUM):**
   5. Mark `/billing/{id}/remind` UI toast as "Reminder logged
      (integration pending)" instead of "Reminder sent." OR hide
      the button until the real provider ships.
   6. Same for `messageTenant`, `onboarding/nudge`, `health/page`,
      `health/silence`, `releases/rollback`.
   7. Add a simple "stub badge" UI primitive for any endpoint that
      returns `_stub: true` or has a known stub status.

D. **Bigger work (cross-reference `PLATFORM_SUBSCRIPTION_REVIEW §10`):**
   8. Phase B — Subscription correctness (idempotency keys, pay_module
      fix, change_reason in audit, unique constraint).
   9. Phase C — Billing completeness (real credit-note, real reminders).
   10. Phase D — Operator UX (CollectionsPage, Tenant 360, KPI strip).

---

## 17 · Recommended next fix branch

**`platform-stepup-fe-wiring`** — single concern: close §8.

Scope:
- Frontend-only.
- Add 419 `STEP_UP_REQUIRED` interceptor to `realHttp.js`.
- Add `useStepUp()` retry helper.
- Add at least one integration-style FE test.
- Optionally: add a permission-keyed step-up button on the platform
  step-up modal so operators see "Step up to confirm refund" instead
  of a generic PIN prompt.

**No backend changes.** No Retail / Dine / Inventory / Pricing /
Journal touches. Estimated effort: 1-2 days. Highest single
operational-safety win available right now.

---

## 18 · Architectural compliance

This audit followed the constraints recorded in `README_SYSTEM_ARCHITECTURE.md`:

- ✅ Platform-domain only — no `Pos/`, `Dine/`, `Inventory/`, `Pricing/`, `Journal/` references.
- ✅ Read-only — no backend changes.
- ✅ One tiny FE fix applied at the end (§13 `hasModule` regression) — documented before applying.
- ✅ PermissionBoundary untouched.
- ✅ Module entitlement gates untouched.
- ✅ Future modules (Wholesale / Van Sales) can still plug into the same step-up + entitlement stack — the recommended `platform-stepup-fe-wiring` branch makes the FE flow generic and not platform-specific.

---

**End of audit. One tiny FE fix applied. No deploy.**
