← All epicsBRD.md §9 · lines 9756–10325

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 24 — B2B Edition support (derived view)

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

  • Stories (11): US-24.1, US-24.2, US-24.3, US-24.4, US-24.5, US-24.6, US-24.7, US-24.8, US-24.9, US-24.10, US-24.11
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 24 — B2B Edition support

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

Prototype: Company Account · Approval Workflow · PO & Contract · Multi-Location

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

Value: B2B merchants on BC's B2B Edition can run subscriptions with company-account semantics.

US-24.1: Company-account subscriptions

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

Prototype: Company Account

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

Phase: P3 · Persona: Merchant Admin (B2B)

As a Merchant Admin on B2B Edition, I want subscriptions attached to Company Accounts rather than individual customers, so that any authorized company user sees them.

Acceptance criteria:

  • Given B2B Edition is detected, When a subscription is created via a company-buyer checkout, Then it is associated with company_id, not customer_id.
  • Given any user with that company's permissions opens the portal, When they load, Then they see the company's subscriptions.

UI states.

<!-- ui-states US-24.1 -->
surface: "Subscriber portal subscription list — apps/storefront-svelte/src/routes/account/subscriptions/+page.svelte — with a B2B company-context badge above the list (lines 35-52). The list itself is rendered by apps/storefront-svelte/src/lib/subscriptions/SubscriberPortalApp.svelte (loadSubscriptions, lines 104-115). B2B context fetched server-side in +page.server.ts (tryGetB2bContext, line 36). Persona: Merchant Admin / buyer (B2B)."
idle:
  render: "'Your subscriptions' heading; for a B2B Edition shopper a company-name + role badge renders beside it (data-testid='b2b-context', lines 41-51). Below, SubscriberPortalApp lists the authenticated customer subscription rows (plan name, amount, cadence, status badge, next-charge)."
  primary_action: "Per-row Manage / Update payment / Cancel actions on active rows; the company badge itself is informational."
loading:
  trigger: "SubscriberPortalApp.loadSubscriptions() → apiClient.getSubscriptions() runs in a $effect once authToken is set (lines 117-123)."
  render: "Two animate-pulse skeleton cards while loadingSubs && subscriptions.length===0 (lines 356-364)."
error:
  surfaced_at: "Inline role=alert at the foot of the list card (lines 586-600), scoped to the portal — never a toast. For token errors it appends a 'Sign in again' button."
  render: "The getSubscriptions failure reason (err.message)."
  recovery: "For auth errors, 'Sign in again' clears the session and returns to the magic-link form (handleSignOut, line 165); otherwise the list reloads on the next successful auth."
empty:
  render: "'No subscriptions yet. When you subscribe to a product, it’ll show up here.' (SubscriberPortalApp lines 365-368). NOTE — see gaps: for a non-creator company user this empty copy is misleading because the company subscriptions are never queried."
edge_status:
  - status: "B2B Edition shopper — company context resolved"
    affordance: "The company name + role badge renders (lines 41-51); the shopper sees the list scoped to their own customer id (see gaps)."
  - status: "Non-B2B shopper — b2bContext null"
    affordance: "No badge renders; the customer-scoped list shows as normal (no visual change)."
  - status: "Company user who did not create the subscriptions"
    affordance: "Contract north-star: the list shows all of the company subscriptions. Today it falls back to that user own (often empty) customer-scoped list — recovery is the unbuilt company-scoped query; see gaps + defects."
inputs: []
disabled_focus:
  keyboard: "The badge is non-interactive text (no focus needed). The list row actions are native button elements reached in tab order (SubscriberPortalApp lines 403-436); the 'Sign in again' recovery is a real button (line 591)."
gaps: "PARTIAL. Built: the B2B company/role badge (tryGetB2bContext → +page.svelte data.b2bContext, lines 35-52). NOT built: company-scoped subscription visibility (AC2). apiClient.getSubscriptions() (api-client.ts line 315) calls /api/v1/portal/subscriptions with only the bearer token and NO company_id — the companyId resolved in b2bContext is shown in the badge but never used to scope the query. A company user who is not the subscription creator therefore sees their own customer-scoped list (frequently empty), not the company subscriptions."

US-24.2: Role-based portal permissions

Phase: P3 · Persona: Subscriber (B2B Buyer)

As a B2B Buyer with limited role, I want to view subscriptions but not modify them, so that I match my company's purchasing controls.

Acceptance criteria:

  • Given company roles are defined in BC B2B Edition, When a buyer with view_only role opens the portal, Then action buttons are disabled and labeled "Requires higher permissions."

UI states.

<!-- ui-states US-24.2 -->
surface: "Subscriber portal subscription actions area — apps/storefront-svelte/src/lib/subscriptions/SubscriberPortalApp.svelte. Action buttons are gated by the canManage derived (line 49); B2B Junior Buyers get a read-only view with an approval-required message (lines 447-453). Mounted by apps/storefront-svelte/src/routes/account/subscriptions/+page.svelte. Persona: B2B Buyer (view-only role)."
idle:
  render: "For a manage-capable buyer (canManage true) each active row shows Manage / Update payment method / Change shipping / Change billing buttons plus Cancel (lines 401-446). For a view-only buyer (canManage false) those buttons are replaced by a bordered note: 'Changes to this subscription require approval from your company admin (companyName). Contact your admin…' (lines 449-452)."
  primary_action: "Manage-capable: the row action buttons. View-only: no action — the note directs the buyer to their company admin."
loading:
  trigger: "loadSubscriptions() → apiClient.getSubscriptions() on auth (lines 117-123); per-action mutations set busyId."
  render: "Skeleton cards while the list loads (lines 356-364); while a row mutates, that row buttons are disabled via busyId===sub.id (e.g. line 406)."
error:
  surfaced_at: "Inline role=alert at the foot of the list card (lines 586-600), scoped to the portal — never a toast."
  render: "The mutation/load failure reason (err.message); token errors append a 'Sign in again' affordance."
  recovery: "Re-attempt the action after the inline error clears; auth errors route to 'Sign in again' (handleSignOut)."
empty:
  render: "Not a list-owning surface for this story — the role gating attaches to each active subscription row. The list-level empty ('No subscriptions yet…', lines 365-368) is owned by US-24.1 / US-17.1; documented here so the state is considered, not skipped."
