← All epicsBRD.md §9 · lines 8363–8863

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 22 — Manual subscription creation & CS tools (derived view)

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

  • Stories (9): US-22.1, US-22.2, US-22.3, US-22.4, US-22.5, US-22.6, US-22.7, US-22.8, US-22.9
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 22 — Manual subscription creation & CS tools

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

Prototype: Manual Create · Impersonate · Force Actions · Notes & CSV · Allotment Grants · Customer Lookup (Ext) · Bulk CSV Create

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

Value: Support can solve unique situations without hacking the database.

US-22.1: Create subscription manually

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

Prototype: Manual Create

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

Phase: P2 · Priority: P1 · Effort: L · Persona: Support / Ops

As Support / Ops, I want to create a subscription for a customer directly in admin (bypassing storefront), so that I can onboard phone orders, recovered carts, or comped accounts.

Acceptance criteria:

  • Given I click "New subscription," When I pick customer + plan + PM + schedule, Then the subscription is created with an Event marking the manual origin.

UI states.

<!-- ui-states US-22.1 -->
surface: "Admin (React/BigDesign) CS-tools — manual subscription creation. Single-page form rendered by apps/admin/src/pages/subscriptions/SubscriptionCreate.tsx (header 'Epic-22 US-22.1'). Persona: Support / Ops. Resolves customer by email, picks the customer's most-recent active payment method, POSTs /api/v1/admin/subscriptions/create; on success navigates to /subscriptions/{id}."
idle:
  render: "Page titled 'Create subscription manually' with subtitle, then a vertical FormGroup stack: Customer email (BigDesign Input type=email, placeholder shopper@example.com), Plan (BigDesign Select, pre-selected to the first active plan), Start date (BigDesign Input type=date, defaulted to today, min=today), Charge immediately (BigDesign Checkbox, unchecked). A footer Small note explains the manual-origin subscription.created event."
  primary_action: "'Create subscription' primary Button, disabled until customer email matches a basic email shape, a plan is selected, and a start date is set (valid gate, line 110); secondary 'Cancel' Button navigates to /subscriptions."
loading:
  plans: "On mount fetchActivePlans() runs; while plans === null the Plan Select is disabled with no options and no spinner (line 173). No skeleton is shown for the plan load."
  submit: "While submitting === true the primary Button shows its isLoading spinner with label 'Creating…' (submitBtnBusy) and every Input/Select/Checkbox plus the Cancel Button are disabled (lines 162/173/188/198/214/219)."
error:
  surfaced_at: "Two inline BigDesign Message(type='error') banners, never toasts. Plan-load failure renders a 'Couldn't load plans' Message above the form (lines 145-153). Submit failure renders a 'Couldn't create subscription' Message below the fields and above the buttons (lines 203-211), carrying the API detail string (e.g. customer_has_no_payment_method, plan_not_active, 'Customer not found')."
  recovery: "On submit failure setSubmitting(false) re-enables the still-populated form (line 130) so the rep edits and resubmits; for customer_has_no_payment_method the message text directs onboarding the PM via the storefront or subscriber portal first. Plan-load failure exposes no in-app retry control — the rep must reload the page."
empty:
  render: "When fetchActivePlans() returns zero active plans, plans === [] and planId stays '' (the rows.length>0 guard at line 99 never fires). The Plan Select renders enabled with an empty option list and the primary Button stays disabled (valid requires planId.length>0). NO dedicated empty-state copy is shown — only the static plan help text 'Only active plans are shown. Create a plan via Products → product → Configure subscription…' points at the fix."
inputs:
  - field: "customer_email"
    control: "email"
    label: "'Customer email' — BigDesign Input label + description via FormGroup (lines 156-164)"
    validation: "non-empty and matches a basic email shape (line 112); resolved server-side by case-insensitive LOWER(email) match scoped to store_hash."
  - field: "plan_id"
    control: "select"
    label: "'Plan' — BigDesign Select label + description (lines 168-178)"
    allowed_values: "active plans only (data.plans filtered to status==='active', line 37); option.value = plan.id, option.content = plan.name."
  - field: "start_date"
    control: "date"
    label: "'Start date' — BigDesign Input type=date, label + description, min=today (lines 182-190)"
  - field: "charge_immediately"
    control: "checkbox"
    label: "'Charge immediately' — BigDesign Checkbox, default unchecked (lines 194-200)"
edge_status:
  - status: "customer_not_found"
    surfaced_at: "submit error Message — API 404 'Customer not found' (subscriptions.ts:838-840)"
    affordance: "form stays populated and re-enabled; rep corrects the email and resubmits, or onboards the BC customer first."
  - status: "plan_not_active"
    surfaced_at: "submit error Message — API 412 plan_not_active (subscriptions.ts:857-863)"
    affordance: "rep picks a different plan from the Select, which already lists only active plans, then resubmits."
  - status: "customer_has_no_payment_method"
    surfaced_at: "submit error Message — API 412 (subscriptions.ts:874-878)"
    affordance: "message text instructs onboarding a payment method via the storefront/subscriber portal first; rep retries the create once the PM exists."
  - status: "validation_error"
    surfaced_at: "submit error Message — API 400 invalid_fields / json_parse_error (subscriptions.ts:822/826)"
    affordance: "rep corrects the offending field (email / plan / date) and resubmits; the client-side valid gate already blocks most malformed input."
disabled_focus:
  keyboard: "All four controls are real BigDesign components (Input/Select/Checkbox/Button) wrapping native focusable elements — no div-onClick dead-ends. Each field sits in a FormGroup with a label prop, so every control is programmatically labeled. Tab order follows DOM source order: customer email → plan select → start date → charge checkbox → 'Create subscription' → 'Cancel'. Native disabled removes controls from the tab order while submitting (and removes the Plan Select while plans load). The primary Button is reachable via Tab and activatable with Enter or Space."
  gaps: "NO focus management on dynamic reveal: the conditional error Messages (lines 145-153, 203-211) are inserted with no aria-live region, no role='alert', and no programmatic focus move — the file imports no useRef and calls no .focus() anywhere (imports at lines 14-15). A keyboard or screen-reader rep gets no announcement when a create fails; focus stays on the re-enabled primary Button. Focus is likewise not moved to the Plan Select when plans finish loading."

