← All epicsBRD.md §9 · lines 7947–8361

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 21 — Merchant dashboard, KPIs, exception queue (derived view)

Read-only per-epic slice of BRD.md §9, lines 7947–8361. The canonical source of truth is BRD.md — edit there, never here. The stable address for a story is its US-ID (US-21.x), not a line number. Regenerates on every dev → main sync via derive-state-on-main.

  • Stories (8): US-21.1, US-21.2, US-21.3, US-21.4, US-21.5, US-21.6, US-21.7, US-21.8
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 21 — Merchant dashboard, KPIs, exception queue

<!-- traceability:start:BRD:Epic-21 -->

Prototype: Dashboard · Exception Queue · Subscriptions · Subscription Detail · Advanced Filters · Bulk Actions · CSV Export · Cohort & LTV · Detail

<!-- traceability:end:BRD:Epic-21 -->

Value: Merchants have one screen that tells them what's working and what needs attention.

US-21.1: KPI summary

<!-- traceability:start:US-21.1 -->

Prototype: Dashboard

<!-- traceability:end:US-21.1 -->

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want the dashboard to show MRR, active subs, new subs, churn rate at a glance, so that I know my business health.

Acceptance criteria:

  • Given I open the dashboard, When it renders, Then tiles show: MRR (with trailing 30-day delta), active subs, new subs (30d), cancels (30d), gross churn %, net revenue churn %.
  • Given I click a tile, When it drills in, Then I see time-series and supporting detail.

UI states.

<!-- ui-states US-21.1 -->
surface: "Admin (React/BigDesign) merchant dashboard KPI tile row — apps/admin/src/pages/dashboard/Dashboard.tsx (KpiGrid, lines 213-265). Persona: Merchant Admin. Three drill-through KpiCard buttons (MRR, churn rate, failed charges) backed by GET /api/v1/admin/dashboard (getAdminDashboard, AdminDashboardPayload)."
idle:
  render: "Page title + subtitle, then a responsive KpiGrid of drill-through tiles. Each KpiCard (styled.button, lines 38-62) shows a label, an H1 value, help text, and an inline 7-day sparkline (renderSparkline, lines 229/246/263): MRR = formatCents(data.mrr_cents); churn = formatPct(data.churn_rate); failed = data.failed_count. BRD north-star is 6 tiles (see gaps)."
  primary_action: "Click a tile to drill in: MRR -> /subscriptions?status=active&sort=-mrr (line 216), churn -> /subscriptions?status=cancelled&since=30d (line 233), failed -> /exceptions?type=charge_failed (line 250). NOTE: this drills to a filtered LIST, not the time-series detail the AC asks for (see gaps)."
loading:
  trigger: "GET /api/v1/admin/dashboard via getAdminDashboard() on mount (useEffect, lines 166-178)."
  render: "Page-level: while data === null the whole page is replaced by a single 'Loading...' Text (FormattedMessage merchantAdmin.dashboardLanding.loading, lines 192-200). The KPI tiles are not independently skeletoned; there is no per-tile spinner."
error:
  surfaced_at: "Page-level: on fetch failure the entire dashboard is replaced by a BigDesign Message(type='error') with header merchantAdmin.dashboardLanding.errorHeader and the raw error text (lines 180-190). Not a toast; not scoped to a tile."
  render: "The error header copy plus the raw error.message string."
  recovery: "Reload the page — there is NO in-app retry control; the error view offers no button to re-invoke getAdminDashboard (see gaps + defects)."
empty:
  render: "A brand-new store renders all three tiles with zeroed values ($0.00 / 0.0% / 0) and no sparkline (renderSparkline returns null on an empty trend array, line 92). There is NO dedicated 'no data yet' empty copy for the KPI row, so a new merchant's zeros are indistinguishable from a real zero-MRR business (see gaps)."
  cta: "n/a — the KPI row is not a collection; the per-tile drill-through is the only forward path."
edge_status:
  - status: "Metric present but no historical trend (new store / first 7 days)"
    affordance: "The headline figure still renders and the tile remains a working drill-through button — clicking it opens the filtered subscriptions/exceptions list so the merchant can act despite the missing sparkline."
disabled_focus:
  keyboard: "Each KPI tile is a real <button type='button'> (KpiCard styled.button, lines 38-62; type + aria-label set at lines 214-218/231-235/248-252) reachable in tab order and activatable with Enter or Space, with a visible :focus-visible outline (lines 58-61). Tab order: MRR -> churn -> failed, then the section cards' 'Open queue' / 'Open list' buttons."
  gaps: "The page-level loading Text (192-200) and error Message (180-190) are inserted with no aria-live region and no programmatic focus move — a keyboard/SR user gets no announcement when the dashboard finishes loading or fails."