edge_status:
  - status: "Junior Buyer / view_only role (canManage false)"
    affordance: "Contract north-star: the action buttons render DISABLED with an accessible 'Requires higher permissions' label so the buyer perceives what exists but cannot commit. Today they are HIDDEN and replaced by an approval-required note (see gaps + defects)."
  - status: "Admin / Senior Buyer (canManage true)"
    affordance: "Full action set renders (Manage / Update PM / addresses / Cancel)."
  - status: "past_due row for a view-only buyer"
    affordance: "The 'Update payment method' fix button is also canManage-gated (line 529) — a view-only buyer sees the failure note without the fix button; recovery is the company admin acting."
inputs: []
disabled_focus:
  keyboard: "Manage-capable action buttons are native button elements in tab order with disabled={busyId===sub.id} during mutation (lines 403-436). North-star: when a buyer is view-only the buttons stay in the tab order as disabled controls (programmatically labeled 'Requires higher permissions') rather than being removed, so a keyboard/screen-reader buyer still perceives the gated affordances."
gaps: "PARTIAL — the gating logic is present but DEAD on every live route. (1) Wiring: b2bContext is never passed to SubscriberPortalApp at any mount site (/account/subscriptions +page.svelte line 60, /subscriptions line 25, /account/addresses line 37 all omit it), so canManage = (!b2bContext || b2bContext.canManage) is always true and the view-only branch (lines 447-453) never executes. (2) Shape mismatch: the server tryGetB2bContext returns role as a string ('Admin' | 'Senior Buyer' | 'Junior Buyer') with no canManage field (b2b-edition.ts B2bRole), while SubscriberPortalApp B2bContext expects role:number + canManage:boolean — so even if passed it would not gate correctly. (3) AC divergence: the AC asks for action buttons DISABLED and labeled 'Requires higher permissions'; the impl HIDES them and uses different copy."

US-24.3: Approval workflow for subscription changes

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

Prototype: Approval Workflow

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

Phase: P3 · Priority: P1 · Effort: XL · Persona: Subscriber (B2B) / Merchant Admin

As a B2B Buyer, I want to request a subscription change that routes to my company admin for approval, so that enterprise controls are respected.

Acceptance criteria:

  • Given approval workflows are enabled, When a buyer initiates a change (pause/cancel/quantity), Then the change enters a pending state awaiting approval.
  • Given the approver acts (approve/reject), When they act, Then the change applies or is dropped with notification.

UX notes.

  • Subscriber (buyer): attempts a change → receives "Submitted for approval" state with approver name + ETA
  • Approver: inbox entry with change summary, "Approve" / "Reject with reason" buttons
  • On approve/reject: notifications to buyer and requester

Data contract.

  • Entity: subscription_change_requests{id, subscription_id, requested_by, approver_id, change_type, change_payload, status, decided_by, decided_at, reason}
  • Change types: pause, cancel, quantity_update, variant_swap, address_update
  • On approval: execute the change via normal action APIs

Success metrics.

  • Functional (target): approval workflow completes within SLA merchant sets (e.g., 24h)
  • Product: B2B subscription activation rate lifts (approval is a blocker in current tools)

Dependencies.

  • BC B2B Edition API (approvers, permissions)
  • Notification infra (email + in-app)

Non-functional.

  • Pending changes do not block the subscription (renewals continue unchanged until approved)

UI states.

<!-- ui-states US-24.3 -->
surface: "NOT YET BUILT — forward-looking contract. TWO surfaces in the Subscriber Buyer Portal (Svelte/Tailwind): (1) /account/subscriptions/[id] — B2B buyer subscription detail with pending-change status banner; (2) /account/approvals — buyer-org approver change-request inbox. Persona: B2B Buyer (requester) / B2B org_admin or designated approver."
idle:
  render: "Buyer detail: subscription summary renders normally; when a pending change_request exists a 'Pending approval' notice appears beneath the subscription header — 'Pause requested · awaiting approval by [Approver Name] · ETA [date]' — with a 'Withdraw request' button. Approver inbox: a list of pending change-request cards, each showing the subscriber name, subscription plan, requested change type (pause / cancel / quantity update), and submission timestamp."
  primary_action: "Buyer detail: 'Withdraw request' cancels the pending request, restoring normal management actions. Approver inbox: 'Approve' and 'Reject' buttons on each request card."
loading:
  trigger: "GET /api/v1/portal/subscriptions/{id}/change-requests (buyer detail); GET /api/v1/portal/approvals (approver inbox); POST /api/v1/portal/change-requests/{req_id}/approve or /reject."
  render: "Buyer detail: a skeleton placeholder pulses in the pending-change notice area while the change-request data fetches alongside the subscription summary. Approver inbox: three skeleton cards pulse while the queue loads. On approve/reject submission the acting button shows 'Processing…' and is disabled; all other action buttons on that card are also disabled to prevent double-action."
error:
  surfaced_at: "Inline — beneath the 'Withdraw request' button on the buyer detail, or beneath the 'Approve'/'Reject' buttons on the approver card (role=alert). Never a page-level banner that disassociates the error from the failing request."
  render: "API error message (e.g., 'This request has already been decided — refresh to see the latest state')."
  recovery: "Buyer: retry the withdrawal, or refresh to see whether the approval was already decided. Approver: retry the approve/reject action; if the request was already withdrawn by the buyer, the inbox refreshes to show the resolved state and the card disappears."
empty:
  render: "Buyer detail: when no pending change request exists, the subscription detail renders with its normal management actions and no pending notice. Approver inbox: 'No pending approvals' with a checkmark illustration and the copy 'All change requests have been decided.'"
  cta: "Approver inbox: no further action required when the queue is empty."
edge_status:
  - status: "change_request pending — buyer submitted, approver has not yet acted"
    affordance: "Buyer sees approver name and ETA in the pending-change notice; 'Withdraw request' lets them cancel before a decision is made. Renewals continue on existing terms unchanged until the request is decided."
  - status: "change_request approved — approver accepted the change"
    affordance: "The change is applied via the normal action API; the subscription transitions to its new state. Buyer receives a notification and the subscription detail re-renders to reflect the applied change."
  - status: "change_request rejected — approver declined with a reason"
    affordance: "Buyer sees the rejection reason inline on the subscription detail beneath the now-closed pending notice. Normal management actions are restored so the buyer can choose a different action or leave the subscription unchanged."
  - status: "approval_sla_exceeded — approver has not acted within the merchant-configured SLA"
    affordance: "Buyer is notified; the change request is flagged as timed out. The pending-change notice updates to 'Approval overdue' with the approver's contact info so the buyer can follow up directly without a dead-end."
inputs:
  - field: "rejection_reason"
    control: "textarea"
    label: "Rejection reason — required when the approver presses 'Reject'; inline textarea revealed on the approver card, must be non-empty before 'Confirm rejection' is enabled"