UX notes.

  • Surface: global "+ New subscription" button in admin
  • Form wizard: Customer (search/create BC customer) → Plan (filtered to eligible) → PM (existing or new-collection flow via processor-hosted form) → Start date + anchor → Review → Create
  • Merchant can optionally charge cycle 0 immediately or start without upfront charge

Data contract.

  • Our API: POST /api/v1/subscriptions with full payload
  • Server: validates customer, plan, PM ownership; creates subscription; optionally enqueues charge
  • Events: subscription.created with source: manual, actor: support:{user_id}

Success metrics.

  • Functional: manually-created subscriptions behave identically to storefront-created
  • Product (target): customer service cases resolvable without engineering intervention ≥ 95%

Dependencies.

  • Processor adapter (PM collection)
  • BC customer create/search

US-22.2: Customer lookup from BC App Extension

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

Prototype: Customer Lookup (Ext)

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

Phase: MVP · Persona: Support / Ops

As Support / Ops viewing a customer in BC admin, I want a "Subscriptions" panel showing all their subscriptions, so that I can act without switching tools.

Acceptance criteria:

  • Given I open a BC customer, When the extension panel loads, Then it lists all the customer's subscriptions with deep-links to our admin.

UI states.

<!-- ui-states US-22.2 -->
surface: "NOT YET BUILT — forward-looking contract. BC App Extension CUSTOMERS panel (React/BigDesign) embedded in BC Admin customer detail view, showing all subscriptions for the customer currently open in BC Admin, with deep-links into our subscription admin app. Persona: Support / Ops."
idle:
  render: "Panel heading 'Subscriptions' followed by a count badge (e.g., '3 subscriptions'). Each subscription renders as a BigDesign Table row: status Badge (active / paused / past_due / cancelled), plan name, billing interval, next charge date formatted as 'Jun 29, 2026' (or '—' if cancelled), and a 'View →' Link that opens the subscription's admin detail page (/subscriptions/{id}) in a new tab. Rows are sorted active-first, then paused, then past_due, then cancelled."
  primary_action: "'View →' link on each row deep-links to /subscriptions/{id} in our subscription admin, opening in a new tab so the BC Admin customer page remains open."
loading:
  trigger: "GET /api/v1/admin/customers/{customerId}/subscriptions — fires on panel mount once the BC customer context is available from the App Extension."
  render: "A BigDesign ProgressCircle (size='small') centered in the panel body with a visually hidden 'Loading subscriptions…' label for screen readers. No Table rows are rendered while the request is in flight."
error:
  surfaced_at: "Inline BigDesign Message (type='error') occupying the panel body, replacing the table — never a vanishing toast. Visible for the duration of the panel session until the rep retries."
  render: "Message heading 'Couldn't load subscriptions' with body text 'An error occurred while fetching this customer's subscriptions. Please try again.' A 'Retry' Button (variant='secondary') sits beneath the message text."
  recovery: "The 'Retry' Button re-fires the GET fetch; while the request is in flight the error Message is replaced by the ProgressCircle. On success, focus moves to the panel heading (h2 'Subscriptions') so keyboard users re-enter the list without losing context."
empty:
  render: "BigDesign Panel body with centered Text copy 'No subscriptions found for this customer.' and a 'Create subscription' Link that deep-links to /subscriptions/create (the manual-create flow, US-22.1) in a new tab. No customer context is pre-filled; the rep enters the customer email in that form."
  cta: "'Create subscription' — opens the manual-create flow (US-22.1) in our subscription admin in a new tab."
edge_status:
  - status: "has_past_due — one or more subscriptions are past_due (a charge has failed)"
    affordance: "'View →' deep-link on the past_due row opens /subscriptions/{id} in our admin where the rep can retry the charge or prompt the subscriber to update their payment method."
  - status: "has_paused — one or more subscriptions are paused"
    affordance: "'View →' deep-link opens /subscriptions/{id} in our admin where the rep can resume the subscription or investigate the pause reason."
  - status: "all_cancelled — every subscription for this customer is cancelled"
    affordance: "Cancelled rows remain listed with a 'cancelled' Badge and 'View →' deep-link to the read-only admin detail; a 'Create subscription' Link (same as empty.cta) appears beneath the table so the rep can start a fresh subscription if needed."
inputs: []
disabled_focus:
  keyboard: "Every 'View →' affordance is a real <a> element (not a div-onClick) opening in a new tab — reachable via Tab, activatable via Enter. The 'Retry' Button (error state) and 'Create subscription' Link (empty and all_cancelled states) are real focusable elements in tab order; no div-onClick dead-ends. On initial load completion, focus moves to the panel heading (h2 'Subscriptions') so keyboard users enter the list without disorientation. After a successful Retry, focus likewise lands on the panel heading before the first Table row. No focus trap is implemented — the browser manages iframe focus boundaries natively for the App Extension embed."

US-22.3: Impersonate subscriber (view-only)

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

Prototype: Impersonate

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

Phase: P1 (pulled forward 2026-05-16) · Persona: Support / Ops

As Support / Ops troubleshooting, I want to view a subscriber's portal as they see it, so that I diagnose UX issues.

Acceptance criteria:

  • Given I click "View as subscriber," When impersonation starts, Then I see the portal in read-only mode and my actions are logged as actor=support:<user_id>, impersonated=<customer_id>.

UI states.

<!-- ui-states US-22.3 -->
surface: "admin · React/BigDesign — the 'View as subscriber' (view-only impersonation) surface (apps/admin/src/pages/cs-tools/CsImpersonate.tsx), routed at /cs-tools/impersonate (App.tsx:117); also launched from the 'CS override actions' panel embedded in the subscription detail page via a 'View as subscriber' button (CsActionsPanel.tsx:403-414, rendered by SubscriptionAdminDetail.tsx:779) which opens the route in a new tab pre-seeded with ?sub=<id>."
idle:
  render: "A '← Back to subscriptions' subtle Button, an H1 'View as subscriber', a secondary Text gloss ('All actions in this session are logged with your operator ID'), then a Panel 'Start impersonation session' holding a Form > FormGroup > Input labeled 'Subscription ID' (placeholder 'e.g. sub_abc123', description 'The subscription to view from the subscriber's perspective.') and a primary 'View as subscriber' Button; below it a static 'How it works' Panel documenting read-only mode, the audit trail, and the 15-minute TTL (CsImpersonate.tsx:101-206). When launched from the detail panel the Subscription ID is pre-filled from the ?sub= query param (CsImpersonate.tsx:69-71)."
  primary_action: "Enter a Subscription ID and activate 'View as subscriber' → POST /api/v1/admin/cs/impersonate { subscription_id }, then window.open(portal_url) opens the read-only subscriber portal in a new tab (CsImpersonate.tsx:76-91, 48-64)."