gaps: "PARTIAL. (1) Only 3 tiles render; the BRD AC requires 6: MRR (with trailing 30-day delta), active subs, new subs (30d), cancels (30d), gross churn %, net revenue churn %. active_subscription_count / new_subs_30d / cancels_30d are absent from AdminDashboardPayload (api-client.ts:428-444) and the render; churn is a single churn_rate, not split into gross vs net revenue churn; the 3rd tile (failed charges) is not in the AC's 6. (2) AC clause 2 ('drill in -> time-series and supporting detail') is unmet: tiles navigate to a filtered list, and the only inline series is the 7-day sparkline. (3) The load error has no retry affordance (reload-only)."

US-21.2: Upcoming charges panel

<!-- traceability:start:US-21.2 -->

Prototype: Dashboard

<!-- traceability:end:US-21.2 -->

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want to see total upcoming charge value for the next 7 and 30 days, so that I can forecast revenue.

Acceptance criteria:

  • Given upcoming charges exist, When the panel renders, Then I see $ value, charge count, and a mini-chart over the period.

UI states.

<!-- ui-states US-21.2 -->
surface: "Admin (React/BigDesign) merchant dashboard 'Upcoming charges' panel — apps/admin/src/pages/dashboard/Dashboard.tsx (SectionCard, lines 267-312). Persona: Merchant Admin. A revenue-forecast widget backed by AdminDashboardPayload.upcoming_charges_7d (api-client.ts:443) from GET /api/v1/admin/dashboard."
idle:
  render: "A card with a header (merchantAdmin.dashboardLanding.upcoming.header) and a summary line totalling charge count and $ value over the window (reduce over upcoming_charges_7d, lines 276-279), then a mini bar chart: one DayBar per day scaled to the max amount (ChargesBar, lines 288-299) with weekday labels beneath (DayLabel, lines 300-308). Renders 7 days only — the BRD's 30-day view is absent (see gaps)."
  primary_action: "n/a — read-only forecast widget; no CTA. Per-bar count/$ is shown via a hover title (line 293)."
loading:
  trigger: "GET /api/v1/admin/dashboard via getAdminDashboard() on mount (lines 166-178) — the panel shares the page load; it is not fetched independently."
  render: "Page-level: while data === null the whole dashboard shows a single 'Loading...' Text (lines 192-200); the panel itself has no skeleton."
error:
  surfaced_at: "Page-level: on dashboard fetch failure the panel is not rendered — the entire page is replaced by a BigDesign Message(type='error') (lines 180-190)."
  render: "The error header (merchantAdmin.dashboardLanding.errorHeader) plus the raw error string."
  recovery: "Reload the page — there is no in-app retry control (shared with the dashboard load; see US-21.1 defects)."
empty:
  render: "When upcoming_charges_7d is an empty array, ChargesBar renders no bars and the summary reduce yields '0 charges / $0.00' (lines 276-279; max defaults to 1 at line 285). There is NO explicit 'No upcoming charges' empty copy — the panel just shows an empty bar area (see gaps)."
  cta: "n/a — informational forecast; nearest action is the subscriptions list to investigate scheduling."
disabled_focus:
  keyboard: "The forecast panel contains NO actionable controls — it is a read-only visualization; the nearest focusable elements in tab order are the dashboard's KPI tiles (US-21.1) and the section-card buttons. No div-onClick dead-ends exist because the panel registers no click handlers."
  gaps: "Per-day detail (count + $ amount) is exposed ONLY via a mouse-hover `title` on each DayBar (line 293) — not keyboard- or screen-reader-reachable. A keyboard/SR user can read the aggregate summary but not the per-day breakdown."
gaps: "PARTIAL. (1) Only the 7-day forecast (upcoming_charges_7d) is built; the BRD requires BOTH 7-day and 30-day views — there is no upcoming_charges_30d in AdminDashboardPayload (api-client.ts:428-444) and no 30d render/toggle. (2) The empty forecast shows an empty bar area with no explicit copy. (3) Per-bar detail is mouse-hover-only (keyboard/SR inaccessible)."

US-21.3: Exception queue

<!-- traceability:start:US-21.3 -->

Prototype: Exception Queue · Detail

<!-- traceability:end:US-21.3 -->

Phase: MVP · Priority: P0 · Effort: L · Persona: Support / Ops

As Support / Ops, I want a prioritized list of subscriptions needing attention, so that I work the highest-impact items first.

Acceptance criteria:

  • Given exceptions exist, When I open the queue, Then items are grouped by type (failed charge, OOS renewal, reconciliation drift) and sorted by impact (MRR of affected sub, days in exception).
  • Given I resolve an item, When I click "Mark resolved" with a note, Then it leaves the queue and Event-logs the resolution.

UX notes.

  • Surface: /stores/[storeHash]/exceptions
  • Layout: left sidebar filters (type, severity, date range), main table with columns: Type | Subscription | Customer | Impact (MRR) | Age | Status | Actions
  • Each row expandable to show: context summary, proposed remediation, "Mark resolved" + "Escalate" buttons
  • Empty state: "All clear" confirmation

