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 19 — Payment method & address management (derived view)
Read-only per-epic slice of
BRD.md§9, lines 7094–7498. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-19.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (6): US-19.1, US-19.2, US-19.3, US-19.4, US-19.5, US-19.6
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 19 — Payment method & address management
<!-- traceability:start:BRD:Epic-19 --><!-- traceability:end:BRD:Epic-19 -->Prototype: Payment Methods · Update Card · Address Book · Per-Sub Assignment · Update Billing Address
Value: Subscribers can update PM and address without support tickets; address changes trigger correct tax/shipping recalc.
US-19.1: Update payment method
<!-- traceability:start:US-19.1 --><!-- traceability:end:US-19.1 -->Prototype: Payment Methods · Update Card · Magic Link Login · Subscriptions List · Subscription Detail · Cancel Flow · Update Payment Method · Payment Method Updated — All Subs · Multi-Actor Subscription Detail · Delivery Schedule (cadence ≠ billing) · Pending start (deferred activation)
Phase: MVP · Priority: P0 · Effort: L · Persona: Subscriber
As a Subscriber, I want to update my card on file, so that renewals don't fail when I get a new card.
Acceptance criteria:
- Given I click "Update payment method" on a subscription, When the secure form loads (processor-hosted element), Then I enter new card details and submit.
- Given the update succeeds, When it resolves, Then
payment_method_refon the subscription points to the new PM and dunning retries (if any) reset.
UI states.
<!-- ui-states US-19.1 -->surface: "/account/subscriptions + /subscriptions (SubscriberPortalApp.svelte) inline 'Update payment method' panel; also /portal/subscriptions/[id] via SubscriptionStatusActions.svelte — both mount UpdatePaymentForm.svelte"
idle:
render: "An inline panel headed 'Update payment method' (or 'Update card on all N subscriptions' in cross-sub mode). BC Payments rail (default): a radio list of the shopper's BC-vaulted cards (brand, last-4, exp) under 'Select a saved card', plus '+ Add a new card'. Stripe rail: a processor-hosted Stripe PaymentElement iframe. Raw card data never enters our DOM (ADR-0037 / PCI SAQ-A)."
primary_action: "Pick a saved card then 'Use this card' (BC vault path) or 'Save payment method' (Stripe path); on success payment_method_ref points at the new PM and, when the subscription was past_due, dunning retries reset (US-11.5)."
loading:
trigger: "GET /api/v1/portal/stored-instruments (BC vault list) or Stripe loadStripe + PaymentElement mount; submit is PUT /api/portal/subscriptions/{id}/payment-method (Stripe) or PUT stored-instruments/select/{id} (BC vault)."
render: "BC path shows 'Loading saved cards…'; Stripe path shows a spinner reading 'Loading secure card form…' with the mount hidden until ready. On submit the action button reads 'Saving…' and is disabled — no double-submit."
error:
surfaced_at: "Inline inside the panel — a role=alert <p data-testid=form-error> beneath the controls; the vault picker also renders its own load failure inline as role=alert. Never a vanishing toast."
render: "The specific failure reason — the gateway/decline message, 'Stripe is not configured for this store', a 'Failed to save payment method (status)' line, or 'Could not open payment form — please allow popups for this site.'"
recovery: "Re-select a card or '+ Add a new card' and resubmit; the action button re-enables after a failure. When the Stripe SetupIntent is unprovisioned the panel surfaces a clear message and directs the shopper to contact support (provisioning gap — see gapNotes)."
empty:
render: "When the BC vault returns zero instruments the picker renders 'No saved cards found. Add one below.' — never a blank panel."
cta: "'+ Add a new card' opens the BC hosted add-instrument flow; the panel refetches the vault list on return."
inputs:
- field: "saved_card"
control: "radio"
allowed_values: "The shopper's BC-vaulted instruments fetched at runtime from GET /api/v1/portal/stored-instruments — one radio per instrument (brand •••• last-4 — exp MM/YYYY), default instrument flagged. Constrained selection, never a free-text token entry."
- field: "card_details"
control: "hosted"
allowed_values: "Collected only inside the processor-hosted element (Stripe PaymentElement iframe / BC vault) — no raw PAN or CVV field exists in our DOM (PCI SAQ-A)."
edge_status:
- status: "past_due — last charge failed, subscription in dunning"
affordance: "'Update payment method' is the single recovery CTA on the past_due row; selecting or adding a chargeable card resets retry_attempt and enqueues an immediate retry (US-11.5), returning the subscription to active."
- status: "active — proactive card replacement before a renewal fails"
affordance: "Open the 'Update payment method' panel from the active subscription row; the new payment_method_ref applies to the next charge."
- status: "cross-sub mode — subscriptionId is null ('Update card on all N subscriptions')"
affordance: "North-star: one wallet-level update applies the new card to all N active subscriptions by next renewal (US-19.6). Today the panel offers only the per-subscription fallback — pick a card under each subscription individually (gap — see gapNotes)."
- status: "bc_payments rail — vault-only, tokenization pending (P-4)"
affordance: "A role=status banner explains updates use the existing BC vault selection flow; the shopper selects an already-vaulted card or adds one via the hosted IAT flow rather than typing a new card here."
disabled_focus:
keyboard: "Card choices are real <input type=radio name=stored-instrument> inside a <fieldset>/<legend>, reachable by Tab plus arrow keys; 'Use this card' is a real <button> disabled until a radio is selected (disabled={!selectedToken || saving}); 'Save payment method', '+ Add a new card', Cancel, and the aria-label='Close' (×) are all real <button>s in tab order — no div-onClick dead-ends."
focus_move: "On open, focus moves into the panel — to the first saved-card radio (BC vault) or the Stripe PaymentElement; on Cancel, Close (×), or a successful update, focus returns to the 'Update payment method' trigger that opened it, and the row re-renders (active, or recovering from past_due) under aria-live=polite. Not yet wired — the mount path runs no focus()/autofocus today (gap — see gapNotes)."
guard: "Selecting a card is a single intentional, reversible action (no typed-confirm); submit is disabled while saving to prevent double charge-rail calls; raw card entry is delegated to the processor-hosted element only."
UX notes.
- Surface: subscription detail → "Update payment method"
- Form: BC hosted IAT (Instrument Access Token) for vault path — shopper selects from stored instruments or adds a new card via IAT, raw card data goes directly to
payments.bigcommerce.com; Stripe Elements for Stripe-direct path (ADR-0037) — we never touch card data - States: loading | ready for input | validating | success (new PM shown, last 4 digits, brand icon) | error (specific message, retry CTA)
- Success: if subscription was in dunning (past_due), immediately trigger retry (see US-11.5)
Data contract.
- BC vault path: shopper selects from stored instruments via BC hosted IAT; vault token returned; we POST to
/api/v1/subscriptions/{id}/payment-methodwith{vault_token}. Stripe-direct path: Stripe Elements tokenization returns{processor_pm_token}. - Backend:
- Adapter.verifyPaymentMethod(token) — confirms PM is chargeable (optionally $0 auth)
- Adapter.attachToCustomer(customer, token) — for Stripe-direct: stores at Stripe; for vault path: BC vault already holds the instrument
- Update
subscriptions.payment_method_ref = new_pm_id - If charge(s) in dunning for this sub: reset retry_attempt, enqueue immediate retry
- Events:
subscription.payment_method_updated, optionalcharge.retry_queued
Success metrics.
- Functional (target): PM update success rate ≥ 98%
- Product (target): % of past_due subscribers who recover within 48h of PM update ≥ 80%
- Operational (target): P95 update flow < 5s (dominated by vault token fetch + BC API roundtrip)
Dependencies.
- US-11.5 (retry on PM update)
- BC stored-instruments IAT (vault path) or Stripe Elements (Stripe-direct path) — see ADR-0037
Non-functional.
- No card data touches our servers (PCI SAQ-A boundary)
- CSRF token required on the form; rate limit 10 attempts per subscriber per hour
US-19.2: Default vs per-subscription PM
<!-- traceability:start:US-19.2 --><!-- traceability:end:US-19.2 -->Prototype: Per-Sub Assignment
Phase: MVP · Persona: Subscriber
As a Subscriber with multiple subscriptions, I want to update a single default PM that applies to all my subs, or a per-subscription PM, so that I control granularly.
Acceptance criteria:
- Given I have 3 subscriptions, When I update "Default PM," Then all 3 subs point at the new default.
- Given I instead update PM on a specific subscription, When I save, Then only that sub is affected.
UI states.
<!-- ui-states US-19.2 -->surface: "Storefront subscriber portal (Svelte) — SubscriberPortalApp.svelte. Two entry points into UpdatePaymentForm.svelte: (1) the cross-sub wallet banner 'Update card on all <N> subscriptions', shown when activeSubs.length > 1 (openUpdatePm(null) -> cross-sub mode), and (2) a per-row 'Update payment method' button (openUpdatePm(sub.id) -> single-sub override). Persona: Subscriber with multiple subscriptions."
idle:
render: "With more than one active subscription, a banner ('One card, every subscription healthy' / data-demo portal-pm) renders an 'Update card on all' button; every active row additionally exposes 'Update payment method'. Opening either mounts UpdatePaymentForm — on the default BC Payments rail it shows a pending banner (data-testid bc-payments-pending-banner) and the StoredInstrumentsPicker saved-card radio list."
primary_action: "Per-sub: pick a saved card -> 'Use this card' updates only that subscription (handleSelected scope 'single'). Cross-sub (north-star): pick a card once -> applies to all N active subscriptions (handleSelected scope 'all')."
loading:
list: "While subscriptions load, SubscriberPortalApp renders an animate-pulse skeleton (aria-label 'Loading subscriptions')."
picker: "StoredInstrumentsPicker shows 'Loading saved cards...' then 'Saving...' on 'Use this card'; the Stripe rail shows 'Loading secure card form...'."
error:
surfaced_at: "Inside the form a role=alert paragraph (data-testid form-error); the picker's own load failure is a role=alert; portal-level failures render a role=alert with a 'Sign in again' recovery for auth errors — never a vanishing toast."
render: "The failure reason (card-load error, selection/save error, or processor error message)."
recovery: "Re-pick a saved card and 'Use this card' again, or 'Add' a new card; for auth errors use 'Sign in again'. The selection persists across the retry."
empty:
render: "When the shopper has no vaulted cards, StoredInstrumentsPicker renders 'No saved cards found. Add one below.' with the AddStoredInstrumentButton beneath it; the portal list's own empty state ('No subscriptions yet...') is owned by US-17.1."
cta: "Add a card via AddStoredInstrumentButton, then select it."
edge_status:
- status: "default-PM fan-out across all active subscriptions (cross-sub 'Update card on all', AC1)"
affordance: "North-star: pick a saved card in the cross-sub form -> it applies to all N active subs and confirms 'Card ending <x> now powers all <N> subscriptions' (onPaymentUpdated 'all' branch). This control is not wired on either rail today (see gaps); the working affordance the form offers instead is to update each subscription individually via its own 'Update payment method' (UpdatePaymentForm lines 354-357)."
- status: "per-subscription PM override (AC2)"
affordance: "'Update payment method' on one row -> pick a saved card -> only that subscription changes (handleSelected scope 'single'); confirmed 'Card ending <x> updated on this subscription'."
- status: "no saved cards on file"
affordance: "'No saved cards found. Add one below.' + AddStoredInstrumentButton to vault a card, then select it."
inputs:
- field: "saved_card"
control: "radio"
allowed_values: "the shopper's vaulted BC instruments (StoredInstrumentsPicker radio group; '<brand> dots <last4> — exp mm/yyyy')"
- field: "new_card"
control: "hosted"
allowed_values: "processor-hosted entry — Stripe PaymentElement iframe (Stripe rail) or AddStoredInstrumentButton vault flow (BC rail); raw PAN never enters the DOM"
disabled_focus:
keyboard: "Every control is reachable in tab order — the banner 'Update card on all' button, each row's 'Update payment method', the saved-card radio group, 'Use this card', 'Cancel', and the close control — all real button/input elements; no div-onClick. Visible focus ring on each."
focus_move: "On success the form closes and a polite toast (role=status, aria-live=polite) confirms the single/all scope; north-star returns focus to the originating trigger."
guard: "Selecting a card is a single intentional click (non-destructive); while saving every control disables to block double-submit."
gaps: "Cross-subscription default-PM fan-out (AC1) is NON-FUNCTIONAL on both rails. BC Payments: StoredInstrumentsPicker only mounts when subscriptionId is truthy (UpdatePaymentForm line 344), so cross-sub mode renders only the 'Bulk payment-method update is coming soon' copy (lines 353-357) with no card control. Stripe: cross-sub is explicitly 'coming soon' (lines 271-275) and handleStripeSubmit aborts cross-sub (lines 170-173). handleSelected computes scope 'all' (lines 235-241) but is unreachable in cross-sub mode. The per-sub override (AC2) is fully functional."
US-19.3: Update shipping address
<!-- traceability:start:US-19.3 --><!-- traceability:end:US-19.3 -->Prototype: Address Book
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to update my shipping address, so that future shipments go to the right place.
Acceptance criteria:
- Given I open address editor, When I save a new address, Then the next charge uses that address for tax/shipping recalc.
- Given the new address is in a country the plan doesn't allow, When I save, Then I'm blocked with a clear message (per US-7.3).
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-19.3 -->surface: "/account/subscriptions (also /subscriptions) — inline UpdateShippingAddressForm.svelte rendered per-subscription card inside SubscriberPortalApp.svelte, opened by the 'Change shipping address' button (data-testid change-shipping-address-btn)"
idle:
render: "The inline address-edit panel (header 'Update shipping address' with a × close): First name, Last name, Street address, Apt/suite (optional), City, State / Province, Country, Postal / ZIP, Phone (optional). North-star pre-fills the subscription's current shipping address; today the fields open blank with Country defaulting to 'US'."
primary_action: "Save address → PATCH the new address; on success the panel closes, the list reloads, and the next charge recalculates tax/shipping for the new address (AC1)."
loading:
trigger: "PATCH /api/v1/portal/subscriptions/{id}/shipping-address (apiClient.updateShippingAddress)"
render: "The Save button shows 'Saving…' and is disabled; every field, the Cancel button, and the × close are disabled while busy — no double-submit."
error:
surfaced_at: "Inline beneath the fields and above the action row — a role=alert paragraph (data-testid shipping-address-error), scoped to this form, never a vanishing toast."
render: "The failure reason: the client guard 'Country code must be exactly 2 uppercase letters (e.g. US, GB, CA).', or the server reason — a plan-disallowed shipping country (US-7.3) or a tax/shipping recalc / gateway failure."
recovery: "Correct the named field — e.g. pick an allowed country — and re-submit Save; the entered values are preserved across the retry."
empty:
render: "Not a list surface — a single-subscription shipping-address form; the subscriptions-list empty state is owned by the portal home (US-17.1)."
cta: "n/a (single-subscription form)"
inputs:
- field: "first_name"
control: "text"
- field: "last_name"
control: "text"
- field: "street_1"
control: "text"
- field: "street_2"
control: "text"
- field: "city"
control: "text"
- field: "state_or_province"
control: "select"
allowed_values: "ISO-3166-2 subdivisions for the selected country; free-text fallback only for countries with no enumerated subdivision list"
- field: "country_iso2"
control: "select"
allowed_values: "BC store-enabled ISO-3166 alpha-2 countries, narrowed to the plan's allowed shipping countries (US-7.3); defaults to 'US'"
- field: "postal_code"
control: "text"
- field: "phone"
control: "text"
edge_status:
- status: "country not allowed by the plan (US-7.3)"
affordance: "The Country select offers only plan-allowed shipping countries, so the block is prevented at input; if a disallowed country is still submitted the inline role=alert names it and the user picks an allowed country or contacts the merchant — never a silent block."
- status: "validation rejected — required field missing or malformed country code"
affordance: "Inline role=alert names the offending field; the user corrects it (the constrained Country/State selects remove the malformed-code case) and re-submits with values preserved."
- status: "save failed — tax/shipping recalc or gateway error (5xx / network)"
affordance: "Inline error with a Retry path (re-submit Save); entered values are preserved so there is no re-keying."
disabled_focus:
keyboard: "Every control is a real <input>/<select>/<button> reachable in tab order — the × close, all nine fields, Save, and Cancel; no div-onClick. The Country and State selects are keyboard-operable with type-ahead, replacing the raw-text ISO input; each control shows a visible focus ring."
focus_move: "Opening the panel moves focus to the first field (First name); on success the panel closes and focus returns to the 'Change shipping address' trigger; the reload surfaces an aria-live=polite confirmation."
guard: "Save is a single intentional submit (a non-destructive address change) — no typed-confirm; while busy every control disables to block double-submit."
US-19.4: Update billing address
<!-- traceability:start:US-19.4 --><!-- traceability:end:US-19.4 -->Prototype: Update Billing Address
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to update my billing address separately from shipping, so that each is correct.
Acceptance criteria:
- Given I toggle "Different billing address" in the PM editor, When I enter and save, Then the billing address is stored with the PM.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-19.4 -->surface: "/account/subscriptions and /subscriptions — inline billing-address form on an active subscription row (UpdateBillingAddressForm.svelte, mounted by SubscriberPortalApp.svelte)"
idle:
render: "On an active subscription, a 'Change billing address' button (data-testid=change-billing-address-btn) sits in the row's action group; clicking it mounts the inline form with nine fields — first name, last name, street address, apt/suite (optional), city, state/province, country (defaulted to 'US'), postal/ZIP, phone (optional) — plus 'Save address' and 'Cancel'."
primary_action: "Save address — PATCHes the billing address onto this subscription, stored separately from the shipping address (US-19.3)."
loading:
trigger: "PATCH /api/v1/portal/subscriptions/{id}/billing-address (apiClient.updateBillingAddress)"
render: "The submit button reads 'Saving…' and is disabled; every input plus the Close (×) and Cancel buttons are disabled while the request is in flight (busy); no double-submit."
error:
surfaced_at: "Inline, beneath the form fields and above the action buttons (role=alert, data-testid=billing-address-error), scoped to this form — never a toast that vanishes."
render: "The failure reason — either the client-side guard 'Country code must be exactly 2 uppercase letters (e.g. US, GB, CA).' or the API error message thrown by updateBillingAddress."
recovery: "Correct the flagged field and press 'Save address' again; or press 'Cancel' / the Close (×) to dismiss without saving."
empty:
render: "Not a list surface — a single-subscription billing-address form; the subscriptions-list empty state is owned by the portal home (US-17.1)."
cta: "n/a (single-subscription form surface)"
inputs:
- field: "country"
control: "select"
allowed_values: "ISO-3166-1 alpha-2 list (US, GB, CA, …); component currently defaults to 'US'."
- field: "state_or_province"
control: "select"
allowed_values: "subdivisions of the selected country (US states / CA provinces); free-text fallback only for countries with no enumerated subdivision set."
edge_status:
- status: "subscription is paused or cancelled — billing edit not offered"
badge: "none — the action group renders only on an active row"
affordance: "Resume (US-18.2) or Reactivate (US-18.7) the subscription first; the 'Change billing address' affordance returns once it is active."
- status: "B2B junior buyer — changes require company-admin approval"
badge: "read-only notice"
affordance: "A 'Contact your admin to request a modification' notice replaces the edit affordance (SubscriberPortalApp.svelte:447-452); the subscriber requests the change through their company admin."
disabled_focus:
keyboard: "Every field is a real <input> and both Save and Cancel are real <button>s reachable in tab order; the Close control is a real <button> with aria-label Close, never a div-onClick. While busy, all controls are disabled and skip the tab order."
focus_move: "North-star: on open, focus moves to the first field (First name); on a successful Save the form unmounts (closeModal), a polite toast announces the update, and focus returns to the 'Change billing address' trigger; Cancel and Close also return focus to the trigger."
guard: "Save and Cancel are single intentional clicks — non-destructive, no typed-confirm. Discarding unsaved edits via Cancel/Close is unguarded (minor)."
US-19.5: Subscriber preferences
Phase: P3 · Persona: Subscriber
As a Subscriber, I want to record preferences (allergens, dislikes, sizes) that merchants use to personalize curated shipments, so that I don't get what I can't use.
Acceptance criteria:
- Given the merchant has enabled the preferences schema, When I edit preferences in the portal, Then the updated values are available to the merchant's curation logic (exposed via API / in the merchant admin).
- Given a curated renewal runs, When the curator (rule engine or human) picks a SKU, Then preferences are respected.
UI states.
<!-- ui-states US-19.5 -->surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) — 'Your preferences' panel within the portal account page or subscription detail view; rendered only when the merchant has enabled a preferences schema for their store; allows subscribers to record allergens, dislikes, and size preferences used by the merchant's curation logic for personalised shipments. Persona: Subscriber. Phase 3."
idle:
render: "A 'Your preferences' section shows each merchant-configured preference category as a labeled group (e.g. 'Allergens to avoid', 'Dislikes', 'Preferred size'). Each category displays the subscriber's currently saved values, or 'Not set' if none have been entered. An 'Edit preferences' button opens an inline or panel form with inputs for each category. A note reads 'Your merchant uses these to personalise your future shipments.'"
primary_action: "Edit preferences → opens the inline preference form; 'Save preferences' commits via PATCH /api/v1/portal/preferences with the updated category values; 'Cancel' dismisses the form without saving."
loading:
trigger: "PATCH /api/v1/portal/preferences while the save request is in-flight."
render: "'Save preferences' button shows 'Saving…' and is disabled; all preference inputs (allergen select, dislikes select, size select) are disabled; no double-submit."
error:
surfaced_at: "Inline beneath the preference form controls as a role=alert paragraph, scoped to the preference save — never a vanishing toast."
render: "The API failure reason (e.g. 'Couldn't save preferences. Try again.' or a validation message for an unsupported preference value)."
recovery: "The form controls re-enable; the subscriber corrects or re-confirms their preferences and clicks 'Save preferences' again. 'Cancel' exits the form without losing previously saved values."
empty:
render: "Before any preferences are saved, each category shows its label and 'Not set' beneath it. The form fields open with placeholder guidance ('None selected', 'Choose your size'). The section is never blank — category labels and 'Not set' placeholders are always shown as the starting state."
cta: "Click 'Edit preferences' to set your preferences — your merchant uses these to personalise upcoming shipments."
edge_status:
- status: "merchant preferences schema not enabled"
affordance: "The 'Your preferences' section is not rendered. Subscribers whose merchant has not enabled preferences see only the standard subscription management view — no preferences affordance is shown and no explanation of the absence is given."
- status: "preferences saved — no curated subscription event has run yet"
badge: "Saved"
affordance: "The section shows the saved values with the note 'Your preferences are saved. Your merchant will use these when personalising upcoming shipments.' No further subscriber action is required."
- status: "merchant adds a new preference category after the subscriber has saved others"
affordance: "The new category appears in the form with 'Not set' — the subscriber sees it on next visit and can fill it in; their existing preferences for other categories remain unchanged."
inputs:
- field: "allergens"
control: "select"
label: "Allergens to avoid"
allowed_values: "merchant-defined allergen list (e.g. Gluten | Dairy | Nuts | Soy | Shellfish | Eggs | None)"
- field: "dislikes"
control: "select"
label: "Dislikes"
allowed_values: "merchant-defined dislike categories (e.g. Spicy | Bitter | Seafood | Lamb | None)"
- field: "preferred_size"
control: "select"
label: "Preferred size"
allowed_values: "merchant-defined size options (e.g. XS | S | M | L | XL | XXL)"
disabled_focus:
keyboard: "The 'Edit preferences' button is a real <button> reachable in tab order, Enter/Space-activatable, with a visible focus ring. All preference inputs within the form are real <select> or <input type='checkbox'> elements inside <fieldset>/<legend> groups — never div-onClick. 'Save preferences' and 'Cancel' are real <button>s in tab order."
focus_move: "On form close (save success) focus returns to the 'Edit preferences' button; a portal toast (role=status, aria-live=polite) announces 'Preferences saved.' On error the role=alert paragraph is announced and the controls re-enable without losing the subscriber's entered values."
US-19.6: Update payment method across all subscriptions
<!-- traceability:start:US-19.6 --><!-- traceability:end:US-19.6 -->Prototype: Magic Link Login · Subscriptions List · Subscription Detail · Cancel Flow · Update Payment Method · Payment Method Updated — All Subs · Multi-Actor Subscription Detail · Delivery Schedule (cadence ≠ billing) · Pending start (deferred activation)
Phase: MVP · Priority: P1 · Effort: M · Persona: Subscriber
As a Subscriber with multiple active subscriptions, I want to update my saved card once and have all my subscriptions reflect the new card by next renewal, so that I don't manage payment per-subscription like the legacy Recharge experience.
Acceptance criteria:
- Given I have N active subscriptions, When I update my payment method at the customer/wallet level, Then all N subscriptions reflect the new PM by next renewal.
- Given the update succeeds, When I view subscription detail for any of those subscriptions, Then each shows "PM updated YYYY-MM-DD, applies to next charge."
- Given the update fails on the gateway, When I get the error, Then no subscription's PM is changed (atomic semantics — the update either lands across all N or none; partial states are not exposed to the subscriber).
- Given the update succeeds, When the system writes the audit trail, Then a single
eventsrow withactor_kind='subscriber'is written referencing all affected subscription IDs (per US-1.7 actor stamping).
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-19.6 -->surface: "/account/subscriptions + /subscriptions portal list (SubscriberPortalApp.svelte renders the 'One card, every subscription healthy' wallet banner when activeSubs.length > 1; its 'Update card on all' button opens UpdatePaymentForm.svelte in cross-sub mode — subscriptionId=null, isCrossSub)"
idle:
render: "With more than one active subscription, the wallet banner reads 'One card, every subscription healthy' and either 'Currently using {brand} •••• {last4} across all {N} subscriptions.' once a card is known, or 'Update the card once and it propagates to all {N} active subscriptions.' on first load (paymentMethod is null until an update lands)."
primary_action: "'Update card on all' — opens the cross-sub UpdatePaymentForm; the north-star is one saved-card selection that becomes the wallet default and applies to all N active subscriptions by next renewal (the ≤2-click PRD §14 differentiator)."
loading:
trigger: "On open, the saved-card fetch (StoredInstrumentsPicker GET /api/v1/portal/stored-instruments); on submit, the wallet default-PM write (north-star adapter setDefaultPaymentMethod(payment_customer_ref, new_payment_method_ref); today the per-instrument PUT /api/v1/portal/stored-instruments/select/{id})."
render: "While cards load the picker shows 'Loading saved cards…'; on submit the action button switches to 'Saving…' and is disabled so the update cannot double-submit."
error:
surfaced_at: "Inline within the cross-sub form, in a role=alert paragraph (data-testid='form-error') directly beneath the card picker — the subscriber sees the failure on the same surface they acted on, never a toast that vanishes."
render: "The gateway or save failure reason (e.g. 'Failed to save payment method (502)…' from the PUT, or StoredInstrumentsPicker's load error)."
recovery: "Re-select a card and retry; per atomic semantics (AC3) a failed update leaves every subscription on its prior card — no partial state is exposed — so the wallet is unchanged and the subscriber can retry, add a card via 'Don't see your card?' (AddStoredInstrumentButton), or 'Cancel' / '×' to dismiss."
empty:
render: "When the wallet holds no vaulted instruments the picker renders 'No saved cards found. Add one below.' — never a blank form; the 'Don't see your card?' add path is the only way forward."
cta: "Add a card (AddStoredInstrumentButton) — collect a new BC-vaulted instrument, then it becomes selectable as the wallet default."
inputs:
- field: "saved_card"
control: "radio"
allowed_values: "the subscriber's existing BC-vaulted instruments (StoredInstrumentsPicker radio group, labelled 'brand •••• last4 — exp MM/YYYY'); raw card data never enters this form (ADR-0037)"
edge_status:
- status: "bulk wallet update not yet wired (BC Payments rail — the store default)"
badge: "Coming soon"
affordance: "The cross-sub form surfaces the per-subscription fallback ('select a card under each individual subscription') — use each active row's 'Update payment method' button (US-19.1). North-star: a single wallet-level setDefaultPaymentMethod fans the new default to all N (gap — see gapNotes)."
- status: "bulk wallet update not yet wired (Stripe rail)"
badge: "Coming soon"
affordance: "Update each subscription individually — the per-row 'Update payment method' opens the Stripe Elements form scoped to one subscription (US-19.1); cross-sub Stripe is gated off today (gap — see gapNotes)."
- status: "single active subscription (N=1)"
badge: "n/a (banner suppressed)"
affordance: "The wallet banner does not render (it gates on activeSubs.length > 1); use the subscription's own 'Update payment method' button (US-19.1)."
- status: "past_due — last charge failed"
badge: "Past due (statusLabel)"
affordance: "Not a dead-end badge: the row renders 'Your last payment failed. Update your card to resume this subscription.' with an 'Update payment method' button (canManage-gated) that opens UpdatePaymentForm scoped to that one subscription (US-19.1) — the per-sub recovery, distinct from the wallet fan-out; once the card clears, the sub resumes and rejoins the activeSubs the wallet update covers."
- status: "paused or cancelled-in-grace — excluded from the fan-out"
badge: "Paused | Cancelled"
affordance: "Only status='active' subs are counted by activeSubs, so these inherit no wallet update yet; Resume (US-18.2) a paused sub or Reactivate within the 14-day grace (US-18.7) a cancelled one, after which it inherits the wallet default on its next charge."
disabled_focus:
keyboard: "'Update card on all', the saved-card radios, 'Use this card' / 'Save payment method', 'Add a card', and 'Cancel' / '×' are all real <button> / <input type=radio> elements reachable in tab order — never div-onClick; the radio group is a <fieldset> with a <legend>; visible focus ring throughout."
focus_move: "Opening the form reveals the inline picker beneath the banner; on a successful update the form closes (closeModal) and a fixed bottom-right role=status aria-live=polite toast (with a 'Dismiss notification' button) announces 'Card ending {last4} now powers all {N} active subscriptions.'; adding a card remounts the picker (pickerKey) to refetch."
guard: "Selecting a card is a single intentional click (non-destructive — it changes the default, it does not delete an instrument); no typed-confirm required; the submit button stays disabled until a card is selected and while saving."
Data contract.
- Schema:
customers.payment_customer_ref(D5, decided in synthesis 7574bb48) is the gateway-side customer reference. The default PM update path operates at the customer level: adapter call updates the customer's default PM at the gateway; subscriptions inherit on next charge. - Adapter contract: each adapter implements
setDefaultPaymentMethod(payment_customer_ref, new_payment_method_ref)with atomic semantics. Failure returns the old default unchanged. - Confirmation UX renders a list of N subscriptions affected with their next-renewal dates.
Success metrics.
- Functional: 100% of N subs reflect new PM by next charge after a successful update.
- UX: ≤ 2 clicks to update PM across N subscriptions for a customer with N>1 (PRD §14 differentiator metric).
- Operational (target): gateway atomic-update success rate ≥ 99% (treats partial-update failure modes as defect class).
Dependencies.
- Upstream: D5 /
customers.payment_customer_refschema (PR #62 schema migration). - Upstream: US-1.7 (
events.actor_user_id+actor_kindfor audit row). - Downstream: US-19.1 (per-subscription PM update) remains valid for cases where the subscriber wants to assign a non-default PM to a single subscription only.
Non-functional.
- Atomic semantics: the update is all-or-nothing at the gateway level. We do not implement N-way fan-out write-back; we update the customer-level default and let subscriptions inherit.
- Audit: one
eventsrow, not N — the action is one logical PM update, even though it affects N subscriptions. Theeventspayload lists affected subscription IDs. - Renewal latency: "by next renewal" means the next scheduled charge after the update timestamp, not retroactive. A charge in flight at update time uses the prior PM.