loading:
  trigger: "POST /api/v1/admin/cs/impersonate while the `busy` flag is true (handleStart → mintImpersonationSession, CsImpersonate.tsx:76-91)."
  render: "The 'View as subscriber' Button enters BigDesign isLoading (spinner, non-activatable) and the 'Subscription ID' Input is disabled, so the in-flight mint cannot be double-submitted (CsImpersonate.tsx:119-136). The page chrome stays mounted — there is no skeleton; only the two form controls disable."
error:
  surfaced_at: "Inline, in a BigDesign Message (type='error', header 'Failed to start impersonation') rendered below the Form inside the 'Start impersonation session' Panel (CsImpersonate.tsx:140-149), scoped to this page and dismissible via the Message onClose (X) — never a toast that vanishes."
  render: "The thrown reason verbatim — e.g. 'HTTP 404: <body>', 'HTTP 403', or 'NO_AUTH_TOKEN' (CsImpersonate.tsx:48-64, 86-88)."
  recovery: "The 'Subscription ID' Input and 'View as subscriber' Button stay operable so the rep can correct the ID and re-submit; a successful retry clears the error Message and renders the success Message plus the 'Session details' Panel (CsImpersonate.tsx:79-91, 151-188)."
empty:
  render: "Before any session is minted (`result === null`) neither the success Message nor the 'Session details' Panel render — the at-rest surface shows only the idle Form and the static 'How it works' Panel (CsImpersonate.tsx:151, 191-206). This is a single-action surface, not a list, so it is never a blank pane: the form and the read-only/audit/TTL guidance are always present."
  cta: "'View as subscriber' — submitting the form mints the first session and populates the 'Session details' Panel (CsImpersonate.tsx:129-136, 160-186)."
inputs:
  - field: "subscription_id"
    control: "text"
    allowed_values: "n/a — free-text subscription identifier (e.g. sub_abc123), not an enumerable domain."
    note: "Real BigDesign <Input> WITH a programmatic `label` prop 'Subscription ID' and a `description` (CsImpersonate.tsx:119-126) — a properly labeled field inside a FormGroup, not a bare unlabeled <input>. Client-side guard: a blank/whitespace-only value keeps the submit Button disabled (CsImpersonate.tsx:77, 133)."
edge_status:
  - status: "active — live read-only impersonation session (within the 15-minute TTL)."
    badge: "Expires: <time> (<m>m <s>s remaining)"
    affordance: "After a successful mint, the success Message 'Impersonation session started' and a 'Session details' Panel render the Session ID, a Portal URL <a href> link, and the expiry countdown; the rep can re-open the read-only portal in a new tab via that Portal URL link (CsImpersonate.tsx:151-188, 166-174). Per the 'How it works' Panel the portal renders normally but all mutation buttons (cancel/pause/skip) are disabled and the visit is logged as actor=support (CsImpersonate.tsx:191-201)."
  - status: "expired — session TTL (15 minutes) has elapsed."
    badge: "expired"
    affordance: "expiresIn() returns 'expired' once expires_at passes and the remaining-time readout shows 'expired' (CsImpersonate.tsx:93-99, 176-180); to continue, the rep re-submits 'View as subscriber' to mint a fresh session + portal_url, since the expired portal_url no longer authorizes the read-only view (CsImpersonate.tsx:202-205)."
disabled_focus:
  keyboard: "Every control is a real, keyboard-reachable element in tab order: the '← Back to subscriptions' BigDesign <Button variant='subtle'> (CsImpersonate.tsx:103-109), the 'Subscription ID' BigDesign <Input> (real labeled <input>, CsImpersonate.tsx:119-126), the 'View as subscriber' primary BigDesign <Button> (real <button> with a visible focus ring, Enter/Space-activatable, CsImpersonate.tsx:129-136), and the result Panel's Portal URL <a href> (a real anchor, Tab-reachable, CsImpersonate.tsx:166-174). No control is a div-onClick or an unreachable affordance; the embedded launcher in CsActionsPanel.tsx:403-414 is likewise a real BigDesign <Button>."
  focus_move: "The 'View as subscriber' trigger is disabled while the ID is blank or `busy` (CsImpersonate.tsx:133). On success the 'Session details' Panel and success Message render dynamically but focus is NOT moved to them and they carry no aria-live, and the portal opens in a background new tab via window.open (CsImpersonate.tsx:85, 151-188) — so the rep gets no programmatic focus or announcement of the result. NORTH-STAR: move focus to (or wrap in aria-live='polite') the success Message / 'Session details' Panel, and the error Message (CsImpersonate.tsx:140-149), so the outcome is announced (see gap notes)."
  guard: "This is a view-only, non-destructive action (it mints a read-only session and mutates nothing), so no typed-confirm is required; the in-flight mint is double-submit-guarded by the isLoading Button plus the disabled Input (CsImpersonate.tsx:124, 132-133). Every session action is audit-logged as actor=support with the operator ID per the AC and the 'How it works' Panel (CsImpersonate.tsx:181-185, 197-201)."

US-22.4: Bulk subscription creation via CSV

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

Prototype: Bulk CSV Create

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

Phase: P1 (pulled forward 2026-05-16) · Persona: Support / Ops

As Support / Ops, I want to create many subscriptions in bulk via CSV upload (e.g., B2B onboarding), so that I don't create them one by one.

Acceptance criteria:

  • Given I upload a valid CSV, When the job processes, Then subscriptions are created in a staging state and activated on confirmation.

UI states.