disabled_focus:
  keyboard: "All Approve, Reject, Withdraw, and Confirm buttons are real <button> elements in tab order — no div-onClick. The rejection-reason textarea is revealed inline when the approver presses 'Reject' and receives focus immediately. Pressing Escape collapses the rejection-reason form and returns focus to the 'Reject' button. Focus is not trapped — the approver can tab away to other cards at any time."
  focus_move: "On approve/reject completion the card transitions to a resolved state and focus moves to the next pending card in the inbox, or to an aria-live=polite 'All approvals complete' notice if the queue is now empty."

US-24.4: Purchase order as payment method

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

Prototype: PO & Contract

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

Phase: P3 · Persona: Subscriber (B2B) / Support / Ops

As a B2B Buyer, I want to use a PO number as my subscription's payment method, so that charges settle against company net-30 terms rather than a card.

Acceptance criteria:

  • Given BC B2B supports PO payments, When a company subscribes with PO, Then payment_method_type: po is stored and renewal "charges" generate BC orders with payment_status: pending for manual reconciliation.

UI states.

<!-- ui-states US-24.4 -->
surface: "NOT YET BUILT — forward-looking contract. TWO surfaces: (1) Subscriber Buyer Portal (Svelte/Tailwind) /account/subscriptions/[id] — PO payment method display on subscription detail; (2) Merchant Admin (React/BigDesign) /subscriptions?payment_type=po — Support/Ops reconciliation view for pending PO orders. Persona: B2B Buyer / Support / Ops."
idle:
  render: "Buyer detail: the payment method section shows 'Purchase Order · PO #[number] · Net-30 terms' in place of a card-on-file block; a 'PO terms' callout explains that renewals generate BC orders with payment pending for manual reconciliation rather than auto-charging a card. Admin reconciliation: a filterable table of subscriptions with payment_method_type=po, columns for Subscriber, Company, Plan, Next Renewal, BC Order ID, and Reconciliation Status badge."
  primary_action: "Buyer detail: 'Contact account manager' link (PO terms are negotiated, not self-service). Admin reconciliation: 'Mark reconciled' action per row to update the PO order status."
loading:
  trigger: "GET /api/v1/portal/subscriptions/{id} (buyer detail); GET /api/v1/admin/subscriptions?payment_type=po (admin reconciliation). On renewal: POST /api/v1/internal/charges/po-renewal which creates the BC order with payment_status: pending."
  render: "Buyer detail: the payment section shows a pulse skeleton while fetching; the subscription summary is visible immediately. Admin reconciliation: the table shows a shimmer per row while paginating."
error:
  surfaced_at: "Buyer detail: inline beneath the payment method section (role=alert) if the subscription cannot be loaded. Admin reconciliation: inline above the table if the PO subscription list fails to load; per-row inline notice if a 'Mark reconciled' action fails."
  render: "Buyer detail error: 'Could not load PO details — try refreshing.' Admin error: 'Could not update reconciliation status — retry.'"
  recovery: "Buyer: refresh the subscription detail page. Admin: retry the 'Mark reconciled' action; the row stays at its previous status until the action succeeds. The reconciliation table as a whole shows a 'Reload' link if the full list load fails."
empty:
  render: "Admin reconciliation: 'No PO subscriptions' with the copy 'No active subscriptions are using purchase-order payment terms.' Buyer detail: this surface is only reached for subscriptions with payment_method_type=po, so a PO reference always exists — a dedicated empty state is not required."
  cta: "Admin reconciliation empty: link to the subscriptions list to browse all subscriptions."
edge_status:
  - status: "po_order_pending — BC order created, awaiting manual reconciliation"
    affordance: "Admin sees the BC order ID as a clickable link to the BC admin order detail; 'Mark reconciled' updates the order status. Buyer sees a 'Payment pending reconciliation' badge on the renewal entry."
  - status: "po_order_overdue — reconciliation not completed by net-30 due date"
    affordance: "Admin sees an 'Overdue' warning badge with the due date; 'Mark reconciled' or 'Flag for follow-up' actions are available. Buyer sees 'Renewal overdue' badge with copy directing them to contact their account manager."
  - status: "po_reference_missing — subscription has payment_method_type=po but no PO number stored"
    affordance: "Admin sees a 'Missing PO ref' warning badge on the row; an 'Edit PO reference' inline action reveals a text input and Save button so ops can add or correct the PO number without leaving the reconciliation view."
inputs:
  - field: "po_reference"
    control: "text"
    label: "PO reference — admin inline edit on a row flagged as missing or incorrect PO number; saved via PATCH /api/v1/admin/subscriptions/{id}"
  - field: "reconciliation_status"
    control: "select"
    label: "Reconciliation status — admin row action, inline Select on 'Mark reconciled'"
    allowed_values: "pending / reconciled / overdue / disputed"
disabled_focus:
  keyboard: "Buyer detail: 'Contact account manager' is a real <a href> in tab order, Enter-activatable. Admin reconciliation: all row actions are real <button> elements — no div-onClick. The inline PO-reference edit reveals a BigDesign Input that receives focus automatically; pressing Escape cancels and returns focus to the 'Edit PO reference' trigger. Tab order within the inline edit: PO input → Save → Cancel."

US-24.5: Buyer hierarchy & visibility

Phase: P3 · Persona: Merchant Admin (B2B)

As a Merchant Admin with multi-level company hierarchies, I want to see rolled-up subscription MRR by parent company, so that I understand enterprise account health.

Acceptance criteria:

  • Given BC B2B hierarchy is defined, When I view the dashboard filtered by a parent company, Then MRR rolls up across all child companies.

UI states.

<!-- ui-states US-24.5 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) subscription analytics dashboard — B2B hierarchy MRR rollup view at /analytics/mrr-by-company. Persona: Merchant Admin."
idle:
  render: "An MRR summary panel at the top of the analytics page with a BigDesign Select labeled 'Filter by company' (placeholder 'All companies'). When a parent company is selected the MRR total card updates to show the rolled-up value across all child companies. A collapsible child-company breakdown table appears beneath the summary listing each child company with its individual MRR contribution."
  primary_action: "Select a parent company from the BigDesign Select — selecting automatically triggers the MRR rollup fetch; no separate Apply button."
loading:
  trigger: "GET /api/v1/admin/analytics/mrr?parent_company_id={id} — fired on mount for all-companies aggregate and on each company selection change. GET /api/v1/admin/b2b/companies to populate the company Select on mount."
  render: "The MRR total card shows a BigDesign Spinner while fetching. The child-company breakdown table shows shimmer rows. The company Select is disabled while the company list loads."