Data contract.

  • Our API: GET /api/v1/exceptions?filter=...
  • Entity: exception_queue rows with type, severity, subscription_id, charge_id, detected_at, proposed_remediation, status, resolved_by, resolution_notes
  • Types: charge_failed_dunning, out_of_stock_at_renewal, reconciliation_drift, processor_pm_invalidated, chargeback_received

Success metrics.

  • Functional (target): every exception in DB appears in queue within 1 min
  • Product (target): median time-to-resolution ≤ 1 business day
  • Operational (target): exception backlog size (unresolved > 7d) < 5% of total

Dependencies.

  • US-13.2 (reconciliation populates drift exceptions)
  • Epic 11 (dunning populates charge-failed exceptions)

Non-functional.

  • Queue is real-time (websocket or polling) — new exceptions appear without page refresh

UI states.

<!-- ui-states US-21.3 -->
surface: "Admin (React/BigDesign) prioritized exception queue — apps/admin/src/pages/exceptions/ExceptionQueueList.tsx: a sticky left FilterRail (sort Select + type/severity Checkboxes, lines 291-345) beside a main column of expandable exception rows (lines 386-472). Persona: Support / Ops. Driven by GET /api/v1/exceptions (fetchExceptions, lines 129-147), which the API aggregates from failed/requires_action charges (listExceptions, db.ts:1837-1866)."
idle:
  render: "Header: a 'Back to dashboard' subtle Button (lines 260-262) and a title + count subtitle (lines 264-278). Then a two-column Layout (lines 290-474). FilterRail: a Sort Select ('impact' | 'age', lines 297-310), a Type Checkbox group over the six ExceptionType values (TYPES, lines 54-61; rendered 319-326), and a Severity Checkbox group (high/medium/low, lines 335-342). Main column: one ExceptionRowEl per item (severity bar, type label, customer, impact, age, expand chevron, lines 389-433); expanding shows detected-at, proposed remediation, and the action row."
  primary_action: "Per row: 'Open subscription' (navigate to /subscriptions/{id}, lines 451-456) and 'Mark resolved' (opens the resolve Modal, lines 457-459); charge_failed rows additionally get 'Refund customer' (lines 460-464)."
loading:
  trigger: "GET /api/v1/exceptions on mount and on every filter/sort change (fetchExceptions, lines 129-147; useEffect deps [typeFilters, severityFilters, sortBy], lines 184-200). One-shot fetch only — no polling/websocket despite BRD §US-21.3 'Queue is real-time' (see gaps)."
  render: "While items === null && !error a plain 'Loading…' Text renders (merchantAdmin.exceptions.loading, lines 348-352); no skeleton or spinner."
error:
  surfaced_at: "Page-level BigDesign Message(type='warning', header merchantAdmin.exceptions.errorHeader) rendered above the Layout (lines 280-288); a resolve failure surfaces an inline Message(type='error') in the resolve Modal (lines 506-513)."
  render: "The thrown reason verbatim — 'HTTP <status>' from fetchExceptions (line 144), or 'NO_AUTH_TOKEN' (line 133)."
  recovery: "List error: the rail filters stay live, so toggling any filter re-fires the fetch (lines 184-200) — but there is no explicit 'Retry' control. Resolve error: resolving resets to false (line 232) to retry, or Cancel dismisses (lines 482-485)."
empty:
  render: "When items have loaded and visible.length === 0, the 'All clear' empty card renders: a green success-check circle plus merchantAdmin.exceptions.empty.title and .empty.body (lines 354-384); the subtitle also switches to merchantAdmin.exceptions.subtitle.empty (lines 269-277)."
edge_status:
  - status: "charge_failed exception"
    affordance: "Expand -> 'Open subscription', 'Mark resolved', and a type-specific 'Refund customer' Button (lines 451-464)."
  - status: "oos_at_renewal exception"
    affordance: "Expand -> 'Open subscription' + 'Mark resolved' only (no OOS-specific actions — see US-15.6); resolve requires a note (line 489)."
  - status: "reconciliation_drift exception"
    affordance: "Expand -> 'Open subscription' + 'Mark resolved'; the resolve Modal logs the resolution note via resolveException (exceptions.ts:44-57)."
  - status: "escalated"
    affordance: "Rows with status === 'escalated' show an extra 'escalated' Badge (merchantAdmin.exceptions.escalatedBadge, lines 398-403); the same open/resolve actions apply. BRD's 'Escalate' action is not implemented (see gaps)."