<!-- ui-states US-22.4 -->
surface: "Admin CS-tools page, route `/cs-tools/bulk-create` (App.tsx:118 -> CsBulkCreate.tsx). React + @bigcommerce/big-design (Box/Panel/Button/Message/Badge/Flex). Support/Ops persona. Route is registered but has NO SideNav entry (App.tsx:111-116) so it is reached by direct URL. Two-phase flow: upload+preview, then confirm-and-create."
idle: "Initial render before any file is chosen: subtle `Back to subscriptions` button (line 172), H1 `Bulk subscription create (CSV)` (line 179), a `CSV format` Panel with required/optional column copy and a `Download template CSV` button (line 193), and an `Upload CSV` Panel holding the native file input (line 216) and a primary `Upload and preview` button that is disabled while csvText is empty (line 233). No preview, no error, no confirm result."
loading:
  preview: "uploading=true -> primary `Upload and preview` button renders BigDesign isLoading spinner and is disabled (lines 232-233); prior preview/error/result are cleared first (lines 124-126). No skeleton or overlay; the in-button spinner is the only loading affordance."
  confirm: "confirming=true -> `Confirm - create N subscriptions` button renders isLoading and both it and the `Upload a different file` reset button are disabled (lines 328-335)."
error:
  surfaced_at: "Universal path: inline BigDesign `<Message type=\"error\">` at lines 241-250 (marginTop medium, below the action row, inside the `{!confirmResult}` Upload/Preview Panel). Both preview errors (preview is null on failure) AND confirm errors (confirmResult stays null on failure) surface here. It is a persistent inline message, not a toast; it stays until dismissed or reset. The second error Message at lines 347-354 (inside the `{confirmResult}` `Import complete` Panel) is a defensive branch that is effectively dead - confirmResult is set only on success and no post-success action sets `error` (see gapNotes)."
  recovery: "The Message exposes an onClose (X) that clears `error` (line 247). The triggering control stays mounted (on a preview error `preview` is null so the upload form remains; on a confirm error `confirmResult` is null so the preview panel with the `Confirm` button remains), so the user re-presses `Upload and preview` / `Confirm` to retry, or presses `Upload a different file` / `Start another import` to reset via handleReset (line 161). Error text is the raw `HTTP <status>: <body-slice>` thrown by authedPost (line 91), or `NO_AUTH_TOKEN` verbatim when the session token is missing (line 80)."
empty:
  render: "When a parsed CSV yields no creatable rows (preview.valid_rows === 0, e.g. header-only or all-rows-invalid) the Preview panel still renders fully: the summary bar reads Total `{total_rows}` / Valid `0` / Errors `{error_rows}` (lines 255-270); the bordered scroll `<Box>` (line 285) renders empty when preview.preview is `[]`; the primary `Confirm` button is suppressed because `preview.valid_rows > 0` is false (line 324), leaving only the subtle `Upload a different file` reset button (line 335). Before any upload the surface shows the idle Upload panel - never a blank page."
edge_status:
  - status: "row_invalid_on_preview"
    affordance: "Preview row with valid=false renders red-tinted (background #fff5f5, line 302) with a danger Badge `Error` (lines 315-318) and inline `<Small color=\"danger\">{row.error}</Small>` naming the failed field (line 312); the amber warning Message (lines 272-281) states error rows are skipped. User fixes the CSV and presses `Upload a different file` (line 335) to re-upload."
  - status: "validation_errors_present"
    affordance: "preview.error_rows > 0 -> amber `<Message type=\"warning\">` header `N row(s) have validation errors` with body `Error rows will be skipped on confirmation. Only valid rows will be created.` (lines 272-281). User may still press `Confirm` to create the valid subset (line 325) or `Upload a different file` to fix and retry."
  - status: "row_failed_on_create"
    affordance: "Confirm-result row with no subscription_id renders red-tinted with a danger Badge `Failed` (lines 405-408) and inline `<Small color=\"danger\">{row.error}</Small>` (line 403). User presses `Start another import` (line 414) to re-attempt, or `View subscriptions` (line 417) to navigate to /subscriptions and inspect what landed."
  - status: "partial_success"
    affordance: "Confirm response with failed_count > 0 -> the `Import complete` panel surfaces both `created_count` (success) and `failed_count` (danger) tallies side-by-side (lines 356-366) and the per-row list flags each failure; `Start another import` (line 414) lets the user retry only the failed rows."
inputs:
  - field: "csv_file"
    control: "file"
    required: true
    accepts: ".csv,text/csv"
    note: "Native `<input type=\"file\">` at lines 216-223 - NOT wrapped in a BigDesign FormGroup and has no associated `<label>`; it carries only data-testid=\"csv-file-input\", so its accessible name is absent (focus-mgmt gap, see gapNotes). The adjacent `CSV format` Panel (line 186) is prose, not programmatically associated. Constraints from Panel copy: start_date must be YYYY-MM-DD; maximum 1,000 rows per job (line 190)."
disabled_focus:
  keyboard: "Every actionable control is a natively focusable element reachable by sequential Tab - there are NO div-onClick dead-ends. BigDesign `<Button>` (renders a real `<button>`): `Back to subscriptions` (line 172), `Download template CSV` (line 193), `Upload and preview` (line 229), `Confirm` (line 325), `Upload a different file` (line 335), `Start another import` (line 414), `View subscriptions` (line 417), plus the error `<Message>` close-X. The file picker is a native `<input type=\"file\">` (line 216; Space/Enter opens the OS dialog). Result-row subscription IDs are native `<a href>` links (line 395). Disabled buttons correctly leave the tab order: `Upload and preview` while `!csvText.trim() || uploading` (line 233) and `Confirm` / `Upload a different file` while confirming (lines 329, 335)."
  focus_on_reveal: "CONTRACT (north-star, NOT yet in code): when handlePreview swaps the `Upload CSV` panel to `Preview results` (preview set at line 252; the upload form including the just-clicked button unmounts at line 213) focus MUST move to the revealed Preview region heading; likewise when confirmResult replaces the whole panel (line 211 -> line 345, the `Confirm` button unmounts) focus MUST move to the `Import complete` heading. The error `<Message>` MUST be announced (role=alert / aria-live polite) and SHOULD receive focus when set (lines 241, 347)."
  current_gap: "NOT IMPLEMENTED - the component performs no programmatic focus move on either dynamic reveal and sets no aria-live on the error Message; focus is orphaned to document.body after the panel swap. See gapNotes."

US-22.5: Force-charge / force-refund

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

Prototype: Force Actions

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

Phase: P1 (pulled forward 2026-05-16) · Persona: Support / Ops

As Support / Ops, I want to force a charge or refund outside of schedule, so that I handle edge cases.