error:
  surfaced_at: "Inline — a BigDesign Message(type='error') above the MRR summary panel if the analytics fetch fails or the BC B2B hierarchy API returns an error. A separate Message appears above the Select if the company list cannot be loaded."
  render: "Error text (e.g., 'Could not load MRR data — the B2B hierarchy API may be unavailable. Retry or check your BC B2B Edition configuration.')."
  recovery: "A 'Retry' Button inside the error Message re-fires the analytics fetch without leaving the page. If the company Select list is unavailable the all-companies aggregate view is shown automatically as a fallback so the page is not a dead-end."
empty:
  render: "When no B2B companies are found in the hierarchy: a BigDesign Message(type='info') with text 'No B2B company hierarchy is configured. Set up company structures in BC B2B Edition to enable hierarchy filtering.' When a selected parent company has no active subscriptions: the MRR total shows $0.00 with a sub-note 'No active subscriptions under this company.'"
  cta: "External link to BC B2B Edition admin panel when no hierarchy exists."
edge_status:
  - status: "B2B Edition not installed or hierarchy API returns 403"
    affordance: "The company filter Select is hidden; a BigDesign Message(type='info') explains B2B Edition is required and links to the BC App Marketplace."
  - status: "parent company selected but all child MRR is zero (no active subscriptions)"
    affordance: "MRR total shows $0.00; the breakdown table shows child companies with $0.00 per row so the hierarchy structure is still visible. A 'View all subscriptions' link filters the subscriptions list to this company — no dead-end."
  - status: "partial hierarchy data — some child companies return an API error"
    affordance: "A BigDesign Message(type='warning') above the breakdown table lists which child companies could not be retrieved; their MRR is excluded from the total. A 'Retry missing companies' Button re-fetches only the failed rows."
inputs:
  - field: "parent_company_id"
    control: "select"
    label: "Filter by company — BigDesign Select, options populated from BC B2B hierarchy API; 'All companies' is the default option"
  - field: "date_range"
    control: "select"
    label: "Date range — BigDesign Select for MRR calculation window"
    allowed_values: "last_30_days / last_90_days / last_12_months / current_month"
disabled_focus:
  keyboard: "Both BigDesign Selects wrap native <select> elements — keyboard-reachable, arrow keys navigate options, Enter or Space confirms selection. The 'Retry' Button and the 'Retry missing companies' Button are real BigDesign Buttons — no div-onClick. Tab order: date-range Select → company Select → Retry (when visible) → MRR table (read-only). No focus traps."

US-24.6: Volume-tier renewal pricing

Phase: P3 · Persona: Merchant Admin (B2B)

As a Merchant Admin, I want renewal pricing to reflect negotiated volume tiers, so that enterprise deals honor contract terms.

Acceptance criteria:

  • Given a company has volume-tier pricing in BC, When renewals compute, Then the applicable tier is read at renewal time.

UI states.

<!-- ui-states US-24.6 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan configuration page — 'Volume-tier renewal pricing' section at /plans/{plan_id}/settings#volume-tiers. Persona: Merchant Admin."
idle:
  render: "A 'Volume-tier renewal pricing' section within the plan settings page, gated by a BigDesign Toggle ('Enable volume-tier pricing for renewals', off by default). When toggled on a tier mapping table appears with rows for each BC customer group or price list, columns for Tier Source, Applies To, and Fallback Behavior. A read-only note explains that the applicable BC tier is resolved live at each renewal rather than stored at subscription creation."
  primary_action: "'Save tier settings' Button — enabled only when the toggle is on and at least one tier mapping row has a valid Tier Source selection."
loading:
  trigger: "GET /api/v1/admin/plans/{id}/tier-config on mount; GET BC customer-groups API to populate the Tier Source Select options."
  render: "The tier mapping table shows a BigDesign Spinner while BC customer-group data loads. The Toggle and Save button are disabled during load."
error:
  surfaced_at: "Inline — BigDesign Message(type='error') beneath the Toggle if BC customer-group data cannot be fetched; Message(type='error') beneath the 'Save tier settings' Button if the tier config save fails."
  render: "BC customer-group fetch error: 'Could not load BC customer groups — check your API credentials or try again.' Save error: API detail string from the response."
  recovery: "A 'Retry' Button in the customer-group fetch error Message re-fetches BC data without leaving the plan settings page. For save failure the form remains populated and re-enabled; the admin corrects the offending row and resubmits."
empty:
  render: "When no BC customer groups or price lists are defined: the Tier Source Select renders with no options and a helper note beneath it: 'No BC customer groups found — create customer groups in BC Admin to enable tier pricing.' The 'Save tier settings' Button remains disabled."
  cta: "External link to BC Customer Groups admin page below the helper note."
edge_status:
  - status: "renewal fires and assigned tier not found in BC at renewal time"
    affordance: "Renewal falls back to the configured fallback_behavior (use base price / block renewal / notify admin). An admin alert is generated; an 'At-risk renewals' badge appears on the plan settings page with a link to view affected subscriptions."
  - status: "Toggle disabled after tier config was previously saved"
    affordance: "A BigDesign Dialog warns 'Disabling volume-tier pricing will revert all renewals for this plan to base price from the next cycle.' Confirming proceeds; dismissing leaves the Toggle on."
  - status: "plan has no active subscriptions — tier config saves but has no immediate effect"
    affordance: "BigDesign Message(type='info') confirms save success and notes 'This will apply when new subscriptions to this plan renew.'"
inputs:
  - field: "tier_pricing_enabled"
    control: "checkbox"
    label: "Enable volume-tier pricing — BigDesign Toggle, default off; enabling reveals the tier mapping table"
  - field: "tier_source"
    control: "select"
    label: "BC tier source — BigDesign Select per tier mapping row; options sourced from BC customer groups API"
  - field: "fallback_behavior"
    control: "select"
    label: "Fallback when tier not found at renewal — BigDesign Select per mapping row"
    allowed_values: "use_base_price / block_renewal / notify_admin"
disabled_focus:
  keyboard: "Toggle, all Tier Source Selects, Fallback Behavior Selects, and Save Button are real BigDesign components wrapping native focusable elements — no div-onClick. When the Toggle is off all tier-mapping inputs are disabled and removed from the tab order. The confirmation Dialog traps focus within the dialog on open (Escape dismisses and returns focus to the Toggle); all dialog buttons are real BigDesign Buttons."

US-24.7: Contract-term commitments

Phase: P3 · Persona: Merchant Admin (B2B)

As a Merchant Admin, I want to enforce contract-term commitments with auto-renewal and notice-period semantics, so that enterprise subscriptions match signed agreements.