disabled_focus:
  keyboard: "The filter controls are real BigDesign components reachable in tab order: the Sort Select (lines 297-310, arrow-key selectable) and the Type/Severity Checkbox groups (lines 319-342). The in-row actions and the resolve Modal (Textarea + Cancel/Confirm) are real BigDesign Button/Textarea elements; the Modal(variant='dialog') traps focus and is Esc-dismissable (line 478)."
  guard: "The resolve confirm is disabled until the note is non-empty (line 489); resolve is non-destructive (it removes the queue item)."
  gaps: "(1) No real-time updates — BRD §US-21.3 requires websocket/polling; the component fetches once per mount/filter-change only (useEffect, lines 184-200). (2) No date-range filter — BRD UX note lists 'type, severity, date range' but the FilterRail (lines 291-345) has only sort + type + severity. (3) No type-grouping — AC1 requires items 'grouped by type', but the component renders a flat visible.map sorted by impact/age (lines 386-472); type is a filter, not a grouping. (4) No 'Escalate' action despite the BRD UX note ('Mark resolved' + 'Escalate'); only resolve exists. (5) Exception-type naming drift: BRD §US-21.3 data contract lists 'charge_failed_dunning'/'out_of_stock_at_renewal'/'processor_pm_invalidated'/'chargeback_received', but impl (component + db.ts:1800-1806) uses 'charge_failed'/'oos_at_renewal'/'pm_invalid'/'chargeback' — component and API agree, so doc-vs-impl divergence, not a runtime break. (6) The row expander is a div-onClick RowHeader (line 390), not a native button — a keyboard focus gap. (7) The list-load error is type='warning' with no explicit retry; loading is a bare text line with no skeleton."

US-21.4: Subscription list with search & filter

<!-- traceability:start:US-21.4 -->

Prototype: Subscriptions · Advanced Filters

<!-- traceability:end:US-21.4 -->

Phase: MVP · Persona: Merchant Admin / Support

As a Merchant Admin, I want to list and filter subscriptions by status, plan, customer, date ranges, so that I can find the subscription I need.

Acceptance criteria:

  • Given I open "Subscriptions," When it loads, Then I see a paginated table with columns: customer, plan, status, MRR, next charge, cycles completed.
  • Given I apply filters (status=past_due, plan=X), When results update, Then the URL is shareable.

UI states.

<!-- ui-states US-21.4 -->
surface: "Admin (React/BigDesign) subscriptions list — apps/admin/src/pages/subscriptions/SubscriptionsList.tsx. Persona: Merchant Admin / Support. A paginated, server-filtered table backed by GET /api/v1/subscriptions (fetchList, lines 66-94) with search, status & plan filters, a shareable URL, and sortable columns."
idle:
  render: "Header with a live result count (merchantAdmin.subscriptions.subtitleCount), an Export-to-CSV + 'New subscription' button row, a white filter bar (search Input + status Select + plan Select, lines 369-426), and a BigDesign Table (lines 553-682) with columns: select checkbox, customer (name+email), company, plan (name+product), status badge (statusBadge, lines 100-110), MRR, next charge, cycles completed, and a row 'View' button. Pagination footer with itemsPerPage [5,10,25,50] (lines 671-681)."
  primary_action: "Type in search (debounced 250ms, line 192) or change the status/plan Select to filter; click a sortable column header to sort (onSort, lines 665-669); click a row's 'View' to open /v2/subscriptions/{id} (line 656)."
loading:
  trigger: "GET /api/v1/subscriptions via fetchList() whenever search/status/plan/page/pageSize/sort change (useEffect, lines 189-224)."
  render: "There is NO dedicated loading indicator. On first load (data === null) the Table renders an empty shell (items = data?.items ?? [], line 226) — the empty card only appears once data !== null (line 700). On refetch the previous rows stay visible (setData only fires on success) with no skeleton/spinner (see gaps)."
error:
  surfaced_at: "Inline, page-level: a red BigDesign Text block above the filter bar (lines 358-362) carrying the raw error string thrown by fetchList (e.g. 'HTTP 500', 'NO_AUTH_TOKEN', line 92)."
  render: "The raw technical error string (no friendly mapping)."
  recovery: "Adjust any filter / search (re-runs the fetch useEffect) or reload the page — there is no explicit Retry button (see gaps)."
empty:
  render: "When data !== null and items.length === 0, a centered card renders with a bold header (merchantAdmin.subscriptions.empty.header, line 712) and body copy (merchantAdmin.subscriptions.empty.body) (lines 700-718)."
  cta: "No CTA inside the empty card; the 'New subscription' button stays available in the page header (line 351)."
inputs:
  - field: "search_query"
    control: "text"
    label: "'Search' Input with a SearchIcon, debounced 250ms (lines 372-384)"
    validation: "free text; resets page to 1 on change; matched server-side via GET /api/v1/subscriptions?q="
  - field: "status_filter"
    control: "select"
    label: "'Status' Select (lines 389-405)"
    allowed_values: "all | active | paused | past_due | cancelled (options, lines 398-404)"
  - field: "plan_filter"
    control: "select"
    label: "'Plan' Select (lines 410-423)"
    allowed_values: "all + dynamic plan ids from the list response (data.plans, line 228; options, lines 419-422)"