Acceptance criteria:

  • Given I click "Force charge now," When I confirm, Then an immediate charge is enqueued with an audit note.
  • Given I click "Force refund," When I confirm the amount and reason, Then the processor is called immediately.

UI states. (rendering contract — ui-states block convention, #1851)

<!-- ui-states US-22.5 -->
surface: "/v2/subscriptions/:id (SubscriptionAdminDetail.tsx) — the 'CS override actions' BigDesign Panel plus the force-charge and force-refund Modals in CsActionsPanel.tsx (React/BigDesign admin), mounted with subscriptionId + subscriptionStatus props below the subscription detail."
idle:
  render: "A BigDesign Panel headed 'CS override actions' with a caption ('These actions are logged with your operator ID and require a reason.') and three buttons in a wrapped Flex: 'Force charge now' (enabled only when status is active, past_due, or trialing), 'Force refund' (enabled unless status is cancelled), and 'View as subscriber'. Nothing charges from the panel itself — each button opens a confirmation Modal."
  primary_action: "'Force charge now' opens the force-charge Modal to enqueue an off-schedule charge (AC1); 'Force refund' opens the force-refund Modal to call the processor immediately (AC2). Each Modal's primary button stays disabled until a reason is typed."
loading:
  trigger: "Modal submit — POST /api/v1/admin/cs/subscriptions/:id/force-charge or .../force-refund (csPost with the bearer session token), CsActionsPanel.tsx:290-313 / :316-341."
  render: "The Modal's primary action relabels to 'Queuing…' (charge) or 'Processing…' (refund) and is disabled (disabled = !reason.trim() || busy, :438 / :499) so the operator cannot double-submit; the panel buttons stay behind the open dialog."
error:
  surfaced_at: "Inline inside the open Modal — a BigDesign <Message type='error'> rendered below the reason/amount inputs (CsActionsPanel.tsx:466-474 charge, :527-535 refund), dismissible via onClose; it stays on screen, not a vanishing toast."
  render: "The thrown reason — 'HTTP <status>: <body>' surfaced by csPost (a processor decline, no succeeded charge to refund, or a 4xx validation message), or 'NO_AUTH_TOKEN' when the admin session token is missing."
  recovery: "The Modal stays open with the entered reason and amount preserved; the operator corrects the input (or retries a transient failure) and re-clicks the primary button, which re-enables once not busy — or clicks Cancel to back out."
empty:
  render: "Not a list surface — the override panel always renders its two action buttons; when the current status supports neither action (e.g. cancelled) they render disabled rather than the panel going blank."
  cta: "n/a — single-subscription action panel, not a collection. For a non-chargeable, non-cancelled status a Small caption ('Force-charge is only available for active, past_due, or trialing subscriptions.', :416-420) explains the disabled charge button; the cancelled case shows two disabled buttons with no caption (gap — see gapNotes)."
inputs:
  - field: "charge_reason"
    control: "text"
  - field: "amount_cents_override"
    control: "text"
  - field: "refund_reason"
    control: "text"
  - field: "refund_amount_cents"
    control: "text"
edge_status:
  - status: "active / past_due / trialing — chargeable"
    affordance: "'Force charge now' is enabled (disabled = !isChargeable, CsActionsPanel.tsx:385) and opens the force-charge Modal to enqueue an immediate off-schedule charge; 'Force refund' is enabled for these statuses too."
  - status: "cancelled — neither action applies"
    affordance: "'Force charge now' (not chargeable) and 'Force refund' (disabled = isCancelled, :395) both render disabled; the always-enabled 'View as subscriber' button stays available to inspect the account. No caption explains the disabled refund for cancelled subs (gap — see gapNotes)."
  - status: "paused / expired / other non-chargeable but not cancelled"
    affordance: "'Force charge now' is disabled and a Small caption renders — 'Force-charge is only available for active, past_due, or trialing subscriptions.' (:416-420); 'Force refund' stays enabled so the operator can still refund the last succeeded charge."
  - status: "charge queued — async, not yet executed"
    affordance: "On success the Modal closes and a persistent BigDesign success <Message> renders above the panel ('Force-charge queued… The scheduler will attempt it on the next tick.', :351-362); onActionComplete (reload) refreshes the detail so the operator can watch it resolve — the banner is dismissible, not auto-vanishing."
  - status: "refund issued — irreversible"
    affordance: "On success the Modal closes and a persistent success <Message> reports the refunded amount + transaction id (:364-375); the refund Modal's primary action is actionType='destructive' (:497) to flag the irreversible processor call before it is made."
disabled_focus:
  keyboard: "Every control is a real element — the three panel actions are BigDesign <Button>s (CsActionsPanel.tsx:383,393,403) and each Modal's Cancel + primary are BigDesign action buttons (:428-440, :488-501), all reachable in tab order with no div-onClick. Reason fields are BigDesign <Textarea label='Reason (required)'> and amounts are BigDesign <Input label=… type=number> (:447-464, :508-525) — label-bound FormGroup controls, never bare unlabeled inputs. 'Force charge now' / 'Force refund' carry a real disabled attribute (not aria-only), so the keyboard cannot trigger an action the status forbids."
  focus_move: "The Modals use BigDesign Modal variant='dialog' (:441, :502), which is expected to supply the role=dialog focus trap and return focus to the trigger on close (assumed BigDesign default — not verified against library source). The verified component fact: it adds NO explicit focus() / autoFocus / ref (grep: none in CsActionsPanel.tsx), so the required Reason textarea is not programmatically focused on open and the inline error <Message> carries no role=alert / aria-live, leaving its appearance unannounced (gaps — see gapNotes)."
  guard: "Both actions are typed-reason-gated: the Modal primary button stays disabled until reason.trim() is non-empty (:438, :499), so neither charge nor refund fires without an audit reason; the refund button is additionally actionType='destructive' to flag the irreversible processor call, and the primary disables while busy to block double-submit."

US-22.6: Merchant note on subscription

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

Prototype: Notes & CSV

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

Phase: P2 · Persona: Support / Ops

As Support / Ops, I want to attach internal notes to a subscription, so that the next person has context.

Acceptance criteria:

  • Given I add a note, When it saves, Then it appears on the subscription detail, timestamped and attributed to me.

UI states.

<!-- ui-states US-22.6 -->
surface: "Admin (React/BigDesign) — the inline MerchantNotePanel on apps/admin/src/pages/subscriptions/SubscriptionAdminDetail.tsx (component defined line 861): a Textarea + character count + Save Button + last-saved timestamp. Reads metadata.merchant_note via parseMerchantNote (line 943) from the detail payload; saves via setSubscriptionNote (apps/admin/src/lib/api-client.ts -> PUT /api/v1/admin/subscriptions/:id/note). Persona: Support / Ops."
idle:
  render: "A 'Notes' Panel with help text, a Textarea pre-filled with the current merchant_note (or empty with placeholder when none), a character-count Small ('<count> / 5000', note.charCount, line 907), and a primary Save Button disabled until the note is changed (dirty, line 873). When a save has landed and nothing is dirty, a '· saved at <time>' suffix shows next to the count (note.savedAt, line 914)."
  primary_action: "Edit the Textarea, then Save -> setSubscriptionNote persists metadata.merchant_note; an empty note saves as null (clears it)."
loading:
  render: "While busy is true the Save Button shows BigDesign isLoading with the 'Saving…' label (saveBtnBusy, line 927) and the Textarea is disabled (line 903), preventing edits or double-submit."
error:
  surfaced_at: "Inline, directly below the count/Save row: a Message(type='error') renders when setSubscriptionNote rejects (line 931-938), carrying the raw error string."
  recovery: "busy resets in finally so the Textarea re-enables with the typed note intact; the rep re-presses Save. The Message has no onClose and no aria-live (see gaps)."
empty:
  render: "When the subscription has no note (parseMerchantNote returns '' for null metadata) the Textarea renders empty with its placeholder and the count reads '0 / 5000'; the Save Button stays disabled until the rep types something — never a blank, actionless pane."
edge_status:
  - status: "note exceeds 5000 characters"
    affordance: "tooLong (line 874) turns the character-count Small to danger color (line 905) and keeps the Save Button disabled (line 923); the rep trims the note to re-enable Save."
  - status: "unsaved edits present"
    affordance: "dirty (line 873) enables the Save Button and hides the 'saved at' suffix (line 910) until the next successful save, so the rep can tell saved from pending state."
  - status: "corrupt metadata JSON"
    affordance: "North-star: parseMerchantNote should surface a non-blocking 'couldn't read the existing note' warning so the rep knows the field exists but failed to parse; today the catch silently returns '' (line 948) and the note appears blank with no explanation (see gaps)."
inputs:
  - field: "merchant_note"
    control: "textarea"
    label: "'Notes' Panel Textarea with placeholder and help text (line 897-903)"
    validation: "Free text (not an enumerable domain); client guard tooLong blocks save above 5000 chars (line 874); empty saves as null to clear (line 881)."
disabled_focus:
  keyboard: "The Textarea and Save Button are real BigDesign components wrapping native focusable elements, reachable in tab order (Textarea -> Save) and Enter/Space-activatable. While busy, native disabled removes both from tab order, preventing double-submit."
  focus_move: "On save success the count gains a '· saved at <time>' suffix (note.savedAt, line 914) but focus is not moved and the change carries no aria-live, so a screen-reader rep is not told the save landed; the error Message likewise carries no aria-live / role=alert (line 931-938). North-star announces both."
gaps: "The AC ('add a note -> it appears timestamped and attributed to me') is built: Textarea + char count + busy Save + 'saved at' timestamp. Divergence: parseMerchantNote (line 943-951) catches a JSON.parse failure on a corrupt metadata field and returns '' — a corrupt note silently shows blank with no user-visible error, so the rep cannot distinguish 'no note' from 'note unreadable'. A11y: save-success and error are not announced (no aria-live)."

US-22.7: AllotmentGrant management (issue, refresh-override, suspend, revoke)

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

Prototype: Allotment Grants

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

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

As Support / Ops, I want to issue, view, suspend, and revoke AllotmentGrant rows on customers or buyer-orgs, so that admin-granted quota / wallet flows (PRD-COMPANION D18) have a CS surface.

Acceptance criteria:

  • Given an admin-grant form, When I select a target customer or buyer-org, choose unit_type / amount_per_period / refresh_cadence / rollover_policy, optionally set expires_at, and confirm, Then an AllotmentGrant row is created with granted_by = my_admin_id and reason captured.
  • Given an existing grant, When I view the detail page, Then I see current_balance, next_refresh_at, the full debit history, and admin actions (suspend / revoke / manual-refresh / override-balance).
  • Given I trigger manual-refresh, When confirmed, Then current_balance is reset to amount_per_period per rollover_policy and next_refresh_at advances; an audit Event is logged with actor_id = my_admin_id.
  • Given I revoke a grant, When confirmed, Then status='revoked', no further debits accepted, the subscriber-facing balance disappears from the portal (per US-6.9 visibility config).

Data contract. UI is read/write to allotment_grants + allotment_debits per PRD §8.1. Audit Events fired on every state-changing action.

Dependencies. US-6.9 (AllotmentGrant primitive). Prototype prototype/prototypes/cs-tools/ (synthesis action item 12).

UI states.

<!-- ui-states US-22.7 -->
surface: "Admin (React/BigDesign) AllotmentGrant detail panel inside CsActionsPanel (apps/admin/src/pages/cs-tools/CsActionsPanel.tsx lines 548-625), rendered only when allotmentGrantId is set (via ?allotment_grant_id= on the subscription detail). Loads GET /api/v1/admin/cs/allotments/:id on mount (useEffect lines 215-223). Persona: Support / Ops / Merchant Admin."
idle:
  render: "The 'Allotment grant' Panel shows a status Badge, current_balance + unit_type, amount_per_period, next_refresh date, and refresh_cadence (lines 558-585), an optional revocation-reason block (lines 587-592), and a debit-history Table (lines 594-608)."
  primary_action: "'Revoke grant' destructive Button (lines 610-618), shown only when grantDetail.status is not 'revoked'; it opens the revoke modal (lines 628-678)."
loading:
  trigger: "GET /api/v1/admin/cs/allotments/{id} on mount."
  render: "While grantLoading a plain 'Loading grant detail…' Text renders (lines 550-552); no skeleton."
error:
  surfaced_at: "A BigDesign Message(type=error) inside the panel (lines 553-555), carrying the HTTP error string from csGet."
  recovery: "No in-panel retry control — the rep reloads the deep-link; a transient NO_AUTH_TOKEN / 401 requires re-authentication before the panel can reload."
empty:
  render: "When debit_history is empty the Table is omitted (guard line 594) and only the grant summary + status render — there is no 'no debits yet' copy. If the grant id resolves to nothing, the error Message renders in place of the summary."
edge_status:
  - status: "active"
    affordance: "the 'Revoke grant' Button is available (lines 610-618)."
  - status: "revoked"
    affordance: "the Revoke Button is hidden and the revocation reason is shown (lines 587-592); north-star is to re-issue a new grant to restore quota — the issue flow is not built (gap)."
  - status: "suspended"
    affordance: "north-star: a Resume action returns the grant to active. NOT built — suspend/resume controls are absent (gap); the Badge merely shows 'suspended' as a warning."
  - status: "balance exhausted before next_refresh_at (manual-refresh needed)"
    affordance: "north-star: a 'Manual refresh' Button resets current_balance per rollover_policy (BRD AC3). NOT built (gap)."
inputs: []
disabled_focus:
  keyboard: "The 'Revoke grant' Button and the revoke modal's reason Textarea / Confirm revoke / Cancel are real BigDesign components, keyboard-reachable and Enter/Space activatable; the BigDesign Modal traps focus while open and restores it on close; Confirm revoke is disabled until a reason is typed (line 644)."
gaps: "Raw-enum display: the status Badge label is the bare API string grantDetail.status (line 564) — the merchant sees 'active'/'revoked'/'suspended' unformatted instead of a humanized badge (BRD AC2). Issue (AC1), suspend, manual-refresh (AC3), and override-balance actions are entirely absent — the panel is a revoke-only surface and a dead-end for those ACs. Panel/modal error Messages carry no role=alert."

US-22.8: Custom-field admin UI — view, edit, validate

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

As Support / Ops, I want to view and edit per-subscription custom-field data on the admin subscription detail page, so that prospect-specific data (PO numbers, accounting codes, delivery preferences) can be corrected without API access.

Acceptance criteria:

  • Given a subscription with active CustomFieldDefinitions in scope, When I view the admin detail page, Then each defined field renders with its current value, type-appropriate input, validation hints, and required-marker.
  • Given I edit a field that fails validation (regex mismatch, type mismatch, required-but-empty), When I attempt save, Then save is blocked with the field-level error displayed inline.
  • Given I save valid changes, When the API persists, Then subscriptions.metadata.custom_fields[key] updates, an audit Event records actor_id and the value diff, and the change is visible immediately on next page load.
  • Given a field is archived after a subscription has data, When I view the page, Then the archived field renders read-only with a "field archived" hint; the data is not editable but is preserved.

Data contract. Reads custom_field_definitions for schema, writes to parent entity metadata.custom_fields. PRD-COMPANION D20.

Dependencies. US-6.11 (CustomFieldDefinition primitive). Plan Wizard custom-field config story (Epic 5 cross-reference).

UI states.

<!-- ui-states US-22.8 -->
surface: "Admin (React/BigDesign) Custom fields panel in CsActionsPanel (apps/admin/src/pages/cs-tools/CsActionsPanel.tsx lines 683-756). Loads GET /api/v1/admin/cs/subscriptions/:id/custom-fields on mount (useEffect lines 226-243); saves via PATCH (handleSaveCustomFields lines 272-287). Persona: Support / Ops / Merchant Admin."
idle:
  render: "Each active CustomFieldEntry renders as a BigDesign Input labeled with field_label (and a ' *' suffix when required, line 718); validation_regex shows as a 'Pattern: …' description hint (line 719). Archived fields render as a disabled+readOnly Input with a 'field archived' warning Badge (lines 699-714). A 'Save custom fields' primary Button sits at the bottom (lines 740-746)."
  primary_action: "'Save custom fields' Button (line 742); disabled only while cfSaveBusy (line 743)."
loading:
  trigger: "PATCH /api/v1/admin/cs/subscriptions/{id}/custom-fields with {fields: fieldEdits}."
  render: "While customFieldsLoading a 'Loading custom fields…' Text renders (line 684); while cfSaveBusy the Save Button label switches to 'Saving…' and is disabled (line 745)."
error:
  surfaced_at: "Load failure renders a Message(type=error) at the top of the panel (lines 685-687). Save failure renders a single Message(type=error) above the Save Button (lines 729-737); the API field_errors are concatenated into one banner string (csPatch lines 159-163), NOT shown inline per field."
  recovery: "On save failure the form stays populated and editable; the rep reads the banner, corrects the named value(s), and presses Save again."
empty:
  render: "When the plan has no fields (customFields.fields.length === 0) a Small note 'No custom fields defined for this subscription's plan.' renders (lines 688-690) and no Save Button is shown."
edge_status:
  - status: "required field empty"
    affordance: "north-star: Save is blocked client-side with an inline field-level error (BRD AC2). Today required={field.required} (line 722) does not block the onClick save (line 742 is not a form submit); the empty value posts and the API 422 surfaces in the concatenated banner — the rep reads the banner and fills the field (gap)."
  - status: "regex / type validation mismatch"
    affordance: "north-star: inline per-field error blocking save. Today the regex is only a 'Pattern:' description hint (line 719); validation is server-side and surfaces in the banner; the rep corrects and re-saves (gap)."
  - status: "archived field"
    affordance: "rendered read-only with a 'field archived' Badge (lines 699-714); the value is preserved and not editable — to edit, the field must be un-archived in plan config."
inputs:
  - field: "custom_field (text type)"
    control: "text"
    label: "field_label, with ' *' when required (line 718); validation_regex shown as description (line 719)."
  - field: "custom_field (enum type, options[] non-null)"
    control: "select"
    label: "NORTH-STAR: a field whose CustomFieldEntry.options is non-null is enumerable and should render a select bound to its options. TODAY every field renders as a text Input regardless of field_type/options (lines 717-723) — an options-bearing field is a raw text box (defect)."
    allowed_values: "CustomFieldEntry.options"
disabled_focus:
  keyboard: "Active-field Inputs and the 'Save custom fields' Button are real BigDesign components in DOM tab order, keyboard-reachable and Enter/Space activatable; archived Inputs are natively disabled and out of tab order. The Save Button is Tab-reachable."
gaps: "BRD AC2 unmet: no client-side per-field validation — required={field.required} (line 722) does not block the onClick-driven save (handleSaveCustomFields, line 742, is not a form submit), validation_regex is a description hint only (line 719), and API errors surface as one concatenated panel banner (csPatch lines 159-163) rather than inline field-level errors. Fields carrying options[] (enum type) render as plain text Inputs (lines 717-723) instead of selects. No role=alert on the save-error Message."

US-22.9: B2B Edition — admin-only subscription enrollment for buyer-orgs (ADR-0023)

Phase: P3 · Persona: Support / Ops

As Support / Ops, I want to enroll a B2B Edition buyer-org in a subscription via admin impersonation flow, so that B2B subscriptions are created through CS tools (per ADR-0023) rather than the standard storefront cart-capture flow that Buyer Portal bypasses.

Acceptance criteria:

  • Given a B2B Edition buyer-org and an enrolled CS-rep, When I impersonate the buyer-org and navigate to "Create Subscription", Then I can select a plan, configure the subscription (cadence, line items, ship-to-locations), assign actors (payer = buyer-org's stored PM, beneficiary = buyer-org or named contact, manager = my admin id), and confirm.
  • Given the subscription is created, When the first charge fires per the buyer-org's net-N billing terms (US-22.X net-terms cross-reference), Then the charge captures using the buyer-org's stored PM with multi-actor payer resolution.
  • Given the subscription is active, When dunning fires on renewal, Then the dunning email cc's the manager (CS-rep) per notification_prefs; escalation routes to a B2B-specific path if the merchant has Epic 11 B2B-dunning configured.
  • Given the buyer-org views their Buyer Portal subscription tab (US-24.B cross-reference), When the page renders, Then the admin-created subscription is visible with managed-by annotation showing the CS-rep name.

Data contract. Same Subscription.create API path as self-serve B2C; multi-actor population per US-6.10. Manager-role notification routing per Epic 23.

Dependencies. US-6.10 (multi-actor primitive). ADR-0023 (B2B checkout-ownership pattern decision). Net-N terms billing-delay (Epic 22 standalone story).


UI states.

<!-- ui-states US-22.9 -->
surface: "Admin (React/BigDesign) B2B buyer enrollment form for CS reps (apps/admin/src/pages/customers/B2bEnrollment.tsx). A BigDesign Form (line 299) POSTs /api/v1/admin/b2b-enrollment via submitEnrollment (lines 91-124) and handleSubmit (lines 156-214). Persona: Support / Ops."
idle:
  render: "Panel 'New B2B subscription' with a vertical FormGroup stack: Buyer email (Input type=email, required, lines 301-310), Plan ID (Input text, required, hand-typed UUID, lines 314-322), Start date (Input type=date, required, lines 326-334), then a 'B2B compliance metadata' H2 with Company name (Input required, lines 340-347), Purchase order / contract reference (Input optional, lines 351-357), and CS notes (Textarea optional, lines 360-367)."
  primary_action: "'Enroll buyer' type=submit Button (lines 371-378); native HTML5 required attributes gate submission; disabled + isLoading while state.submitting."
loading:
  trigger: "POST /api/v1/admin/b2b-enrollment."
  render: "While state.submitting the 'Enroll buyer' Button shows its isLoading spinner and is disabled (lines 374-375); no double-submit."
error:
  surfaced_at: "A panel-level Message(type=error) above the Form (lines 290-297) carries the top-line reason; per-field inline errors render via each Input's error prop (state.fieldErrors, lines 309/321/333/346/356) populated from the API invalid_fields envelope (lines 185-189)."
  recovery: "The form stays populated and re-enabled (setState submitting:false, line 197); the rep corrects the flagged field(s) and resubmits."
empty:
  render: "When B2B Edition is not installed (gateNavEntry('b2b_edition', caps) === 'hidden', line 221) the form is replaced by a 'B2B Edition required' info Panel explaining the dependency (lines 221-247) — the inert form is never shown. On success an 'Enrollment created' Panel with subscription/customer/company IDs and an 'Enroll another buyer' Button replaces the form (lines 258-287)."
edge_status:
  - status: "resource_not_found (customer / plan missing)"
    affordance: "panel error Message 'Resource not found: …' (lines 176-180); the rep corrects the email or plan UUID and resubmits, or onboards the BC customer first."
  - status: "precondition_failed (plan inactive / no PM)"
    affordance: "panel error Message with the reason (lines 182-184); the rep picks an active plan / ensures a stored PM, then resubmits."
  - status: "invalid_fields"
    affordance: "'Please fix the highlighted fields.' banner plus an inline Input error per field (lines 185-189); the rep fixes the highlighted inputs."
  - status: "B2B Edition not installed"
    affordance: "the dependency empty-state Panel directs the rep to install B2B Edition from the BC control panel (lines 221-247)."
inputs:
  - field: "customer_email"
    control: "email"
    label: "'Buyer email' Input type=email, required; case-insensitive server lookup (lines 301-310)."
  - field: "plan_id"
    control: "text"
    label: "'Plan ID' Input, required, hand-typed UUID (lines 314-322). NORTH-STAR: a plan picker (select of active plans) — see gaps."
  - field: "start_date"
    control: "date"
    label: "'Start date' Input type=date, required; B2B never charges immediately (lines 326-334)."
  - field: "b2b_company_name"
    control: "text"
    label: "'Company name' Input, required for the compliance audit trail (lines 340-347)."
  - field: "b2b_purchase_order_ref"
    control: "text"
    label: "'Purchase order / contract reference' Input, optional (lines 351-357)."
  - field: "notes"
    control: "textarea"
    label: "'CS notes' Textarea, optional (lines 360-367)."
disabled_focus:
  keyboard: "All six controls are real BigDesign Form components wrapping native focusable inputs (no div-onClick), each in a labeled FormGroup; tab order follows DOM source order email -> plan -> date -> company -> PO -> notes -> 'Enroll buyer'; the submit Button is Tab-reachable and Enter-activatable inside the Form; native disabled removes it from tab order while submitting."
gaps: "BRD AC1 unmet: the form captures only basic enrollment fields — cadence, line items, ship-to-locations, and the payer/beneficiary/manager actor assignments required by AC1 are absent; full B2B subscription configuration is not supported. plan_id is a hand-typed UUID Input (lines 314-322) rather than a plan picker (missing-control)."