Acceptance criteria:

  • Given a contract term of 12 months with 60-day notice, When a buyer tries to cancel with < 60 days until term end, Then cancellation is blocked or routes to legal workflow per merchant config.

UI states.

<!-- ui-states US-24.7 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan or subscription settings — 'Contract terms' configuration panel at /plans/{plan_id}/settings#contract-terms (plan-level default) or /subscriptions/{id}/settings#contract-terms (per-subscription override). Persona: Merchant Admin."
idle:
  render: "A 'Contract terms' section with BigDesign Input fields for Commitment length (months) and Notice period (days), a BigDesign Select for Cancellation enforcement mode, and a BigDesign Checkbox for Auto-renew on term end. When a contract term is already configured a summary card above the form shows the current values: 'Current term: 12 months · 60-day notice required · Enforcement: route to legal workflow.' Default state when no term is configured: all fields empty, enforcement defaulted to 'warn only', auto-renew checked."
  primary_action: "'Save contract terms' Button — enabled when both commitment_length_months and notice_period_days are non-empty positive integers and notice_period_days does not exceed commitment_length expressed in days."
loading:
  trigger: "GET /api/v1/admin/plans/{id}/contract-terms or /subscriptions/{id}/contract-terms on mount."
  render: "The contract-terms section shows a BigDesign Spinner while the current configuration loads; all form fields are disabled."
error:
  surfaced_at: "Inline — BigDesign Message(type='error') beneath the 'Save contract terms' Button if the save fails; inline BigDesign FormGroup error message beneath the offending Input for out-of-range or cross-field validation errors."
  render: "Validation error example: 'Notice period cannot exceed the commitment length.' API error: detail string from the response."
  recovery: "Form stays populated and re-enabled after save failure; the admin corrects the offending field and resubmits. The inline field error clears as soon as the value passes client-side validation."
empty:
  render: "When no contract terms are configured: the summary card shows 'No contract terms set — cancellation is standard (no notice period required).' All form fields display their default values; the form is ready for authoring without any additional step."
  cta: "No separate CTA required — the form itself is the authoring surface."
edge_status:
  - status: "buyer attempts cancellation within the notice period"
    affordance: "Storefront/portal cancel flow enforces the configured cancellation_enforcement mode: 'block' surfaces 'Cancellation is not permitted until [date] per your contract terms' to the buyer; 'route_to_legal' generates a review request and shows 'Your cancellation request has been routed for review — your account manager will contact you'; 'warn_only' lets the standard cancel proceed after showing a warning."
  - status: "contract term expires with auto_renew = true"
    affordance: "Subscription auto-renews for another full term; the merchant admin receives a notification. The subscription's term-end date updates and the summary card reflects the new term."
  - status: "contract term expires with auto_renew = false"
    affordance: "Subscription transitions to month-to-month after the term end; the merchant admin receives a notification. The contract-terms summary card updates to 'Term ended — now month-to-month.'"
inputs:
  - field: "commitment_length_months"
    control: "text"
    label: "Commitment length — BigDesign Input type=number, min=1, placeholder '12'"
  - field: "notice_period_days"
    control: "text"
    label: "Notice period — BigDesign Input type=number, min=0, placeholder '60'; validated to be less than commitment length in days"
  - field: "cancellation_enforcement"
    control: "select"
    label: "Cancellation enforcement — BigDesign Select"
    allowed_values: "block / route_to_legal / warn_only"
  - field: "auto_renew_on_term_end"
    control: "checkbox"
    label: "Auto-renew on term end — BigDesign Checkbox, default checked"
disabled_focus:
  keyboard: "All BigDesign Input, Select, Checkbox, and Button controls are in DOM tab order — no div-onClick. Tab order follows DOM source order: commitment-length → notice-period → enforcement Select → auto-renew Checkbox → 'Save contract terms'. Inline field errors use role=alert on the FormGroup description node so screen readers announce the validation message without programmatic focus movement."

US-24.8: Multi-location shipping

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

Prototype: Multi-Location

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

Phase: P3 · Persona: Subscriber (B2B)

As a B2B Buyer, I want a single subscription to ship to multiple company locations on a cycle (e.g., monthly coffee delivery to 5 offices), so that I administer one subscription not five.

Acceptance criteria:

  • Given a multi-location subscription is configured, When a cycle renews, Then one order per location is created with the agreed quantity; total charge equals the sum.

UI states.

<!-- ui-states US-24.8 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber Buyer Portal (Svelte/Tailwind) — multi-location delivery configuration at /account/subscriptions/[id]/locations. Persona: B2B Buyer."
idle:
  render: "A 'Delivery locations' section on the subscription management page listing each configured company location with its full address, quantity per cycle, and a 'Remove' button. An 'Add location' button at the bottom of the list opens an inline add-location form. The subscription summary bar shows 'Shipping to N location(s) · [sum of quantities] units per cycle.'"
  primary_action: "'Add location' — opens the inline add-location form with an address Select and a quantity input. 'Save locations' — confirms the current set of locations and updates the subscription; enabled only when at least one location is configured and all quantities are positive integers."
loading:
  trigger: "GET /api/v1/portal/subscriptions/{id}/locations on mount; GET /api/v1/portal/company/addresses for the address Select; PATCH /api/v1/portal/subscriptions/{id}/locations on save."
  render: "The locations list shows skeleton rows while loading. The add-location form's address Select shows 'Loading addresses…' and is disabled while company addresses fetch. On save the 'Save locations' button shows 'Saving…' and is disabled; all Remove buttons and the quantity inputs are also disabled to prevent mid-save edits."
error:
  surfaced_at: "Inline — beneath the 'Save locations' button (role=alert) if the locations update fails; beneath the address Select in the add-location form if company addresses cannot be fetched."
  render: "Save error: 'Could not update delivery locations — one or more addresses may be invalid.' Address-load error: 'Could not load company addresses — please try again or contact your account manager.'"
  recovery: "If save fails the locations list remains as-edited so the buyer can correct and retry. If address fetch fails a 'Retry' link in the inline error re-fetches company addresses without discarding the current locations."
empty:
  render: "When no delivery locations are configured yet: an empty-state panel with the text 'No delivery locations set up. Add your first location to start shipping to multiple sites.' and an 'Add location' button — never a blank panel."
  cta: "'Add location' — same affordance as the primary action, surfaced in the empty state."