edge_status:
  - status: "past_due row (danger status badge)"
    affordance: "Filter status=past_due to isolate, then click the row 'View' to open the detail (/v2/subscriptions/{id}, line 656) where payment-update / retry-charge actions live."
  - status: "paused row (secondary status badge)"
    affordance: "Click 'View' to open the detail and resume the subscription; the status filter narrows the list to paused."
  - status: "cancelled row (secondary status badge)"
    affordance: "Click 'View' to open the detail to read history / reactivate; the status filter narrows the list to cancelled."
disabled_focus:
  keyboard: "Every control is a native-backed BigDesign component reachable in tab order: search Input, status Select, plan Select, 'Select all on this page' Button, per-row Checkbox, sortable column headers (BigDesign Table sortable, lines 662-670), pagination, and per-row 'View' Button. No div-onClick dead-ends. Filters reset page to 1 on change."
  gaps: "Row select checkboxes are rendered with label='' + hiddenLabel (line 567), giving them NO accessible name — a screen reader announces an unnamed checkbox. The list error (lines 358-362) is not an aria-live region."
gaps: "BUILT against the AC (paginated table + columns + shareable-URL filters all present: readUrlFilters lines 114-122, history.replaceState line 177). Minor divergences from the recon framing: (1) the status column is NOT sortable (no isSortable at line 614; SortableColumn omits 'status', line 63) though customer/plan/mrr/next_charge/cycles are; (2) no loading indicator; (3) row checkboxes lack an accessible name; (4) the list error is a raw technical string with no Retry."

US-21.5: Bulk actions on subscriptions

<!-- traceability:start:US-21.5 -->

Prototype: Bulk Actions

<!-- traceability:end:US-21.5 -->

Phase: P2 · Persona: Merchant Admin

As a Merchant Admin, I want to bulk-skip, bulk-pause, or bulk-cancel subscriptions matching a filter, so that I respond to business events (recall, OOS, holiday freeze) at scale.

Acceptance criteria:

  • Given I filter and select rows, When I click "Bulk action → Skip next," Then I confirm scope (selected vs. all matching filter) and the action is queued and progress-tracked.

UI states.

<!-- ui-states US-21.5 -->
surface: "Admin (React/BigDesign) subscriptions list bulk-action toolbar — apps/admin/src/pages/subscriptions/SubscriptionsList.tsx (toolbar, lines 456-550). Persona: Merchant Admin. A multi-select toolbar over the US-21.4 table that applies skip/pause/cancel to selected rows via POST /api/v1/admin/subscriptions/bulk (bulkSubscriptionAction)."
idle:
  render: "Above the table, a 'Select all on this page' Button shows whenever items exist (lines 432-451). Once selectedIds.size > 0, a blue toolbar (lines 456-550) appears with: '{n} selected', a 'Bulk action' Select (Skip next / Pause / Cancel), an 'Apply' primary Button, and a 'Clear selection' Button. Per-row Checkboxes drive selectedIds (toggleRow, line 238)."
  primary_action: "Check rows (or 'Select all on this page' via toggleAllVisible, line 247), pick an action in the Select, then click 'Apply' (applyBulkAction, lines 259-294)."
loading:
  trigger: "POST /api/v1/admin/subscriptions/bulk via bulkSubscriptionAction({subscription_ids, action}) on Apply (lines 266-269)."
  render: "The 'Apply' Button shows its isLoading spinner (bulkBusy, line 512). The action Select and 'Clear selection' Button are NOT disabled during the in-flight POST (see gaps)."
error:
  surfaced_at: "On a thrown bulk request the error is set page-level and rendered as the red Text at the TOP of the page (setError, line 290 -> Text, lines 358-362), far above the toolbar (line 456) — disassociated from the control (north-star: inline in the toolbar; see gaps)."
  render: "The raw error string; partial failures instead render an inline '{succeeded} succeeded, {failed} failed' Small in the toolbar (bulkResult, lines 535-548)."
  recovery: "Re-select the rows that remain in the list and press Apply again. Failed row ids are tracked (failedIds, line 273) but are NOT shown to the merchant (see gaps)."
empty:
  render: "The toolbar is conditionally rendered — with no rows selected (selectedIds.size === 0) it is absent and the surface is dormant. The underlying list-empty state ('No subscriptions') is owned by US-21.4 (lines 700-718)."
  cta: "n/a — the bulk surface only exists once one or more rows are checked."
inputs:
  - field: "bulk_action"
    control: "select"
    label: "'Bulk action' Select (lines 477-507)"
    allowed_values: "skip | pause | cancel (BulkAction; options, lines 484-506)"
edge_status:
  - status: "Partial failure (some subscriptions succeeded, some failed)"
    affordance: "The toolbar shows '{n} succeeded, {m} failed' (lines 535-548); the merchant re-selects the still-listed failed rows and re-applies. North-star also lists which ids failed."
