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.
Epic 14 — BC order generation from charges (derived view)
Read-only per-epic slice of
BRD.md§9, lines 5369–5706. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-14.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (7): US-14.1, US-14.2, US-14.3, US-14.4, US-14.5, US-14.6, US-14.7
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 14 — BC order generation from charges
<!-- traceability:start:BRD:Epic-14 --><!-- traceability:end:BRD:Epic-14 -->Prototype: Charge to Order · Order History · Order Details · Error Recovery · First-Order Linkage · Order Status Mapping
Value: Every successful charge produces a native BC order indistinguishable from any other order, so merchant ops has no parallel system.
US-14.1: Create BC order on successful charge
<!-- traceability:start:US-14.1 --><!-- traceability:end:US-14.1 -->Prototype: Charge to Order
Phase: MVP · Priority: P0 · Effort: L · Persona: System
As the System, I want to create a BC order via V2 Orders API on each successful renewal charge, so that fulfillment, shipping, tax, and reporting flow through BC natively.
Acceptance criteria:
- Given a charge succeeds, When the workflow continues, Then a BC order POST happens with: customer, billing/shipping addresses from subscription snapshot (or overrides), line items, payment_status=captured, payment_provider_id=<processor txn>, status_id=merchant's "Awaiting Fulfillment."
- Given the order creation succeeds, When the response is parsed, Then
charge.bc_order_idis stored and Event logged. - Given the order creation fails, When the error is caught, Then the charge goes into the exception queue while the processor settlement remains (no refund auto-issued).
Data contract.
- BC API: POST
/v2/orderswith payload:
Notes:{ customer_id, billing_address, shipping_addresses, products: [{product_id, variant_id, quantity, price_inc_tax, price_tax}], payment_method: "manual", payment_provider_id: processor_txn_id, status_id: merchant.default_sub_status_id, staff_notes: "[SUB] {subscription_id} cycle {N}", external_source: "bc-subscriptions-app"}payment_statusis server-computed by BC (V2 API rejects the create with 400 if included). BC sets it based on the presence of recorded payments on the order; for our merchant-initiated-recurring flow it remains empty until/unless we attach a transaction record.- Order-scoped key/value data (subscription_id, charge_id, cycle_number, plan_id) is written as order metafields via BC's order-metafields API (
POST /v3/orders/{order_id}/metafields— thestore/order/metafield/*webhooks confirm the resource; verify the exact path via bc-platform-verify before build), after the order-create call, which stays atomic. (BC orders expose no custom-field subresource — metafields are the order-level key/value primitive.)
- Response: order with ID; store on
charges.bc_order_id
Success metrics.
- Functional: 100% of successful charges produce a BC order
- Operational (target): order creation P95 < 2s
- Reconciliation: zero orphan charges (charges without bc_order_id) post-sweep
Dependencies.
- US-10.2 (executor)
- US-14.2 (metadata) — same API call
Non-functional.
- If BC API returns 5xx, retry with exponential backoff up to 3 attempts within 10 minutes
- If still failing, charge goes into exception queue but processor settlement stands
US-14.2: Tag order with subscription metadata
<!-- traceability:start:US-14.2 --><!-- traceability:end:US-14.2 -->Prototype: Order History · Order Details
Phase: MVP · Persona: Merchant Admin / System
As a Merchant Admin, I want every subscription-generated order clearly labeled in BC, so that my team, reports, and integrations can distinguish them.
Acceptance criteria:
- Given a subscription order is created, When it posts, Then:
custom_fieldscontains{subscription_id, charge_id, cycle_number, plan_id}staff_notesstarts with[SUB] {subscription_id} cycle {N}external_sourceis set to our app identifier
- Given the order is viewed in BC admin, When the staff note is read, Then it deep-links back to our subscription detail page.
UI states.
<!-- ui-states US-14.2 -->surface: "NOT YET BUILT — forward-looking contract. System-written metadata on BC Admin order detail — no interactive UI surface owned by this app. This story's 'rendering' is the data our system writes to BC orders via the BC Orders API (PATCH /v2/orders/{id}): the custom_fields block, the staff_notes deep-link prefix, and the external_source identifier. A Merchant Admin reading the BC order detail sees these fields; the staff_notes URL navigates to our admin subscription detail page."
idle:
render: "In BC Admin order detail: the 'Staff notes' field begins with '[SUB] {subscription_id} cycle {N}' followed by a full URL deep-linking to /admin/subscriptions/{subscription_id} in our app. The order's custom_fields block contains four named entries: subscription_id, charge_id, cycle_number, and plan_id — each with a human-readable label and the corresponding value. The external_source field is set to our app identifier (e.g. 'bc-subscriptions'). These fields are written by our worker after BC order creation and are read-only within BC Admin."
primary_action: "The staff_notes URL, when visited by a Merchant Admin in BC Admin, navigates to our app's subscription detail page for the linked subscription — exposing the full subscription history, plan details, and management actions."
loading:
trigger: "PATCH /v2/orders/{id} (BC Orders API) called by our renewal worker within seconds of BC order creation — writes custom_fields, staff_notes, and external_source as an atomic update."
render: "The tag write is async; BC Admin shows the order without subscription metadata until the PATCH completes. On success the fields populate without a page reload (BC Admin handles its own refresh). No subscriber-facing loading state exists for this system operation."
error:
surfaced_at: "A PATCH failure (BC API 4xx / 5xx or network timeout) is logged as a system event (event_type: order.tag_failed) and surfaced in our admin exception queue with the BC order ID, HTTP status, and retry count — not visible in BC Admin itself. The Merchant Admin reading the untagged BC order sees no custom_fields and no staff_notes prefix; the absence of tags is the observable symptom."
recovery: "The exception queue entry for order.tag_failed exposes a 'Retry tagging' action that re-fires the BC PATCH. If the order cannot be tagged after retries (e.g. order was deleted or BC is unavailable), the exception is marked resolved-with-note and the admin is directed to manually record the subscription linkage in the order's internal notes."
empty:
render: "Non-subscription BC orders carry no custom_fields or staff_notes prefix — their absence is correct and expected. Documented here so the 'untagged order' observation is understood as a system-healthy state for orders not generated by subscriptions."
edge_status:
- status: "PATCH returns 404 — order was deleted in BC before the tag write completed"
affordance: "Exception queue entry is created; 'Retry tagging' will also return 404. The rep marks the exception resolved-with-note; the subscription's internal event log records the BC order ID for future audit."
- status: "external_source already set by another BC app before our PATCH fires"
affordance: "Our PATCH overwrites external_source with our identifier; a system event (event_type: order.external_source_overwritten, previous_value: '{prior_value}') is logged so the merchant can audit the change if an integration depends on that field."
- status: "staff_notes deep-link navigates to a deleted or archived subscription"
affordance: "Our admin subscription detail page renders a 'Subscription not found' state with heading, explanatory copy, and a back-link to /admin/subscriptions — never a bare 404."
disabled_focus:
keyboard: "The staff_notes URL renders as plain text in BC Admin's native staff_notes field — keyboard navigable per BC Admin's own accessibility implementation. No custom interactive elements are authored by this app on the BC order page."
US-14.3: First-order linkage
<!-- traceability:start:US-14.3 --><!-- traceability:end:US-14.3 -->Prototype: First-Order Linkage
Phase: MVP · Persona: System
As the System, I want to tag the initial checkout order with subscription metadata via update after subscription creation, so that the first order is linked even though it wasn't created by us.
Acceptance criteria:
- Given a
store/order/createdwebhook creates a Subscription, When we process, Then we PATCH the order'scustom_fieldsandstaff_notesto add subscription linkage.
US-14.4: Order status mapping
<!-- traceability:start:US-14.4 --><!-- traceability:end:US-14.4 -->Prototype: Order Status Mapping
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to choose which BC order status my subscription orders post into, so that my workflow configuration doesn't break.
Acceptance criteria:
- Given the merchant settings page, When I select a default
status_id, Then new subscription orders post into that status.
UI states.
<!-- ui-states US-14.4 -->surface: "Admin (React/BigDesign) settings — apps/admin/src/pages/settings/store/General.tsx (General). Persona: Merchant Admin. Settings → Store → General. Loads GET /api/v1/admin/settings/store (getSettingsStore), saves via PATCH (patchSettingsStore). This story is the 'BC Order Status' field: default_sub_status_id, the BC order status applied to subscription-generated orders."
idle:
render: "H1 'General' + four Panels: Capture Timing (a Select), Region (read-only country_code), Test Mode (a Checkbox), and 'BC Order Status' — the story's field — rendering an Input(type=number) for the default order status id with help text 'Leave blank to use the default (status 11 — Awaiting Fulfillment)' (lines 226-244). A 'Save' Button sits at the foot (lines 246-252)."
primary_action: "'Save' Button → patchSettingsStore({capture_timing, test_mode_enabled, default_sub_status_id}) (handleSave, lines 79-118); a blank field is sent as null (server default)."
loading:
render: "While the mount fetch is in flight (loading === true) every control — the Capture Timing Select, Test Mode Checkbox, the order-status Input, and the Save Button — renders with disabled={loading} (lines 175, 216, 238, 248). No skeleton or spinner copy is shown."
error:
surfaced_at: "Load failure renders a BigDesign Message(type='error') 'Failed to load settings: …' near the top (loadError, lines 129-135, no onClose — it persists). Save failure renders a dismissible Message(type='error') 'Save failed: …' (saveError, lines 146-153). Save success renders a dismissible Message(type='success') (saveSuccess, lines 137-144)."
recovery: "Save failure leaves the form populated; the merchant corrects a field and presses Save again. Load failure has NO retry control — and worse, after a load error the controls re-enable (loading=false) but handleSave early-returns on `if (!settings) return` (line 80), so Save silently does nothing; recovery is a full page reload (see gaps)."
empty:
render: "Not a list surface — a singleton store-settings page. The order-status field empty (blank Input) is a valid state meaning 'use the BC default, status 11 / Awaiting Fulfillment' (parsed to null in handleSave, line 85; help text line 229)."
edge_status:
- status: "save with no changes"
affordance: "The server returns changed_keys = [] and the success Message reads 'No changes to save.' (lines 108-110); the merchant edits a field to make a real change."
- status: "settings failed to load"
affordance: "loadError Message is shown (lines 129-135). North-star affordance: a retry button that re-runs getSettingsStore; today the only path is reload, and Save is a silent no-op until the page is reloaded (see gaps)."
inputs:
- field: "default_sub_status_id"
control: "select"
label: "'Default order status ID' — the BC order status applied to subscription orders (BRD §US-14.4)."
allowed_values: "the store's BigCommerce order statuses (status_id → human-readable name; code confirms the default 11 → 'Awaiting Fulfillment', line 229). NORTH-STAR control. TODAY this is a raw Input(type=number) requiring the merchant to look up numeric ids in BC admin (lines 231-243) — a raw-enum defect (see gaps)."
- field: "capture_timing"
control: "select"
label: "'Capture Timing' — BigDesign Select (lines 173-180)."
allowed_values: "immediate | on_fulfillment | on_ship (CAPTURE_TIMING_OPTIONS, lines 47-51)."
- field: "test_mode_enabled"
control: "checkbox"
label: "'Enable test mode for new subscriptions' — BigDesign Checkbox (lines 212-217)."
disabled_focus:
keyboard: "All inputs are real BigDesign components — Capture Timing Select, Test Mode Checkbox, the order-status Input, and the Save Button — each labeled and reachable in Tab order (capture timing → test mode → order status → Save). Native disabled removes them from the tab order while loading; Save is activatable with Enter/Space. The country_code is read-only display text, not a control."
gaps: "[built, with defects] (1) Raw-enum: default_sub_status_id is a raw numeric Input (lines 231-239) with help text telling the merchant to look up status ids in BC admin (lines 240-243) — it should be a Select populated with the store's BC order statuses (human-readable names). (2) Silent no-op save after a load error: when getSettingsStore rejects, settings stays null and loading becomes false (lines 73-76), so the controls re-enable but handleSave returns immediately at `if (!settings) return` (line 80) — clicking Save produces no save and no feedback; there is also no retry control."
US-14.5: Order edit sync
<!-- traceability:start:US-14.5 --><!-- traceability:end:US-14.5 -->Prototype: Error Recovery
Phase: P2 · Persona: Merchant Admin
Scope-of-reactions ratified: Hive #924 [Decision-Fast] (2026-05-17) codifies the three-tier reaction matrix the
store/order/updatedhandler implements, preventing future scope creep. Tier 1 (P1, shipped via PR #927): terminal BC order status (Refunded/Cancelled/Declined) →charges.statustransitions torefunded/failed+ emitcharge.external_status_change. Tier 2 (P1, shipped via PR #927): mutable-field edits (address, total change) → always emit advisory audit events (subscription.address_edited,charge.order_total_changed); no subscription-row mutation. Tier 3 (P2, deferred): merchant-setting-driven address propagation tosubscriptions.shipping_address(gated bysync_order_edits_to_subscription = true). Out of scope (any phase): auto-pricing recalculation,plan_idmutation, or interval changes from a webhook payload — these require an explicit[Spec]proposal and SCA/proration design that lives in US-14.6. State-derive catalog reflects P1-shipped + P2-deferred split.
As a Merchant Admin, I want to edit a subscription order in BC (address, quantity) and have the change reflect on the subscription, so that I don't need to update two places.
Acceptance criteria:
- Given I edit a subscription order's shipping address in BC, When
store/order/updatedfires, Then the parent Subscription's default shipping address optionally updates (per merchant setting) or logs as an Event only.
UI states.
<!-- ui-states US-14.5 -->surface: "Admin (React/BigDesign) settings — apps/admin/src/pages/settings/store/FeatureFlags.tsx (FeatureFlags). Persona: Merchant Admin. Settings → Store → Feature Flags. Loads GET /api/v1/admin/settings/store (getSettingsStore), saves via PATCH (patchSettingsStore). This story is the 'Order edits → subscription sync' toggle: sync_order_edits_to_subscription, controlling whether BC order edits propagate back to the subscription row."
idle:
render: "H1 'Feature Flags' + three Panels. The story's control lives in 'Subscription lifecycle' as a Checkbox 'Propagate order edits (address, line items) back to the subscription' with explanatory Small text (lines 211-221). Sibling numeric inputs: nudge_lead_days (lines 197-205), bundle_window_days (lines 226-235), reactivation_grace_days (lines 253-262). A 'Save' Button sits at the foot (lines 271-277)."
primary_action: "'Save' Button → patchSettingsStore({..., sync_order_edits_to_subscription}) (handleSave, lines 97-158); numeric fields are validated client-side first (parseNullableInt, lines 105-126)."
loading:
render: "While the mount fetch is in flight (loading === true) every Input/Checkbox and the Save Button render disabled={loading} (lines 204, 216, 234, 247, 261, 273). No skeleton or spinner copy."
error:
surfaced_at: "Load failure renders a BigDesign Message(type='error') 'Failed to load settings: …' near the top (loadError, lines 169-175, persists, no onClose). Save failure — including client-side numeric validation failures — renders a dismissible Message(type='error') (saveError, lines 186-193; validation messages set at lines 106-126). Save success renders a dismissible Message(type='success') (saveSuccess, lines 177-184)."
recovery: "Save / validation failure leaves the form populated; the merchant corrects the offending value and presses Save again. Load failure has NO retry control, and after a load error Save is a silent no-op (handleSave returns at `if (!settings) return`, line 98) until the page is reloaded (see gaps)."
empty:
render: "Not a list surface — a singleton store-settings page. The sync toggle defaults to off (unchecked); blank numeric inputs mean null (meaningful 'off' / 'use default' state per the field, e.g. bundle_window_days empty = combined shipments off, line 233)."
edge_status:
- status: "sync enabled, but address write-back not yet honored (P2 deferred)"
affordance: "The Checkbox persists the flag and emits settings.changed; per Hive #924 Tier 1 (terminal BC status → charge transitions) and Tier 2 (advisory audit events: subscription.address_edited / charge.order_total_changed) DO fire today. Only Tier 3 — propagating the edited address into subscriptions.shipping_address — is deferred (BRD §US-14.5 note); the toggle is a P1 stub for that behavior (see gaps)."
- status: "numeric field invalid (non-integer / out of range)"
affordance: "parseNullableInt flags it and handleSave sets a specific saveError before any round-trip (lines 106-126); the merchant corrects the value and re-saves."
- status: "save with no changes"
affordance: "Server returns changed_keys = [] and the success Message reads 'No changes to save.' (lines 148-149)."
inputs:
- field: "sync_order_edits_to_subscription"
control: "checkbox"
label: "'Propagate order edits (address, line items) back to the subscription' — BigDesign Checkbox (lines 212-217)."
- field: "nudge_lead_days"
control: "number"
label: "'Days before term end to send the renewal nudge' — Input type=number, min 0 max 90 (lines 197-205)."
- field: "bundle_window_days"
control: "number"
label: "'Days within which co-scheduled charges collapse into one BC order' — Input type=number, min 1 max 30, empty = off (lines 226-235)."
- field: "reactivation_grace_days"
control: "number"
label: "'Days after cancellation when a subscriber can reactivate' — Input type=number, min 1 max 3650, empty = default 90 (lines 253-262)."
disabled_focus:
keyboard: "The sync Checkbox, the three numeric Inputs, and the Save Button are real BigDesign components wrapping native focusable elements — labeled and reachable in Tab order through the panels to Save. Native disabled removes them from the tab order while loading; Save is activatable with Enter/Space."
gaps: "[partial] The sync_order_edits_to_subscription toggle is fully built in the UI and persists (lines 211-221, PATCH at line 131), but the backend honor of the flag — Tier 3 address propagation into the subscription row — is P2-deferred per the BRD §US-14.5 note and Hive #924, so in P1 the toggle is a stub for address write-back (Tier 1/Tier 2 reactions already fire). Also: same as General.tsx, after a load error the controls re-enable but Save silently no-ops (handleSave `if (!settings) return`, line 98) with no retry control."
US-14.6: Mid-cycle plan upgrade — delta-capture semantics
<!-- traceability:start:US-14.6 --><!-- traceability:end:US-14.6 -->Prototype: Renewal screens (term-end / mid-cycle / NTI)
Phase: P2 · Persona: Subscriber / Merchant Admin
As a Subscriber upgrading my plan mid-cycle (e.g., basic-tier to premium-tier monthly subscription, 12 days into a 30-day cycle), I want a clear and merchant-configurable delta-capture behaviour, so that I'm not double-billed or surprised by retroactive charges.
Acceptance criteria:
- Given a plan-upgrade action occurs
Ddays into anN-day cycle and the merchant's policy isprorate_now, When the upgrade saves, Then a one-time charge is captured for(new_price - old_price) * (N - D) / Nand the next renewal proceeds at the new price for a full cycle. - Given the policy is
skip_and_bill_next, When the upgrade saves, Then no immediate charge fires; the next scheduled renewal charges at the new price; the change is logged assubscription.plan_changedwitheffective_at = next_charge_at. - Given the policy is
overcapture, When the upgrade saves, Then a one-time charge captures the full new-price amount immediately and the next_charge_at advances by one full interval (effectively "start the new plan now"); the unused portion of the old cycle is forfeit (merchant policy disclosed in UX). - Given the policy is configured per-plan (not per-store), When upgrades cross plan boundaries with mismatched policies, Then the destination plan's policy applies.
- Given the upgrade requires SCA / 3DS challenge for the delta charge, When the challenge fires, Then the charge enters
REQUIRES_ACTION(D4); the plan-change ispending_paymentuntil the challenge resolves.
Data contract. New column plans.upgrade_delta_capture_policy (enum: prorate_now | skip_and_bill_next | overcapture). New event subscription.plan_changed with { from_plan_id, to_plan_id, delta_amount, policy_applied, effective_at }.
Dependencies. US-18.4 (swap-product semantics share the delta-capture machinery). PRD-COMPANION D21 (NTI freshness — delta charge is a fresh CIT, captures new NTI).
UI states.
<!-- ui-states US-14.6 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Subscriber portal (Svelte/Tailwind) plan-upgrade flow — initiated from the subscription detail's 'Change plan' action, presenting a plan-picker with a delta-charge preview and an optional SCA/3DS challenge step. (2) Merchant Admin (React/BigDesign) plan settings — an 'Upgrade delta-capture policy' select on the plan edit page governing the billing behavior when subscribers upgrade mid-cycle."
idle:
render: "Subscriber portal: the subscription detail 'Change plan' action opens a plan-picker panel listing available upgrade options with: plan name, new price, and an estimated delta line — 'You will be charged $X.XX now (prorated)' for prorate_now, 'Full new-price charge of $X.XX now; unused days forfeited' for overcapture, or 'No charge today — new price applies at your next renewal on [date]' for skip_and_bill_next. The copy is driven by the destination plan's upgrade_delta_capture_policy. A 'Confirm upgrade' button submits. Admin plan settings: the plan edit page includes an 'Upgrade delta-capture policy' select field with a tooltip explaining each option."
primary_action: "Subscriber: 'Confirm upgrade' — POST /api/v1/portal/subscriptions/{id}/upgrade with {to_plan_id}. Admin: 'Save plan' — includes upgrade_delta_capture_policy in the plan PATCH payload."
loading:
trigger: "Subscriber: POST /api/v1/portal/subscriptions/{id}/upgrade. Admin: PATCH /api/v1/admin/plans/{id}."
render: "Subscriber: the 'Confirm upgrade' button shows 'Upgrading…' and is disabled; the plan-picker panel is locked. If the API responds with REQUIRES_ACTION (SCA challenge required on the delta charge), the button state transitions to show a 3DS challenge panel rather than immediately showing a success state. Admin: the 'Save plan' button shows 'Saving…' and is disabled; all plan fields are locked during the PATCH."
error:
surfaced_at: "Subscriber portal: inline error message directly below the 'Confirm upgrade' button (role=alert), scoped to this upgrade attempt — covers plan unavailable, delta-charge declined (prorate_now / overcapture path), and SCA challenge abandoned. Admin: inline BigDesign Message(type='error') below the policy select (role=alert) on plan save failure."
recovery: "Subscriber — delta charge declined: the error message surfaces the failure reason with an 'Update payment method' CTA; the plan-picker stays open with the selected plan so the subscriber can retry after fixing their PM. SCA abandonment: an 'Authorization cancelled — try again' inline message resets to the plan-picker. Admin save failure: the form stays populated; the merchant corrects and resubmits."
empty:
render: "Subscriber portal: if no upgrade-eligible plans exist (only one plan on the product, or all plans are at the same or lower tier), the 'Change plan' affordance either does not render or shows a panel with copy 'No upgrade options are currently available for this subscription.' Admin plan settings: the policy select always renders on the plan edit page; there is no empty-plans state for this control."
inputs:
- field: "to_plan_id"
control: "select"
label: "'Select new plan' — subscriber picks the upgrade destination plan"
allowed_values: "plans available for upgrade from the current subscription's product, filtered to same-product higher-tier options; each option displays name, price, and delta-charge estimate based on the destination plan's policy"
- field: "upgrade_delta_capture_policy"
control: "select"
label: "'Upgrade delta-capture policy' — admin plan setting governing how mid-cycle upgrade charges are computed"
allowed_values: "prorate_now | skip_and_bill_next | overcapture"
edge_status:
- status: "policy = prorate_now; delta charge succeeds"
affordance: "Subscriber sees a success confirmation naming the prorated amount charged and the new plan; next_charge_at is unchanged; the subscription detail reflects the new plan immediately."
- status: "policy = skip_and_bill_next"
affordance: "Subscriber sees 'Plan updated — new price applies at your next renewal on [date]'; no immediate charge fires; subscription detail shows a 'Plan change pending' badge until the next cycle."
- status: "policy = overcapture; unused days forfeited"
affordance: "The confirmation step in the plan-picker requires an explicit 'I understand unused days are not refunded' acknowledgement before the 'Confirm upgrade' button becomes active — never a silent forfeit."
- status: "SCA / 3DS challenge required on the delta charge (REQUIRES_ACTION)"
affordance: "A 3DS challenge panel opens (processor-hosted iframe or redirect); on successful completion the delta charge captures and the plan change is applied; on failure or abandonment the inline error state fires with the CTA to retry."
- status: "destination plan has a different policy than the source plan"
affordance: "The plan-picker clearly labels the destination plan's policy ('This plan uses [policy name]: [description]') before the subscriber confirms — no surprise billing behavior post-confirm."
disabled_focus:
keyboard: "Subscriber portal: the plan-picker options are real radio inputs or a select element reachable via Tab; 'Confirm upgrade' is a real button (not a div-onClick); the SCA challenge panel traps focus while open and returns focus to the subscription detail on completion or cancellation. Admin: the policy select is a real BigDesign Select with a label, reachable in Tab order alongside the other plan fields; 'Save plan' is activatable with Enter/Space."
US-14.7: NTI verification charge before stale-NTI renewal (PRD-COMPANION D21)
<!-- traceability:start:US-14.7 --><!-- traceability:end:US-14.7 -->Prototype: Renewal screens (term-end / mid-cycle / NTI)
Phase: P2 · Persona: System / Subscriber
As the System orchestrating renewals for long-cycle (annual+) subscriptions, I want to fire a $0-or-$1 verification charge before the renewal MIT when the network_transaction_id has aged past freshness threshold, so that the SCA-exemption chain is re-primed and the renewal MIT is unlikely to decline on stale-NTI grounds.
Acceptance criteria:
- Given
subscriptions.last_nti_refreshed_atis more thanmerchant.nti_freshness_days(default 365) ago at scheduled-charge time, When the renewal worker runs, Then aMUSE(member-initiated) verification charge fires before the renewal MIT. - Given the verification charge succeeds, When the response carries a fresh NTI, Then the new NTI is persisted to
subscriptions.last_nti_refreshed_at; the renewal MIT proceeds withMRECflag and the fresh NTI in the chain. - Given the verification charge fails (PM declined, expired, blocked), When the failure is logged, Then the renewal does not fire as MIT; the subscription routes to dunning as if the renewal itself had declined.
- Given the verification is
$0(account-verification mode) vs$1(small-test-charge mode), When the merchant has configured the mode, Then the appropriate processor call shape fires; both succeed-or-fail outcomes route identically. - Given the merchant explicitly opts-out of NTI freshness (
nti_freshness_days = 0), When stale NTIs would otherwise trigger verification, Then renewals proceed as plain MIT and any decline is treated as a normal dunning event.
Data contract. New columns: subscriptions.last_nti_refreshed_at (timestamptz), merchant_settings.nti_freshness_days (int, default 365). New charge sub-state: verification (distinguished from regular processing). New event subscription.nti_refreshed.
Dependencies. PRD-COMPANION D17 (existing NTI persistence). US-11.X (dunning routing for verification-failure).
UI states.
<!-- ui-states US-14.7 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) store settings — an 'NTI freshness' configuration section where the merchant sets nti_freshness_days and the verification charge mode. (2) Subscriber-facing: no new UI on the success path (the verification fires silently before renewal); on verification failure the existing dunning notification email flow (US-11.6) fires, routing the subscriber to the standard update-PM experience."
idle:
render: "Admin settings: a section titled 'Subscription renewal — NTI freshness' containing two fields: 'NTI freshness window (days)' (number input, default 365) and 'Verification charge mode' (select: Account verification ($0) / Small test charge ($1)). Help text beneath the section reads: 'For annual and long-cycle subscriptions, a small verification charge re-primes the SCA exemption chain before renewal, reducing MIT declines on stale payment credentials. Set to 0 to disable.' When nti_freshness_days is 0, the Verification charge mode select is visually dimmed with copy 'Disabled — renewals proceed as plain MIT'. Subscriber-facing success path: no UI change — the subscription renews normally and the verification charge is not surfaced to the subscriber."
primary_action: "Admin: 'Save settings' persists nti_freshness_days and nti_verification_mode via PATCH /api/v1/admin/settings/store. Subscriber-facing: no subscriber action on the success path."
loading:
trigger: "Admin: PATCH /api/v1/admin/settings/store on save. Background: the NTI verification charge fires as a MUSE processor call in the renewal worker — no subscriber-facing loading state on the success path."
render: "Admin: the 'Save settings' button shows 'Saving…' and is disabled; the two NTI fields are disabled during the PATCH. Subscriber-facing success path: no visible state — the renewal proceeds after the verification resolves, typically within a few seconds."
error:
surfaced_at: "Admin settings: inline BigDesign Message(type='error') below the NTI freshness fields (role=alert) when the settings save fails — never a toast. Subscriber-facing verification failure: the standard dunning notification email (US-11.6) fires, carrying the same 'We were unable to process your payment' subject and 'Update payment method' CTA as any other dunning event — no separate 'verification failed' wording is shown to the subscriber."
recovery: "Admin: the NTI settings form stays populated; the merchant corrects and resubmits. Subscriber on verification failure: the dunning email CTA directs the subscriber to update their payment method in the portal; after PM update and a fresh NTI is captured by the next MUSE call, the renewal retries via the standard dunning schedule."
empty:
render: "Admin settings: the NTI freshness section always renders as a singleton configuration; there is no 'no data' empty state. When nti_freshness_days = 0 (opt-out), the section renders the Verification charge mode select in a disabled state with a note — the form is not hidden. Subscriber-facing success path: the absence of any verification-related notification IS the correct experience when verification succeeds."
inputs:
- field: "nti_freshness_days"
control: "number"
label: "'NTI freshness window (days)' — how many days after the last NTI capture before a verification charge fires; 0 disables the feature entirely"
validation: "Required; non-negative integer; 0 = opt-out; default 365."
- field: "nti_verification_mode"
control: "select"
label: "'Verification charge mode' — the processor call shape for the pre-renewal verification"
allowed_values: "account_verification_zero | small_test_charge_one_dollar"
edge_status:
- status: "verification charge succeeds; fresh NTI captured"
affordance: "No subscriber notification. The new NTI is persisted to subscriptions.last_nti_refreshed_at; the renewal MIT fires immediately using the fresh NTI. A subscription.nti_refreshed system event is emitted for ops monitoring."
- status: "verification charge fails (card declined, expired, or blocked)"
affordance: "Renewal does not fire as MIT. The subscription routes to dunning. The subscriber receives the standard dunning email (US-11.6) — no 'verification failed' wording distinguishes it from a regular renewal failure from the subscriber's perspective."
- status: "merchant opted out (nti_freshness_days = 0)"
affordance: "No verification charge fires; renewals proceed as plain MIT. The Verification charge mode select is visually dimmed in admin settings with a note clarifying the opt-out."
- status: "verification mode = small_test_charge_one_dollar; $1 is captured"
affordance: "The $1 is retained (not refunded after use — disclosed in the admin field tooltip before save). The captured NTI is used for the renewal MIT. A system event distinguishes the $1 verification charge from the full renewal so the merchant's reconciliation reports are accurate."
disabled_focus:
keyboard: "Admin: the nti_freshness_days number input and the verification mode select are real BigDesign components, each with an associated label, reachable in Tab order alongside other store settings. The Verification charge mode select is visually dimmed when opt-out is active but remains in the tab order so keyboard users can observe its state; a screen reader reads the disabled label. 'Save settings' is a real Button activatable with Enter/Space."