edge_status:
  - status: "desired address not in BC company addresses"
    affordance: "The address Select only shows BC company addresses; free-form entry is not allowed. An 'Add this address in your BC account' link beneath the Select directs the buyer to BC B2B account management to register the address before returning to configure the location."
  - status: "partial fulfillment failure — one location's order creation fails on renewal"
    affordance: "An inline alert on the subscription detail lists which location orders succeeded and which failed, with a 'Retry failed location' button that re-attempts order creation for only the failed location. Successful location orders are not re-fired."
  - status: "buyer removes all but one location"
    affordance: "A confirmation prompt ('Removing all locations will revert this subscription to a single-address delivery — continue?') appears before the last removal; confirming falls back to the subscription's primary shipping address."
inputs:
  - field: "location_id"
    control: "select"
    label: "Delivery address — Svelte <select> populated from BC company addresses; each option shows the full formatted address"
  - field: "quantity_per_location"
    control: "text"
    label: "Quantity per cycle — number input, min=1, one per location row"
disabled_focus:
  keyboard: "Each location row's 'Remove' button is a real <button> in tab order. 'Add location' is a real <button>. The inline add-location form's address Select and quantity input are focusable controls; the address Select receives focus when the form opens. 'Save locations' and 'Cancel' are real <button> elements; pressing Escape cancels the inline form and returns focus to 'Add location'. Focus does not escape the location management section during inline editing — no div-onClick."
  focus_move: "On successful save the 'Delivery locations' section re-renders with the updated list and focus moves to the 'Add location' button so the buyer can continue adding without tab-reset."

US-24.9: Admin-only B2B subscription enrollment in Phase 1 (ADR-0023)

Phase: P3 · Persona: Merchant Admin / Support / Ops

As a Merchant Admin running a B2B-Edition store, I want B2B buyer-org subscriptions to be enrolled via Epic 22 CS tools rather than self-serve storefront flow in Phase 1, so that the Buyer Portal coexistence pattern (per ADR-0023) doesn't break our cart-capture flow.

Acceptance criteria:

  • Given a B2B-Edition store, When a buyer browses a subscription-enabled PDP, Then the standard subscription widget renders a B2B variant with copy ("Subscription enrollment is handled by your account manager — contact your CS rep") and a contact-form CTA in place of add-to-cart.
  • Given a CS-rep enrolls a B2B buyer-org via US-22.9, When the subscription is created, Then it appears in the Buyer Portal subscription tab with managed-by annotation.
  • Given the merchant explicitly opts-in to v3 cart-merge once that ships (post-spike #113 finding), When the opt-in toggle is on, Then the storefront variant switches back to add-to-cart and the v3 cart-merge handler runs (out-of-scope for Phase 1).

Data contract. Storefront widget reads window.B2B SDK presence to determine variant. No new backend schema in Phase 1.

Dependencies. ADR-0023. US-22.9 (admin enrollment flow). Epic 8 widget B2B variant. Spike #113 (Epic 24 unblocker).

UI states.

<!-- ui-states US-24.9 -->
surface: "TWO surfaces. (1) Admin (React/BigDesign) CS-rep B2B enrollment form (apps/admin/src/pages/customers/B2bEnrollment.tsx), wired into the v2 shell router at App.tsx line 120 (Route 'b2b/enroll' -> B2bEnrollPage, a thin re-export from apps/admin/src/routes/v2/b2b/index.tsx). (2) Storefront (Svelte/Tailwind) PDP B2B variant in SubscriptionWidget.svelte (lines 476-507) — when isB2b is true the subscribe/one-time UI is replaced by a 'Contact sales' panel (no add-to-cart). Persona: Merchant Admin / Support / Ops."
idle:
  render: "Admin: the 'New B2B subscription' form (see US-22.9). Storefront: a B2B section headed 'B2B subscription product' with copy that direct online subscription isn't available and a 'Contact sales' link to b2bContactHref (lines 489-501)."
  primary_action: "Admin: 'Enroll buyer' submit (B2bEnrollment lines 371-378). Storefront: 'Contact sales' anchor (lines 496-501) pointing at the merchant's contact/sales target."
loading:
  trigger: "Admin POST /api/v1/admin/b2b-enrollment; storefront getPlans on mount."
  render: "Admin 'Enroll buyer' shows its isLoading spinner while submitting (B2bEnrollment lines 374-375). Storefront renders an aria-busy skeleton of three pulsing bars while loading (SubscriptionWidget lines 470-475) before the B2B branch resolves."
error:
  surfaced_at: "Admin: a panel Message plus inline Input field errors (B2bEnrollment lines 290-297 and 309-356). Storefront: the B2B variant is static after load; a plan-load failure that mis-routes falls through to the one-time fallback whose role=alert notice 'Subscribe options couldn't load…' renders (SubscriptionWidget lines 522-528), and the subscribe path surfaces a role=alert error (line 770)."
  recovery: "Admin: correct the flagged field and resubmit. Storefront: the 'Contact sales' link is static and always actionable; on a load error the shopper refreshes per the alert copy."
empty:
  render: "Admin: a 'B2B Edition required' Panel when the edition is not installed (B2bEnrollment lines 221-247), and an 'Enrollment created' success Panel after enroll (lines 258-287). Storefront: when b2bContactHref is not provided the CTA degrades to fallback text 'Please reach out to the merchant's sales team to enroll.' (SubscriptionWidget lines 502-505) so the panel is never a dead-end."
edge_status:
  - status: "storefront isB2b = true"
    affordance: "subscribe/one-time UI is replaced by the contact-sales section (lines 476-507) — no add-to-cart, matching ADR-0023 Phase-1 ownership."
  - status: "storefront isB2b = true but no b2bContactHref"
    affordance: "fallback instructional text directs the shopper to the merchant's sales team (lines 502-505)."
  - status: "admin enroll API error (resource_not_found / precondition_failed / invalid_fields)"
    affordance: "panel + inline field errors; the rep corrects and resubmits (B2bEnrollment lines 175-197)."
inputs:
  - field: "customer_email"
    control: "email"
    label: "Admin 'Buyer email' Input type=email (lines 301-310)."
  - field: "plan_id"
    control: "text"
    label: "Admin 'Plan ID' Input, hand-typed UUID (lines 314-322); north-star plan picker — see gaps."
  - field: "start_date"
    control: "date"
    label: "Admin 'Start date' Input type=date (lines 326-334)."
  - field: "b2b_company_name"
    control: "text"
    label: "Admin 'Company name' Input (lines 340-347)."
  - field: "b2b_purchase_order_ref"
    control: "text"
    label: "Admin 'Purchase order / contract reference' Input (lines 351-357)."
  - field: "notes"
    control: "textarea"
    label: "Admin 'CS notes' Textarea (lines 360-367)."
disabled_focus:
  keyboard: "Admin form controls are keyboard-reachable BigDesign Form components in DOM tab order (see US-22.9). Storefront 'Contact sales' is a real <a href> (lines 496-501), Tab-reachable and Enter-activatable; the no-href fallback is plain text with no interactive trap."
gaps: "Copy divergence: BRD AC1 specifies 'Subscription enrollment is handled by your account manager — contact your CS rep'; the shipped storefront copy reads 'This product is sold on a B2B basis. Direct online subscription isn't available…' (SubscriptionWidget lines 489-494) — functionally equivalent (contact-sales CTA present) but not the spec wording. AC2 ('managed-by' annotation in the Buyer Portal subscription tab) is a separate Buyer-Portal surface, not in these components. The admin route is wired only under the v2 shell (App.tsx line 120); a legacy-shell mount is not present. isB2b is supplied by the storefront page (server prerender / window.B2B detection per the prop doc lines 51-60), not derived inside the widget."

US-24.10: org_admin actor role and multi-actor management on B2B subscriptions (PRD-COMPANION D19, ADR-0022)

Phase: P3 · Persona: B2B Buyer / Merchant Admin

As a B2B Buyer-Org Administrator, I want to manage subscriptions on behalf of org members (delegate viewing, editing, cancellation rights), so that team subscriptions don't depend on a single individual's portal login.

Acceptance criteria:

  • Given a B2B subscription with actors[].role = org_admin, When the org_admin views the Buyer Portal subscription tab, Then they see all subscriptions where any actor (owner / payer / beneficiary / manager) is in their org and can act on each per merchant-configured permissions.
  • Given the org_admin updates a subscription's payer.payment_method_ref (using the org's stored PM), When the change saves, Then renewal MIT charges to the new PM from the next cycle; an audit Event records actor_id = org_admin_user_id.
  • Given the org_admin cancels a subscription on behalf of an org member, When the cancellation saves, Then standard cancel flow runs but the audit attributes to org_admin (not beneficiary) and the beneficiary receives a cancellation notification.
  • Given the marketplace v2 path activates (post-ADR-0022 v2 work), When Actor.processor_connection_ref is non-null on the payer row, Then renewal MIT routes to the per-org processor connection; in Phase 1 this column is null and routing falls back to store primary connection.