disabled_focus:
  keyboard: "The 'Bulk action' Select, 'Apply' (disabled until an action is chosen — disabled={!bulkAction}, line 513), 'Clear selection', per-row Checkboxes, and 'Select all on this page' Button are all native-backed BigDesign controls reachable in tab order. No div-onClick dead-ends."
  guard: "Bulk Cancel is destructive/irreversible but fires immediately on Apply with no are-you-sure or typed-confirm (applyBulkAction, line 259); north-star requires a scope-confirm step before any destructive bulk action (see gaps)."
  gaps: "During the in-flight POST only Apply disables (isLoading); the action Select and Clear remain enabled. The result message (lines 535-548) is not an aria-live region, so SR users get no announcement of the succeeded/failed outcome."
gaps: "PARTIAL. (1) Scope is limited to hand-checked rows or 'select all on THIS page' (toggleAllVisible, line 247) — the BRD AC requires confirming scope 'selected vs. all matching filter', which is not offered (no all-matching-filter scope, no confirm dialog). (2) The action is synchronous (single POST /bulk via bulkSubscriptionAction) — the AC wants it 'queued and progress-tracked'; the only feedback is the Apply spinner. (3) Destructive bulk Cancel has no confirm guard. (4) Failure is rendered page-level (disassociated from the toolbar) and failed row ids are hidden."

US-21.6: Subscription detail view

<!-- traceability:start:US-21.6 -->

Prototype: Subscription Detail

<!-- traceability:end:US-21.6 -->

Phase: MVP · Priority: P0 · Effort: L · Persona: Merchant Admin / Support

As a Merchant Admin or Support, I want a detail view of any subscription showing all state, history, payment, addresses, and exception info, so that I have full context without switching screens.

Acceptance criteria:

  • Given I click a subscription, When the detail opens, Then I see: state, next charge, payment method health, shipping/billing addresses, charge history with links to BC orders, event timeline, manual-action buttons.

UX notes.

  • Surface: /stores/[storeHash]/subscriptions/{id}
  • Layout:
    • Header: customer name, subscription ID, status pill, "Actions" dropdown
    • 3-column row: current state (plan, next charge, cadence), payment method health, addresses
    • Charge history: table with cycle #, date, amount, status, BC order link
    • Event timeline: reverse-chronological, filterable by actor/type
    • Exception strip (if any): red banner at top with CTA
  • Actions dropdown: Skip next, Pause, Cancel, Force charge, Force refund, Add credit, Add note, Impersonate

Data contract.

  • Our API: GET /api/v1/subscriptions/{id}?include=charges,events,exceptions
  • Returns denormalized view for single API call

Success metrics.

  • Functional (target): detail page loads in < 1s P95 with 100+ cycle history
  • Operational (target): information architecture tested via task-based user testing (support resolves mock scenario in ≤ 3 min)

Non-functional.

  • Keyboard-nav: Tab order from header → actions → sections; escape closes action dropdown
  • Event timeline virtualized for long histories
<!-- normative-requirements US-21.6 - artifact: listExceptionsForSubscription kind: endpoint fit: include=exceptions param — handler calls listExceptionsForSubscription to join exception records into the subscription detail response closes: grep:apps/api/src - artifact: listEventsForSubscription kind: endpoint fit: include=events param — handler calls listEventsForSubscription to join the event timeline into the subscription detail response closes: grep:apps/api/src -->

UI states.

<!-- ui-states US-21.6 -->
surface: "Admin (React/BigDesign) subscription detail — apps/admin/src/pages/subscriptions/SubscriptionAdminDetail.tsx: identity card (customer/plan/product), a state card (status Badge via statusBadge, line 378; current period; next charge), an 'Actions' Panel (force-retry, cancel-on-behalf, force-cancel), Scheduled-charges + Charge-history Panels (history rows carry BC order deep-links via bcAdminOrderUrl, line 663), an Activity timeline, a merchant-note Panel, and an embedded CsActionsPanel (apps/admin/src/pages/cs-tools/CsActionsPanel.tsx: force-charge / force-refund / 'View as subscriber' impersonation). Persona: Merchant Admin / Support."
idle:
  render: "State card shows the status Badge, current-period range, and next-charge date; the Actions Panel shows 'Force retry' (failed-only), 'Cancel on behalf', and 'Force cancel' Buttons; CsActionsPanel shows 'Force charge now', 'Force refund', and 'View as subscriber'. Charge-history rows link to BC orders (bcAdminOrderUrl, line 663). Per the AC, payment-method-health and shipping/billing-address sections are absent (see gaps)."
  primary_action: "Manual CS actions: force-retry, cancel (modal-confirmed), force-charge / force-refund (reason-required modals), and view-as-subscriber impersonation (opens /cs-tools/impersonate in a new tab)."
loading:
  render: "The whole detail gates on the initial fetch — while data is null a single 'Loading…' Text renders (line 366-372). Each destructive action disables its Button via actionPending while the POST is in flight; per-charge retry uses isLoading."
