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 11 — Dunning, retries & decline recovery (derived view)
Read-only per-epic slice of
BRD.md§9, lines 4564–4855. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-11.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (7): US-11.1, US-11.2, US-11.3, US-11.4, US-11.5, US-11.6, US-11.7
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 11 — Dunning, retries & decline recovery
<!-- traceability:start:BRD:Epic-11 --><!-- traceability:end:BRD:Epic-11 -->Prototype: Retry Policy · Dunning Queue · Subscription Timeline · Email Sequence
Value: Recovery of soft-declined charges without destroying customer LTV.
<!-- normative-requirements US-10.9 - artifact: store/order/statusUpdated kind: event fit: BC webhook triggers adapter.capture() when order fulfillment status fires (Phase-2 on-fulfillment billing) closes: gap:#1721 -->US-11.1: Configurable dunning policy
<!-- traceability:start:US-11.1 --><!-- traceability:end:US-11.1 -->Prototype: Retry Policy
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to configure retry intervals and exhaustion behavior, so that dunning matches my brand and risk tolerance.
Acceptance criteria:
- Given I open dunning settings, When I edit the policy, Then I can set: retry intervals (array of hours),
on_exhaustion(cancel/pause/notify_only), grace period. - Given I save the policy, When new charges are scheduled, Then retries follow the new policy.
UI states.
<!-- ui-states US-11.1 -->surface: "admin · React/BigDesign — the 'Dunning Retry Policy' editor (apps/admin/src/pages/dunning/retry-rules/RetryRulesPage.tsx), routed at /dunning/retry-rules (App.tsx:121) and re-mounted unchanged under Settings → Store → Dunning (settings/store/Dunning.tsx)."
idle:
render: "A BigDesign Table of retry stages with columns 'Retry #', 'Delay (hours)', 'After last retry', 'Grace period (days)', preceded by an info Message glossing each field, followed by a 'Save policy' / 'Reset to defaults' button row and a Small footnote: 'Default: 5 retries at 12h / 12h / 24h / 48h / 72h intervals, then cancel.'"
primary_action: "Edit a stage's delay and the final stage's 'After last retry' action + grace period, then 'Save policy' → PUT /api/v1/admin/dunning/policy with the full stages array."
loading:
trigger: "GET /api/v1/admin/dunning/policy on mount (the `loading` flag, RetryRulesPage.tsx:84-89)."
render: "The entire page is replaced by a bare 'Loading dunning policy…' Text node (RetryRulesPage.tsx:143) — no spinner, no aria-live; the table and buttons are not yet mounted so there is nothing to double-submit. NORTH-STAR: render a table skeleton wrapped in an aria-live='polite'/aria-busy region so the load is announced (see gap notes)."
error:
surfaced_at: "Inline, in a BigDesign Message (type='error', header 'Error') rendered above the stages Table (RetryRulesPage.tsx:231-238), scoped to this page — never a toast that vanishes."
render: "The thrown reason verbatim — e.g. 'PUT dunning policy failed: 500', 'Reset dunning policy failed: 500', or 'NO_AUTH_TOKEN'."
recovery: "The 'Save policy' and 'Reset to defaults' buttons stay operable so the merchant can re-submit; a successful retry clears the error Message and shows the 'Policy saved.' success Message (RetryRulesPage.tsx:239-245)."
empty:
render: "When the policy returns zero stages, the Table renders only its column headers with no rows and no guidance (RetryRulesPage.tsx:247-251) — a blank surface. NORTH-STAR: render an explicit empty state, 'No retry stages configured. Choose Reset to defaults to restore the standard 5-stage policy.' (see gap notes)."
cta: "'Reset to defaults' — POST /api/v1/admin/dunning/policy/reset re-seeds the default stages (RetryRulesPage.tsx:64-69, 128-141)."
inputs:
- field: "on_exhaustion"
control: "select"
allowed_values: "cancel | pause | ignore — option labels 'Cancel subscription' / 'Pause subscription' / 'Keep active (no action)' (ON_EXHAUSTION_LABELS, RetryRulesPage.tsx:71-75)."
note: "Rendered only on the final stage row as a bare <select> with no BigDesign FormGroup/Label (RetryRulesPage.tsx:170-177); enumerable, so modeled as a select. NORTH-STAR wraps it in a FormGroup + programmatic Label (see gap notes)."
- field: "delay_hours"
control: "number"
note: "Per-stage bare <input type='number' min=0 step=0.5> (RetryRulesPage.tsx:155-162); non-finite or negative input is rejected client-side (handleDelayChange:91-97). No FormGroup/Label."
- field: "grace_period_days"
control: "number"
note: "Rendered only on the final stage row as a bare <input type='number' min=0> integer (RetryRulesPage.tsx:187-193); negative/non-finite rejected (handleGraceChange:105-111). No FormGroup/Label."
edge_status:
- status: "Exhaustion action = cancel — set on the final stage's 'After last retry' select."
badge: "cancel"
affordance: "Select option 'Cancel subscription'; on exhaustion the subscription transitions to cancelled. Reachable via the final-row 'After last retry' <select> (RetryRulesPage.tsx:170-177)."
- status: "Exhaustion action = pause."
badge: "pause"
affordance: "Select option 'Pause subscription'; on exhaustion the subscription is paused so the subscriber can update their payment method. Set via the same select."
- status: "Exhaustion action = keep active (ignore)."
badge: "keep active"
affordance: "Select option 'Keep active (no action)'; on exhaustion further dunning automation is suppressed and the subscription stays active."
- status: "Policy saved — PUT/POST round-trip succeeded."
badge: "saved"
affordance: "BigDesign success Message 'Policy saved.' renders above the table after 'Save policy' or 'Reset to defaults' (RetryRulesPage.tsx:239-245); the Table re-renders with the persisted stages so the merchant sees the new policy."
disabled_focus:
keyboard: "Every control is a real, keyboard-reachable element in tab order: the per-stage 'Delay (hours)' and final-stage 'Grace period (days)' <input type='number'>, the final-stage 'After last retry' <select> (arrow-key operable), and the 'Save policy' / 'Reset to defaults' BigDesign <Button>s — real buttons with visible focus rings, never div-onClick. No control is a div-onClick or unreachable affordance."
focus_move: "On save/reset the success or error Message renders in place above the table; today focus stays on the triggering button and the Message carries no aria-live, so the outcome may go unannounced. NORTH-STAR: move focus to (or wrap in aria-live='polite') the result Message so screen readers announce save/error (see gap notes)."
guard: "While saving, both 'Save policy' and 'Reset to defaults' enter BigDesign isLoading (spinner, non-activatable) so the in-flight PUT/POST cannot be double-submitted (RetryRulesPage.tsx:255-271); 'Reset to defaults' is a non-destructive server re-seed of the defaults, so no typed-confirm is required."
# closes — #1851 loop-closer: the keyboard/SR contract is bound to a proving render assertion.
# The impl-sweep fixed the bare-unlabeled-inputs a11y gap (per-stage aria-labels on the delay /
# exhaustion / grace controls); this binding LOCKS it — the lint ERRORs if the assertion vanishes.
closes:
disabled_focus: "render:US-11.1:retry-inputs-labeled"
US-11.2: Retry execution
<!-- traceability:start:US-11.2 --><!-- traceability:end:US-11.2 -->Prototype: Subscription Timeline
Phase: MVP · Priority: P0 · Effort: L · Persona: System
As the System, I want failed charges to retry per policy, so that transient failures self-heal.
Acceptance criteria:
- Given a charge fails with a soft decline, When the workflow branches to dunning, Then it schedules a retry per the policy's next interval.
- Given all retries exhaust, When the on_exhaustion action triggers, Then subscription transitions to
cancelled/pausedper policy and a final email goes out.
Data contract.
- Workflow: on charge failure with soft-decline classification → read dunning policy → compute next retry at = now + policy.attempts[attempt_number].delay_hours → update charge.next_retry_at, charge.retry_attempt += 1 → schedule re-run
- After all retries exhausted → run on_exhaustion action (pause | cancel | notify_only)
Success metrics.
- Functional: dunning recovery rate ≥ 80% (target; calibrate against actual soft/hard decline mix once measured)
- Operational (target): retries fire within 1 hour of scheduled time at P95
Dependencies.
- US-10.2 (executor is the entry point)
- US-11.3 (classification)
- US-11.5 (subscriber PM-update resets counter)
US-11.3: Hard vs soft decline classification
<!-- traceability:start:US-11.3 --><!-- traceability:end:US-11.3 -->Prototype: Subscription Timeline
Phase: MVP · Priority: P0 · Effort: M · Persona: System
As the System, I want to treat hard declines (invalid card, do-not-honor) as non-retryable, so that I don't waste retries on hopeless cases.
Acceptance criteria:
- Given a processor returns a hard decline code, When classified by the adapter, Then no further retries are scheduled and the subscription moves to
past_duewith immediate subscriber notification. - Given a processor returns a soft decline (insufficient funds, network timeout), When classified, Then retries proceed per policy.
- Given a charge dispatch carries
mitContext, When the adapter handles it, Then the adapter normalizes the context to its processor-specific indicator before sending — BC Payments →MRECflag in BigPay payload + persisted NTI re-attached; Stripe →payment_method_options.card.mit_exemption.recurring=true+previous_network_transaction_id; Braintree (standalone) →transactionSource: 'recurring'+externalVault.previousNetworkTransactionId.
Data contract.
- Each processor adapter implements
classifyDecline(processorResponse): 'soft' | 'hard' | 'retry_not_needed' - Each adapter also normalizes
mitContextto its processor-specific indicator (per AC above and PRD §6.3.1) - Mapping for Stripe: hard =
card_declined: do_not_honor,card_declined: fraudulent,card_declined: stolen_card,invalid_number,expired_card(after ACU attempt); soft =insufficient_funds,processing_error, network timeouts - Mapping for BC Payments (Braintree under): similar network of codes; adapter owns the mapping
Success metrics.
- Functional: hard-decline subs never enter retry loops (verified by replay test)
- Product (target): on hard decline, subscriber receives PM-update email within 5 min
Dependencies.
- Per-adapter classification (tested per adapter)
Non-functional.
- Classification is data — mapping table is versioned and reviewed quarterly as processor response codes evolve
US-11.4: Automatic Card Updater integration
Phase: P2 · Persona: System
As the System, I want expired/replaced card numbers auto-updated via network services (Visa VAU, Mastercard ABU), so that most expirations don't even trigger dunning.
Acceptance criteria:
- Given the processor supports Automatic Card Updater (Stripe, Braintree), When a card is replaced by the issuer, Then the stored PaymentMethod's card details update automatically and no dunning occurs.
US-11.5: Subscriber-initiated PM update resets retries
<!-- traceability:start:US-11.5 --><!-- traceability:end:US-11.5 -->Prototype: Subscription Timeline
Phase: MVP · Persona: Subscriber
As a Subscriber with a failed charge, I want to update my payment method and have the charge retry immediately, so that I am back in good standing without waiting for the next scheduled retry.
Acceptance criteria:
- Given my charge has failed and I am in dunning, When I update my PM via the portal, Then the retry counter resets to 0 and a retry is enqueued within 1 minute.
UI states.
<!-- ui-states US-11.5 -->surface: "Storefront subscriber portal (Svelte) — a past_due subscription surfaces 'Update payment method' via SubscriptionStatusActions.svelte (data-demo status-action-past_due) and the inline past_due branch in SubscriberPortalApp.svelte; clicking it mounts UpdatePaymentForm.svelte (single-sub) with the StoredInstrumentsPicker saved-card list. Persona: Subscriber with a failed charge. Distinct from US-19.2 (default-vs-per-sub PM): here the trigger is dunning recovery and the contract is the post-update retry signal."
idle:
render: "The past_due row shows 'Your last payment failed. Update your card to resume this subscription.' beside an 'Update payment method' button (data-demo status-action-update-pm). Clicking opens UpdatePaymentForm: a saved-card radio list (StoredInstrumentsPicker) plus an 'Add' new-card affordance."
primary_action: "Pick a saved card -> 'Use this card' (handleSelect) updates the PM; per the AC the retry counter resets to 0 and a retry is enqueued within 1 minute server-side."
loading:
picker: "StoredInstrumentsPicker shows 'Loading saved cards...' on mount, then 'Saving...' on 'Use this card' (disabled while saving)."
stripe: "The Stripe rail shows 'Loading secure card form...' then 'Saving...' on submit."
error:
surfaced_at: "Inside the form a role=alert paragraph (data-testid form-error); the picker load failure is its own role=alert; the past_due action component surfaces failures as a role=alert (data-demo status-action-error), scoped to this subscription — never a vanishing toast."
render: "The failure reason (card-load error, selection/save error, or processor message)."
recovery: "Re-pick a saved card and 'Use this card' again, or 'Add' a new card and select it; the form stays open with selection preserved."
empty:
render: "When the shopper has no vaulted cards, StoredInstrumentsPicker renders 'No saved cards found. Add one below.' with the AddStoredInstrumentButton; this is the common past_due case (the failing card may be the only one on file)."
cta: "Add a card via AddStoredInstrumentButton, then select it to recover the subscription."
edge_status:
- status: "past_due — last charge failed (the entry state)"
affordance: "'Update payment method' opens the saved-card picker (SubscriptionStatusActions lines 77-85)."
- status: "PM updated successfully"
affordance: "North-star: confirm 'card updated — we are retrying your payment now' (AC: retry enqueued within 1 min, counter reset). Today the toast says only 'Card ending <x> updated on this subscription' (onPaymentUpdated single branch) and the subscriber learns the retry result when the row reloads to active (loadSubscriptions) — see gaps."
- status: "no saved cards / failing card is the only one"
affordance: "'No saved cards found. Add one below.' + AddStoredInstrumentButton to vault a working card, then select it."
- status: "PM update fails"
affordance: "Inline form-error; re-pick a card or add a new one and retry; selection preserved."
inputs:
- field: "saved_card"
control: "radio"
allowed_values: "the shopper's vaulted BC instruments (StoredInstrumentsPicker radio group)"
- field: "new_card"
control: "hosted"
allowed_values: "processor-hosted entry (Stripe PaymentElement / BC vault flow); raw PAN never enters the DOM"
disabled_focus:
keyboard: "Every control is reachable in tab order — 'Update payment method', the saved-card radio group, 'Use this card', 'Cancel', the close control — all real button/input elements, no div-onClick, each with a visible focus ring; controls disable while saving."
focus_move: "On success the form closes and a polite toast (role=status, aria-live=polite) confirms the update; north-star also announces that a retry is in progress and returns focus to the row."
guard: "Selecting a card is a single intentional click (non-destructive); disabled-while-saving blocks double-submit."
gaps: "On a successful PM update from past_due the UI gives NO signal that the immediate retry was enqueued (AC: retry within 1 min, counter reset to 0). The success toast (SubscriberPortalApp onPaymentUpdated, single branch) reads only 'Card ending <x> updated on this subscription' and auto-dismisses after 5s (showToast); handleSelected (UpdatePaymentForm lines 235-241) carries no retry context. The subscriber only learns the outcome when the row eventually re-renders to active on the next reload."
US-11.6: Dunning notifications
<!-- traceability:start:US-11.6 --><!-- traceability:end:US-11.6 -->Prototype: Email Sequence
Phase: MVP · Persona: Subscriber
As a Subscriber, I want clear notifications at each dunning step, so that I know to act.
Acceptance criteria:
- Given a retry fails, When the next retry is scheduled > 24h out, Then an email is sent with: what went wrong, when the next retry is, a CTA to update PM.
- Given I have opted in to SMS, When a retry is imminent (< 6h), Then an SMS is sent with the update-PM link (P2).
UI states.
<!-- ui-states US-11.6 -->surface: "NOT YET BUILT — forward-looking contract. Transactional email (and optional SMS) sent to Subscriber at each dunning retry step — system-generated notifications from the dunning scheduler, not an interactive in-app UI surface."
idle:
render: "Email subject: 'We were unable to process your payment — next retry [date/time]'. Body contains three sections: (1) what went wrong (human-readable failure reason, e.g. 'Your card ending 4242 was declined'), (2) when the next retry fires (exact date and time), (3) a prominent 'Update payment method' CTA button linking to the subscriber portal /account/subscriptions/{id}/payment. SMS (P2, opt-in only, triggered when next retry is < 6h away): short message — '[store] renewal failed. Update payment: [short link]'."
primary_action: "'Update payment method' CTA deep-links to the subscriber portal payment-update flow; the link carries a time-limited signed token scoped to the subscription."
loading:
trigger: "POST to transactional email provider after each dunning retry failure is recorded. SMS POST fires separately when < 6h pre-retry window is reached for opt-in subscribers."
render: "Delivery is async — no in-app spinner is shown to the subscriber. A delivery attempt and its outcome are logged as system events (event_type: notification.delivery_attempted / notification.delivery_failed) visible to ops in the admin event log."
error:
surfaced_at: "Email delivery failure is surfaced in the admin exception queue as a system event — not shown to the subscriber directly. If the CTA link token has expired when the subscriber clicks it, the portal renders an inline error banner: 'This link has expired. Log in to update your payment method.'"
recovery: "On provider delivery failure the scheduler retries per the email provider's retry policy. An expired CTA link directs the subscriber to /account/login; after login they are forwarded to /account/subscriptions/{id} where the payment-update action is available."
empty:
render: "No notification fires when no active dunning event exists (subscription is active, paused, or fully cancelled). Silence is correct — there is no 'no notification sent' state visible to the subscriber."
edge_status:
- status: "subscriber has not opted in to SMS"
affordance: "Email fires on every retry cycle per the AC; SMS is skipped. No error is raised — the SMS gate checks the opt-in flag before attempting delivery."
- status: "next retry is < 6h away and subscriber is SMS-opted-in"
affordance: "SMS fires in addition to the email for that retry step; both carry the same update-PM deep link."
- status: "final dunning attempt — subscription will lapse on failure"
affordance: "Email subject and body include 'This is your final payment attempt' copy; tone escalates from 'update now' to 'act before your subscription is cancelled'. Same CTA link structure."
- status: "CTA link token expired before subscriber acts"
affordance: "Portal expired-link page renders a 'Log in to manage your subscription' button — never a bare 404 or unhandled error."
disabled_focus:
keyboard: "The 'Update payment method' CTA in the email is a real anchor (<a href>) not a div-onClick, reachable via keyboard navigation in any email client. A plain-text email fallback includes the full URL for clients that block HTML. The SMS deep link is a plain HTTPS URL, usable without JavaScript or pointer input."
US-11.7: Merchant-side dunning digest
<!-- traceability:start:US-11.7 --><!-- traceability:end:US-11.7 -->Prototype: Dunning Queue
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want a daily digest of subscribers in dunning, so that my team can intervene on high-value accounts.
Acceptance criteria:
- Given subscribers are in dunning, When the daily digest fires, Then it lists them by MRR desc, shows attempt count, days in dunning, and "contact subscriber" deep link.
UI states.
<!-- ui-states US-11.7 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) dunning digest — a daily-digest list view in the admin app showing all subscribers currently in dunning, sorted by MRR descending. Persona: Merchant Admin."
idle:
render: "Page titled 'Dunning digest — [today's date]'. A sortable BigDesign Table with columns: Subscriber name, Email, MRR (currency, right-aligned), Attempt count, Days in dunning, Last attempt date, and a 'Contact' action column. Default sort is MRR descending. Each row's 'Contact' column renders a BigDesign Button (variant=secondary, size=small) labeled 'View subscriber' that deep-links to the admin subscription detail for that customer."
primary_action: "'View subscriber' deep-link per row — opens /admin/subscriptions/{subscription_id} for that subscriber in the admin app."
loading:
trigger: "GET /api/v1/admin/dunning/digest on page mount."
render: "While the fetch is in flight, three skeleton rows replace the table body; the table header, column labels, and page title remain visible. No full-page spinner."
error:
surfaced_at: "Inline BigDesign Message(type='error', header='Failed to load dunning digest') above the table (role=alert) when the digest fetch fails — never a toast that auto-dismisses."
recovery: "A 'Retry' Button inside the error Message re-fires the digest fetch. If the error persists, the message body suggests navigating to Subscriptions → filter by status 'dunning' as a manual fallback."
empty:
render: "When no subscribers are in dunning, an explicit empty state replaces the table: a success-check icon with heading 'No subscribers in dunning today' and body 'All renewals are current. The digest updates daily.' No table or skeleton is shown."
cta: "Empty dunning digest is a healthy state — no action is required."
edge_status:
- status: "subscriber exited dunning mid-day (after digest was generated)"
affordance: "The digest row remains until the next daily refresh; the 'View subscriber' deep link opens the subscription detail where the current real-time status (now active or cancelled) is shown, so the admin acts on accurate state."
- status: "subscriber has reached maximum dunning attempts — subscription lapse is imminent"
affordance: "A 'Final attempt' badge renders on the row; the 'View subscriber' deep link opens the admin detail where manual intervention (payment-method update, courtesy skip, or cancel) is available."
- status: "high-value subscriber (MRR above a merchant-configured alert threshold)"
affordance: "Row renders with a highlighted MRR cell (bold + attention color) to signal priority; the standard 'View subscriber' affordance is unchanged — priority is visual, not a separate action."
inputs:
- field: "sort_by"
control: "select"
label: "'Sort by' — re-sorts the digest table"
allowed_values: "mrr_desc | attempt_count_desc | days_in_dunning_desc"
disabled_focus:
keyboard: "The 'View subscriber' affordance per row is a real BigDesign Button or anchor, not a div-onClick, reachable via Tab. The sort select is a native or BigDesign Select with a visible label, reachable in Tab order. No focus traps are introduced on this list view."