Data contract. Per US-6.10 multi-actor schema. org_admin role enum value reserved-but-functional in Phase 1 for permission resolution; processor_connection_ref reserved column null in Phase 1.

Dependencies. US-6.10. ADR-0022 (marketplace MoR v2 deferral; v2 hooks present in Phase 1 schema). US-22.9.

UI states.

<!-- ui-states US-24.10 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber Buyer Portal (Svelte/Tailwind) — org_admin 'Org Subscriptions' tab at /account/org-subscriptions. Persona: B2B org_admin actor."
idle:
  render: "An 'Org Subscriptions' tab in the Buyer Portal navigation, visible only to actors with role=org_admin. The tab shows a table of all subscriptions where any actor (owner / payer / beneficiary / manager) is a member of the org_admin's company. Columns: Subscriber, Plan, Status badge, Next Renewal, Payment Method, and a per-row Actions menu. A permission notice at the top of the tab reads 'You have [view only / manage payment / manage cancel / full manage] permissions for org subscriptions' — sourced from the session context."
  primary_action: "Per-row Actions menu surfacing the actions the merchant has granted to this org_admin: 'Update payment method' / 'Cancel on behalf of member' / 'View details' — only permitted actions are shown in the menu."
loading:
  trigger: "GET /api/v1/portal/org/subscriptions?actor_role=org_admin — multi-actor query across all org members. Permission scope is returned in the session context and shown before the subscription data resolves."
  render: "The org subscriptions table shows skeleton rows while the multi-actor query loads. The permission notice is shown immediately from the session context without waiting for subscription data."
error:
  surfaced_at: "Inline — a role=alert notice above the org subscriptions table if the multi-actor query fails. Per-action errors surface inline in the confirmation modal or directly beneath the row's Actions menu trigger."
  render: "Fetch error: 'Could not load org subscriptions — please try again.' Per-action error example: 'Payment method update failed — this subscription requires the beneficiary's consent.'"
  recovery: "A 'Retry' button in the fetch-error notice re-fires the org subscriptions query. Per-action failures re-enable the action controls in the modal so the org_admin can correct and resubmit."
empty:
  render: "When no org members have active subscriptions: 'No org subscriptions yet. Subscriptions created by org members will appear here.' When the org_admin has view-only permissions and the table loads successfully: the table renders with all action columns showing a lock icon; a notice reads 'You have view-only access. Contact your merchant admin to request management permissions.'"
  cta: "View-only notice includes a mailto or contact link to the merchant admin support channel."
edge_status:
  - status: "org_admin permission scope = view_only"
    affordance: "All action columns show a lock icon; the Actions menu renders only 'View details'. No payment or cancel actions are available. The permission notice at the top explains the restriction and offers a path to request elevated permissions from the merchant admin."
  - status: "org_admin cancels on behalf of org member"
    affordance: "A confirmation modal names the subscription and the beneficiary and notes 'This cancellation will be attributed to your account in audit logs and the subscriber will receive a cancellation notification.' 'Confirm cancellation' and 'Cancel' buttons are provided. On confirmation the standard cancel flow runs with audit_actor = org_admin_user_id; the beneficiary receives a cancellation notification."
  - status: "org_admin updates payer payment_method_ref"
    affordance: "A 'Select payment method' panel opens showing the org's stored payment methods. The selected PM is applied to the subscription; an audit event records actor_id = org_admin_user_id. In Phase 1 processor_connection_ref is null — renewal routes to the store primary connection and an informational note in the panel reads 'Using store payment connection (per-org payment routing activates in a later release).'"
  - status: "processor_connection_ref null — Phase 1 per ADR-0022"
    affordance: "Payment method changes are still allowed and function correctly; routing falls back silently to the store primary processor. The panel informational note prevents confusion about why per-org routing is not yet active."
inputs:
  - field: "payment_method_ref"
    control: "select"
    label: "Replacement payment method — Select panel showing the org's stored payment methods; used by org_admin to update payer.payment_method_ref on a subscription via PATCH /api/v1/portal/org/subscriptions/{id}/payer"
disabled_focus:
  keyboard: "The 'Org Subscriptions' tab is a real <a> or <button> in the portal nav tab bar — Tab-reachable and Enter-activatable. All row Actions menus are real <button> triggers opening a keyboard-navigable dropdown (arrow keys, Enter selects, Escape dismisses); no div-onClick anywhere. Confirmation modals trap focus within the modal — Escape dismisses and returns focus to the row Actions trigger. The 'Select payment method' panel's first interactive control (payment method list) receives focus when the panel opens; closing the panel returns focus to the triggering action button."
  focus_move: "On successful cancel or payment-method update the row reflects the new state; focus returns to the Actions menu button for that row so the org_admin can continue acting on other subscriptions without tab-reset."