error:
  surfaced_at: "Two layers: a page-level Message(type='warning') replaces the detail when the detail fetch fails (errorHeader, line 357-361); a dismissible page-level Message(type='error') reports action failures (actionError, line 403-412). CsActionsPanel surfaces force-charge/refund errors inside their modals."
  recovery: "Detail-fetch failure: reload the page (no inline retry). Action failure: actionPending resets in finally and the Message is dismissible, so the rep re-presses the action or adjusts and retries."
empty:
  render: "Single-subscription detail, never a list — sub-panels carry their own empty copy: 'no scheduled charges' (scheduled.empty, line 590), 'no charge history' (history.empty, line 625), and 'no activity' (activity.empty, line 734). The page is never a blank pane."
edge_status:
  - status: "active / past_due / trialing"
    affordance: "Full action set: force-charge is enabled (CsActionsPanel isChargeable, line 211); force-retry enables only when a charge has failed (failedExists, line 558)."
  - status: "cancelled (terminal)"
    affordance: "'Cancel on behalf' and 'Force cancel' disable (isCancelled, line 566/574) and force-charge disables (line 385); 'Force refund' stays available so the rep can still refund a past charge."
  - status: "no failed charges"
    affordance: "'Force retry' disabled (line 558) and per-charge Retry controls absent — nothing to retry; the rest of the action set stays available."
  - status: "destructive action succeeded"
    affordance: "A success Message renders (setToast, line 398-402) and the detail reloads (reload, line 783); the Message is manually dismissible — north-star wraps it in aria-live so screen-reader reps hear the outcome (see gaps)."
inputs: []
disabled_focus:
  keyboard: "All actions are real BigDesign <Button>s reachable in tab order and Enter/Space-activatable; destructive actions route through BigDesign <Modal> dialogs (cancel reason Textarea, force-charge/refund reason fields) that trap focus while open and restore on close. BC order links are real <a href> anchors. Status-gated Buttons use native disabled, removing them from tab order."
  focus_move: "On a successful destructive action the success Message and reloaded detail render but focus is NOT moved to the Message and it carries no aria-live / role=alert (setToast, line 398-402; mirrored in CsActionsPanel setToast, line 347) — a keyboard/screen-reader rep gets no announcement. North-star: aria-live=polite or role=status."
gaps: "The AC enumerates 'state, next charge, payment method health, shipping/billing addresses, charge history with links to BC orders, event timeline, manual-action buttons.' Built: state, next charge, charge history + BC order links, event timeline, action buttons. MISSING: (1) payment-method-health section — no card brand / last4 / expiry / health indicator anywhere in the state card (line 522-548); (2) shipping/billing addresses — absent from the identity card (line 419-505). A11y gap: destructive-action success toasts have no aria-live so the outcome is not announced (line 398-402)."

US-21.7: Export subscription data (CSV)

<!-- traceability:start:US-21.7 -->

Prototype: CSV Export

<!-- traceability:end:US-21.7 -->

Phase: P2 · Persona: Merchant Admin

As a Merchant Admin, I want to export a filtered subscription list to CSV, so that I can analyze in other tools.

Acceptance criteria:

  • Given I click "Export" on a filtered list, When the export builds, Then a background job generates a CSV (stored in Vercel Blob) and I'm notified when it's ready.

UI states.

<!-- ui-states US-21.7 -->
surface: "Admin (React/BigDesign) subscriptions list CSV export — apps/admin/src/pages/subscriptions/SubscriptionsList.tsx ('Export to CSV' Button, lines 340-350; triggerCsvExport, lines 296-321). Persona: Merchant Admin. Exports the current filtered list via GET /api/v1/admin/subscriptions/export?format=csv (fetchCsvExport)."
idle:
  render: "An 'Export to CSV' secondary Button in the list header (lines 340-350, data-testid subscriptions-export-csv-btn), always available regardless of result count. It carries the current search/status/plan filters so the CSV matches the on-screen list."
  primary_action: "Click 'Export to CSV' -> triggerCsvExport() fetches the CSV as a Blob and synthesizes a client-side download via an <a download> element (lines 307-315)."
loading:
  trigger: "GET /api/v1/admin/subscriptions/export?format=csv via fetchCsvExport({q,status,plan_id}) (lines 300-304)."
  render: "The Export Button shows its isLoading spinner (exportBusy, line 342) for the duration of the synchronous fetch; there is no progress bar and no per-row count (see gaps)."
error:
  surfaced_at: "Inline, page-level: authedFetch throws on a non-2xx response and triggerCsvExport's catch sets the error (line 317), rendered as the red Text below the header (lines 358-362)."
  render: "The raw error string (e.g. 'HTTP 500: ...')."
  recovery: "Click 'Export to CSV' again to retry once the underlying issue clears; the filters are unchanged and the Button re-enables in the finally block (lines 318-319)."
empty:
  render: "Export is not gated on result count — on an empty filtered list it produces a header-only CSV; there is no 'nothing to export' guard or message (see gaps)."
  cta: "Adjust the list filters to widen the result set before exporting."