US-24.11: Catalyst storefront + Buyer Portal + subscription widget composite PDP behaviour

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

Prototype: Catalyst + Buyer Portal composite PDP

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

Phase: P3 · Persona: Subscriber (B2B) / Merchant Admin

As a B2B-Edition merchant running a Catalyst storefront with our subscription widget, I want the PDP to behave coherently when all three components (Catalyst PDP, B2B Edition Buyer Portal, our subscription widget) coexist, so that B2B and B2C buyers on the same store have a consistent experience without breakage.

Acceptance criteria:

  • Given a Catalyst storefront with the B2B Edition Buyer Portal SDK loaded, When a non-B2B (B2C) shopper views a subscription-enabled PDP, Then the standard B2C subscription widget renders with the standard add-to-cart flow (Buyer Portal does not activate for non-B2B sessions).
  • Given the same PDP viewed by a logged-in B2B buyer-org user, When the Buyer Portal SDK detects the B2B session, Then the subscription widget switches to the B2B variant per US-24.9 and the standard PDP add-to-cart switches to B2B's useAddToQuote / useAddToShoppingList hooks for non-subscription line items.
  • Given the merchant inspects the Catalyst integration files, When they read documentation, Then references to core/b2b/loader.tsx, core/b2b/use-b2b-cart.ts, core/b2b/use-product-details.tsx (per the Open Source Buyer Portal working with One Click Catalyst integration guide) match the implementation.
  • Given the merchant has not enabled B2B Edition, When PDPs render, Then no Buyer Portal SDK loads and the subscription widget renders the B2C variant unconditionally.

Data contract. No backend schema. Frontend integration: window.B2B namespace probe in widget initialization; conditional render of B2C-variant vs B2B-variant components.

Dependencies. ADR-0023 (B2B checkout-ownership decision rationale). Prototype prototype/prototypes/storefront-widget/PDPCompositeBuyerPortal (synthesis action item 15). Catalyst integration guide as architecture source.


UI states.

<!-- ui-states US-24.11 -->
surface: "Storefront PDP subscription widget — apps/storefront-svelte/src/lib/subscriptions/SubscriptionWidget.svelte. Renders the B2C subscribe/one-time UI OR a B2B 'contact sales' panel, branched on the isB2b prop (Props line 60, destructured line 91). The B2B Edition company/role detection helper lives portal-side in apps/storefront-svelte/src/lib/server/b2b-edition.ts (tryGetB2bContext, line 200). Persona: B2B / B2C shopper."
idle:
  render: "After plans resolve the widget picks one branch: with isB2b=false (default) it renders the B2C fieldset (subscribe vs one-time radios, cadence buttons when >1 plan, next-charge preview, CTA — lines 546-772); with isB2b=true it renders the 'B2B subscription product' panel (lines 476-507) explaining online subscribe is unavailable and the sales team sets up the account."
  primary_action: "B2C: 'Start Auto-Refill' / 'Add to cart' button (lines 719-737). B2B: a 'Contact sales' anchor when b2bContactHref is provided (lines 495-501), else static 'reach out to the merchant sales team' copy (lines 502-506)."
loading:
  trigger: "On mount apiClient.getPlans({ bcProductId }) runs in a $effect (lines 116-153); loading=true until it resolves."
  render: "A skeleton card (three animate-pulse bars) with aria-busy='true' (lines 470-475). The B2B-vs-B2C branch is NOT decided during load — only the skeleton shows, so a slow context fetch never flashes the wrong variant at the widget itself (the widget only reads the already-resolved isB2b prop)."
error:
  surfaced_at: "Inline role=alert inside the one-time fallback fieldset (data-testid='widget-load-error', lines 522-528), scoped to the widget — never a toast. A getPlans failure sets error and leaves plans=[] so the {:else if plans.length === 0} branch renders."
  render: "'Subscribe options couldn’t load — showing one-time purchase only. Refresh to try again.' plus the one-time price and an 'Add to cart' button so the PDP is never a dead-end."
  recovery: "The shopper can still buy one-time immediately; full recovery is a page refresh (no in-widget retry control). The console.error logs degraded_to='one-time-only' for RUM (lines 144-148)."
empty:
  render: "Not a list surface — a single-product widget. The nearest 'empty' is the plan-less product: getPlans returns zero active plans, plans=[], and the one-time-only fieldset renders (lines 508-545) with the BC one-time price and 'Add to cart'. A genuinely plan-less product (error==null) shows no error notice; a load failure (error!=null) adds the role=alert line."
edge_status:
  - status: "B2B-only product (isB2b=true) — direct online subscribe not supported"
    affordance: "The contact-sales panel renders; the buyer follows the 'Contact sales' link (b2bContactHref) to CS-rep enrollment, or the static fallback instructs contacting the merchant sales team."
  - status: "B2C session on a subscribable PDP (isB2b=false, plans present)"
    affordance: "Standard subscribe/one-time radios + 'Start Auto-Refill' / 'Add to cart' render; the Buyer Portal does not activate."
  - status: "B2B Edition not enabled on the store"
    affordance: "isB2b stays false; the B2C variant renders unconditionally — no Buyer Portal SDK load, no breakage."
inputs: []
disabled_focus:
  keyboard: "All controls are native focusable elements: the subscribe/one-time radio inputs (lines 633/649), cadence buttons (line 612), the CTA button (line 719), and the B2B 'Contact sales' anchor (line 496) — all reached in DOM tab order, no div-onClick. Controls carry disabled={submitting} so they leave the tab order while a submit is in flight."
gaps: "PARTIAL. Built: the widget two render branches (B2C fieldset + B2B contact-sales panel) and the portal-side B2B detection helper (b2b-edition.ts tryGetB2bContext, which degrades to null on any failure). NOT built: the runtime Buyer Portal SDK probe (the BRD data contract window.B2B namespace check) that should COMPUTE isB2b on the PDP — today isB2b is a bare prop with no live producer wired into any Catalyst PDP; the B2B add-to-cart hook switch (useAddToQuote / useAddToShoppingList) for non-subscription line items; and the core/b2b/loader.tsx, use-b2b-cart.ts, use-product-details.tsx integration files the AC references. Because isB2b has no SDK-driven producer, a real B2B buyer falls through to the B2C variant by default."