edge_status:
  - status: "Export request failed (timeout / 5xx)"
    affordance: "The page-level error Text shows the failure; the merchant clicks 'Export to CSV' again to retry (the Button re-enables once exportBusy clears, lines 318-319)."
disabled_focus:
  keyboard: "The 'Export to CSV' Button is a native-backed BigDesign Button reachable in tab order and activatable with Enter/Space; while exporting its isLoading state disables it, preventing a double-submit."
  gaps: "For a large export the only feedback is the button spinner — no progress indicator, no time estimate, and no cancel. The error surfaces page-level (lines 358-362) rather than adjacent to the header Button."
gaps: "PARTIAL. The export is a SYNCHRONOUS streaming download (fetchCsvExport -> resp.blob() -> client-side <a download>, lines 307-315), not the BRD AC's design: a BACKGROUND JOB that writes the CSV to Vercel Blob and NOTIFIES the merchant when ready. No job, no Blob storage, no ready-notification; a large export blocks on the request and can time out with only a raw error."

US-21.8: Analytics beyond KPIs (cohort, LTV)

<!-- traceability:start:US-21.8 -->

Prototype: Cohort & LTV

<!-- traceability:end:US-21.8 -->

Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin

As a Merchant Admin, I want cohort retention charts and LTV projections, so that I understand subscriber economics.

Acceptance criteria:

  • Given I open Analytics, When the cohort tab loads, Then I see a heatmap of acquisition cohort × cycle (% retained).
  • Given I open LTV, When it loads, Then I see estimated 12/24-month LTV per plan using cohort-derived survival curves.

UI states.

<!-- ui-states US-21.8 -->
surface: "Admin (React/BigDesign) analytics page — apps/admin/src/pages/analytics/AnalyticsPage.tsx. Persona: Merchant Admin. Two read-only sections: a cohort-retention heatmap (CohortHeatmap, lines 96-156) backed by GET /api/v1/admin/analytics/cohort, and an LTV-projections table (LtvProjections, lines 162-238) backed by GET /api/v1/admin/analytics/ltv. Loaded independently."
idle:
  render: "Title + subtitle, then two cards. Cohort: a semantic heatmap table (HeatTable, aria-label line 112) with cohort rows x cycle columns; each HeatCell is color-scaled green->red by retention (retentionColor, lines 39-43) and shows formatPct retained (lines 136-143). LTV: a BigDesign Table (lines 176-229) with columns plan, avg charge, monthly survival rate, 12-month LTV (ltv_12m_cents), 24-month LTV (ltv_24m_cents), and charge sample size. Each section shows a 'generated_at' timestamp."
  primary_action: "n/a — read-only analytics; no actions. Per-cell retention context is shown via a hover title (line 139)."
loading:
  trigger: "GET /api/v1/admin/analytics/cohort and /ltv via getAnalyticsCohort() + getAnalyticsLtv() on mount (useEffect, lines 251-263) — two independent requests."
  render: "Each section independently shows a 'Loading...' Text while its data is null and no error has been set (cohort, lines 296-300; ltv, lines 324-328). No skeleton."
error:
  surfaced_at: "Inline, per-section: a BigDesign Message(type='error') inside the relevant card — cohort error (cohortError) at lines 289-295, LTV error (ltvError) at lines 317-322 — each with a header and the raw error string. One section can error while the other renders."
  render: "The section error header plus the raw error.message string."
  recovery: "Reload the page — neither section offers an in-app Retry control (see gaps)."
empty:
  render: "When the cohort response has no rows, CohortHeatmap renders a Message(type='info') with merchantAdmin.analytics.cohort.empty (lines 101-108); when LTV has no plans, LtvProjections renders a Message(type='info') with merchantAdmin.analytics.ltv.empty (lines 165-172)."
  cta: "n/a — informational; the empty copy explains that retention/LTV populate once subscriptions complete cycles."
disabled_focus:
  keyboard: "The page has NO actionable controls — it is a read-only analytics view. The cohort heatmap is a semantic <table> carrying an aria-label (line 112) and the LTV table is a BigDesign Table (stickyHeader, line 228); both are reachable and scrollable via the keyboard. No div-onClick dead-ends exist."
  gaps: "Per-cell cohort detail (retained % at cycle N) is exposed ONLY via a mouse-hover title (line 139) — not keyboard- or screen-reader-reachable. The per-section error/loading messages are not aria-live regions."
gaps: "BUILT against the AC (cohort retention heatmap + 12/24-month per-plan LTV both present). Honest defects: (1) cells beyond a cohort's age render 0% (retention_by_cycle[i] ?? 0, line 137) — indistinguishable from a true 0% retention, so a young cohort looks like total churn at later cycles; (2) low-confidence LTV rows are only signalled by the charge_sample_size column, with no visual de-emphasis; (3) per-cell detail is mouse-hover-only; (4) load errors have no Retry."