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 10 — Charge scheduling & execution (derived view)
Read-only per-epic slice of
BRD.md§9, lines 4156–4562. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-10.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (9): US-10.1, US-10.2, US-10.3, US-10.4, US-10.5, US-10.6, US-10.7, US-10.8, US-10.9
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 10 — Charge scheduling & execution
<!-- traceability:start:BRD:Epic-10 --><!-- traceability:end:BRD:Epic-10 -->Prototype: Charge Pipeline · Workflow Trace · Anchor Schedule · Pause Simulator
Value: Scheduled charges fire reliably, idempotently, and with clear failure semantics.
Epic context. This is the most reliability-critical part of the platform. Bugs here produce double-charges, missed renewals, or data corruption — each of which damages merchant trust on observation, not on aggregate. Idempotency at every external boundary is the non-negotiable design constraint.
US-10.1: Nightly schedule scan
<!-- traceability:start:US-10.1 --><!-- traceability:end:US-10.1 -->Prototype: Charge Pipeline
Phase: MVP · Persona: System
As the System, I want a continuous scheduler that picks up charges due in the next window and enqueues them, so that no renewal is missed or double-fired.
Acceptance criteria:
- Given a Vercel Cron runs every 15 minutes, When it fires, Then it queries charges with
status=scheduled AND scheduled_at <= now() + 15minand enqueues each into Vercel Queue. - Given a charge is already
processing, When the scheduler scans, Then it is not re-enqueued.
US-10.2: Charge executor workflow
<!-- traceability:start:US-10.2 --><!-- traceability:end:US-10.2 -->Prototype: Charge Pipeline · Workflow Trace
Phase: MVP · Priority: P0 · Effort: XL · Persona: System
As the System, I want each charge execution to run as a durable workflow with idempotency, so that retries don't double-charge.
Acceptance criteria:
- Given a charge is dequeued, When the workflow starts, Then it acquires a Redis lock on
charge.id(5-min TTL) and setsstatus=processing. - Given the workflow calls the processor with idempotency key =
charge.id, When the same key is presented again, Then the processor returns the same result without double-charging. - Given the workflow crashes mid-flight, When it restarts, Then it resumes from the last completed step using the workflow's durable state.
Data contract.
- Workflow runtime: Vercel Workflow (durable execution with checkpointing)
- Steps (each a durable step):
- Acquire lock —
SETNXin Redis oncharge:{id}:lockwith 5-min TTL - Load subscription — Postgres SELECT
- Load processor connection — Postgres SELECT
- Inventory check — BC API GET
/v3/catalog/products/{id}/variants/{id}or stock level - Price recalc — compute amount per plan strategy
- Tax + shipping recalc — BC checkout quote endpoint
- Dispatch to adapter —
processorAdapter.charge({ idempotencyKey, amount, paymentMethodRef, mitContext })wheremitContext = { type: 'recurring' | 'unscheduled' | 'installment', chainPosition: 'initial' | 'subsequent', networkTransactionId: string | null }. NTI null only onchainPosition='initial'. See PRD §6.3.1 for the full Stored Credential Indicator taxonomy. - Create BC order — BC API POST
/v2/orders - Finalize — update
charges.status=succeeded,bc_order_id, schedule next charge - Emit events + notifications
- Acquire lock —
- On any step failure: transition to dunning workflow (Epic 11) without reverting prior side effects (processor settlement, BC order) — each is separately reversible via refund/void
Success metrics.
- Functional: zero double-charges on retry-after-restart
- Product: charge success rate ≥ 95% P50 (target; revisit after 90d of live traffic)
- Operational (target): P95 full-workflow < 3s (excluding processor latency)
Dependencies.
- US-10.1 (scheduler enqueues)
- Epic 11 (dunning branch)
- US-14.1 (order creation step)
Non-functional.
- Idempotency: same
charge.idpresented twice to the processor must return the same result. Verified per adapter. - Cancellation: if subscription is cancelled while charge is mid-flight, we must not create the BC order; we abort at step 8 and issue refund at step 10.
- Concurrency: at most one workflow per charge at a time (enforced by Redis lock + Postgres advisory lock as belt-and-suspenders)
Risks / open questions.
- What if Vercel Workflow goes down mid-step? Confirm durability guarantees and recovery semantics with Vercel before MVP commit.
US-10.3: Charge anchor-date math
<!-- traceability:start:US-10.3 --><!-- traceability:end:US-10.3 -->Prototype: Anchor Schedule
Phase: MVP · Priority: P0 · Effort: M · Persona: System
As the System, I want next-charge dates computed from anchor_date + N×interval, so that cadence is stable regardless of clock drift, timezone, or retries.
Acceptance criteria:
- Given a monthly subscription with
anchor_date = 2026-04-10, When cycle N closes successfully, Then the next charge is scheduled atanchor_date + N months(handling month-length edge cases: Jan 31 + 1 month → Feb 28/29). - Given a charge retries and succeeds late, When it resolves, Then the next charge is still anchored to the original schedule, not the late completion time.
Data contract.
- Function:
nextChargeDate(anchor, interval_unit, interval_count, cycle_number, timezone): Date - Rules:
- Interval
day/week: straightforward addition - Interval
month: advance by N months; if source day > target month's last day, use target month's last day (Jan 31 + 1mo = Feb 28/29) - Interval
year: as month with Feb 29 handling - Timezone: all subscription dates stored UTC; rendered to merchant/subscriber in their TZ; anchor math uses store's timezone to avoid off-by-one-day
- Interval
Success metrics.
- Functional: 100% cadence stability — no cumulative drift across 24 cycles
- Operational: zero anchor-math bugs reported in production
Dependencies.
- Global NFR: timezone correctness is cross-cutting; verified in test suite with edge-case dates
Non-functional.
- Unit tests: every edge case (leap years, DST, month-length variance) has a test case
- Snapshot anchor_date on subscription creation; never mutate it (next_charge_at is derivative, anchor is the source of truth)
US-10.4: Charge jitter for rate limiting
<!-- traceability:start:US-10.4 --><!-- traceability:end:US-10.4 -->Prototype: Charge Pipeline
Phase: MVP · Persona: System
As the System, I want charges spread across the day rather than clustered at midnight, so that we respect BC API rate limits and processor throttling.
Acceptance criteria:
- Given a subscription's anchor_date is set, When scheduling, Then the time-of-day component is deterministically derived from a hash of
subscription.idto stable-but-distribute across the 24h window. - Given many subscriptions share a day, When they run, Then load is evenly distributed within ±10% across any 15-min window of the day.
US-10.5: Pause/resume respects schedule math
<!-- traceability:start:US-10.5 --><!-- traceability:end:US-10.5 -->Prototype: Pause Simulator
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to pause my subscription for N days and have the next charge push back exactly N days, so that I don't lose or duplicate cycles.
Acceptance criteria:
- Given an active subscription with next_charge_at = T, When I pause for 14 days, Then next_charge_at moves to T+14d and all future scheduled charges shift accordingly.
- Given I un-pause early, When I resume, Then the schedule snaps back to the closest future anchor interval.
UI states.
<!-- ui-states US-10.5 -->surface: "Storefront subscriber portal (Svelte) — /account/subscriptions + /subscriptions. Pause is the 'Pause subscription' card in ManagePanel.svelte (opened from a row's 'Manage'); resume is the 'Resume now' affordance rendered by SubscriptionStatusActions.svelte and inline in SubscriberPortalApp.svelte. Persona: Subscriber."
idle:
render: "An active subscription row exposes 'Manage', which opens a 2-3 column action grid; the 'Pause subscription' card ('Stop all charges until you resume') reveals an inline radio group of fixed durations (4 / 8 / 12 weeks, default 8). A paused subscription instead renders a status line — 'Scheduled to auto-resume <date>.' when next_charge_at is set, else 'Paused — no scheduled resume date.' — beside a 'Resume now' button."
primary_action: "Pause -> pick weeks, 'Pause' commits (submitPause); Resume -> 'Resume now' (resume / handleResume) returns the subscription to active and reschedules the next charge."
loading:
pause: "On submit the 'Pause' button shows 'Pausing...' and the radio group, Pause, Cancel and the close control are disabled (busy) — no double-submit."
resume: "The 'Resume now' button shows 'Resuming...' and is disabled while the request is in flight."
error:
surfaced_at: "Pause failures render inline beneath the ManagePanel controls as a role=alert paragraph (data-testid manage-error), scoped to this subscription. Resume failures render inline as a role=alert (data-demo status-action-error in SubscriptionStatusActions, or the portal-level role=alert in SubscriberPortalApp) — never a vanishing toast."
render: "The failure reason returned by the pause/resume endpoint (err.message)."
recovery: "Re-pick a duration and press 'Pause' again, or press 'Resume now' again; the form stays open with values preserved."
empty:
render: "Not a list surface — pause/resume act on a single subscription row. The subscriptions-list empty state ('No subscriptions yet. When you subscribe to a product, it will show up here.') is owned by the portal list in SubscriberPortalApp (US-17.1)."
cta: "n/a (single-subscription action)"
edge_status:
- status: "active — eligible to pause"
affordance: "Open Manage -> 'Pause subscription' card -> choose 4/8/12 weeks -> 'Pause'."
- status: "paused with a scheduled auto-resume date"
affordance: "'Resume now' returns to active immediately; the auto-resume date is shown so the subscriber knows the schedule."
- status: "paused with no scheduled resume date (next_charge_at null)"
affordance: "'Resume now' is still rendered and resumes manually at any time (the missing ETA is copy-only — see gaps)."
- status: "past_due — management actions gated"
affordance: "'Update payment method' (US-11.5/US-19.1) -> once the charge clears and the sub returns to active, pause/resume are available again."
- status: "gift-giver paused subscription"
affordance: "GiftPanel is shown instead of Resume; the subscription auto-activates on the recipient's claim, so the giver is intentionally not offered a manual resume."
inputs:
- field: "pause_weeks"
control: "radio"
allowed_values: "fixed durations 4, 8, 12 weeks (default 8)"
disabled_focus:
keyboard: "Every control is a real focusable element in tab order — the 'Pause subscription' card button, the weeks radio group (Tab to enter, arrows to choose), 'Pause'/'Cancel'/the close control, and 'Resume now' — no div-onClick. Each shows a visible focus ring; disabled removes a busy control from tab order."
focus_move: "On pause success the form closes and the portal's polite toast (role=status, aria-live=polite) announces 'Paused <n> weeks...'; on resume success the row re-renders to active and the polite toast announces the resume."
guard: "Pause and resume are single intentional clicks (non-destructive; pause shifts the schedule, resume reschedules) — no typed-confirm."
gaps: "When a paused subscription has next_charge_at = null, both SubscriptionStatusActions (line 107) and the inline portal branch (SubscriberPortalApp line 508) render 'Paused — no scheduled resume date.' with no projected resume ETA — the manual 'Resume now' button is always present, so this is a copy/reassurance gap, not a dead-end. Pause durations are fixed at 4/8/12 weeks (ManagePanel line 417); arbitrary durations are not offered."
US-10.6: Schedule visualization
<!-- traceability:start:US-10.6 --><!-- traceability:end:US-10.6 -->Prototype: Anchor Schedule
Phase: P1 (pulled forward 2026-05-16) · Persona: Subscriber / Merchant Admin
As a Subscriber/Merchant Admin, I want to see the next 5 upcoming charges for a subscription, so that I understand cadence at a glance.
Acceptance criteria:
- Given I open a subscription detail, When the upcoming-charges panel renders, Then it lists the next 5 charges with date, amount (estimated), status.
UI states.
<!-- ui-states US-10.6 -->surface: "Upcoming charges (schedule visualization). Admin (React/BigDesign): the 'Scheduled charges' Panel in apps/admin/src/pages/subscriptions/SubscriptionAdminDetail.tsx renders data.charges.scheduled (charges.scheduled.map, line 593) — each row shows cycle + amount (formatCents) + scheduled date (scheduledFor, line 608) + a 'Pending' Badge (pendingBadge, line 614). Subscriber portal (Svelte): apps/storefront-svelte/src/lib/subscriptions/SubscriptionDetailView.svelte shows only a single 'Next charge' date (portal-next-charge, line 81), not an upcoming-charges list. Backend already exposes the next-5 contract via GET /api/v1/portal/subscriptions/:id/charges/upcoming (handlePortalUpcomingCharges, apps/api/src/routes/portal/charges-upcoming.ts, LIMIT 5), but neither detail UI consumes it. Persona: Subscriber / Merchant Admin."
idle:
render: "Admin: the 'Scheduled charges' Panel lists each upcoming charge as cycle number, amount, scheduled date, and a 'Pending' status badge; when none are scheduled the Panel shows the 'no scheduled charges' empty copy (scheduled.empty, line 590). Subscriber: the plan-summary card shows one 'Next charge' date only (portal-next-charge, line 81) — no forward list."
primary_action: "Read-only schedule visualization — no row action; the north-star caps the list at the next 5 charges (date, estimated amount, status) on both surfaces."
loading:
render: "Admin: the whole detail page gates on the initial fetch — while data is null a single 'Loading…' Text renders (line 366-372) with no per-panel skeleton. Subscriber: the parent +page.svelte resolves subscription + charges before mounting this view; the component itself shows no spinner."
error:
surfaced_at: "Admin: a page-level Message(type='warning') replaces the detail when the detail fetch fails (errorHeader, line 357-361). The dedicated next-5 endpoint is not wired into either UI, so its own failure path is unsurfaced today (see gaps)."
recovery: "Reload the page to re-issue the detail fetch — there is no inline retry control on the schedule panel."
empty:
render: "Admin: when data.charges.scheduled is empty the Panel renders the 'no scheduled charges' copy (scheduled.empty, line 590), never a blank pane. Subscriber: the 'Next charge' date falls back to an em-dash via formatDate(null) when next_charge_at is null (line 81)."
edge_status:
- status: "more than 5 charges scheduled (admin)"
affordance: "North-star caps the visualization at the next 5 rows; today the admin Panel maps every row in data.charges.scheduled uncapped (charges.scheduled.map, line 593) — fix is a .slice(0,5) or consuming the next-5 endpoint (see gaps)."
- status: "charge in 'processing' (attempted, not yet settled)"
affordance: "The next-5 contract includes processing charges (charges-upcoming.ts WHERE status IN pending/processing); each row carries its live status so the user sees an in-flight charge instead of a dead gap."
- status: "no upcoming charges (paused / cancelled / terminal)"
affordance: "Admin shows the 'no scheduled charges' empty copy (scheduled.empty, line 590); the subscriber 'Next charge' shows an em-dash — the cadence summary still communicates state rather than a blank."
inputs: []
disabled_focus:
keyboard: "Read-only display panel — no focusable controls. Admin rows are static Text/Badge nodes in DOM source order (Scheduled charges Panel, line 587-620); the subscriber 'Next charge' is a static dd (portal-next-charge, line 81). Keyboard users tab past the panel with nothing to actuate, which is correct for a visualization."
gaps: "AC requires 'the next 5 charges with date, amount (estimated), status' on the subscription detail. Backend already satisfies this via handlePortalUpcomingCharges (charges-upcoming.ts, LIMIT 5) but NEITHER detail UI consumes it: (1) the subscriber portal SubscriptionDetailView renders only a single next_charge_at date (line 81), no upcoming-charges list at all; (2) the admin Panel renders data.charges.scheduled uncapped (line 593), not a next-5 projection. North-star wires both surfaces to the next-5 contract."
US-10.7: Future start date — pending_start state and deferred activation
<!-- traceability:start:US-10.7 --><!-- traceability:end:US-10.7 -->Prototype: pending_start admin view · Pending start (deferred activation)
Phase: P2 · Persona: Subscriber / Merchant Admin / System
As a Subscriber, I want to choose a future date when my subscription starts (e.g., purchase in November, activate January 1), so that I can gift or plan seasonal subscriptions without paying for cycles I haven't yet used.
Acceptance criteria:
- Given a subscriber selects a future start date at checkout, When the subscription record is created, Then
status = 'pending_start'andstarts_at = <chosen date>are stored; no charge is attempted or scheduled untilstarts_atpasses. - Given a subscription is in
pending_startstate andnow() >= starts_at, When the scheduler deferred-activation scan runs, Then the subscription transitions toactive, the first charge is enqueued, andanchor_dateis initialized fromstarts_at. - Given a subscription is in
pending_startstate, When a subscriber views the portal, Then the portal shows "Subscription starts on YYYY-MM-DD" with a days-remaining countdown and no cancel action (only a "Cancel pending subscription" path that results in immediate cancellation with no refund). - Given a subscription is in
pending_startstate, When the merchant admin views the subscription list, Then the row shows status "Pending" and thestarts_atdate. - Given a merchant admin cancels a
pending_startsubscription, When no charge has ever been attempted, Then the subscription transitions tocancelled; no refund is issued. - Given a subscriber submits a
starts_atdate in the past, When the form is validated, Then a validation error is shown: "Start date must be a future date."
State machine.
New state pending_start added to the subscription lifecycle between creation and active. Transitions:
- Creation with future
starts_at→pending_start(only whenstarts_at > now()) pending_start→active: deferred-activation scheduler scan detectsstarts_at <= now()(see deferred-activation scan below)pending_start→cancelled: explicit cancellation before first activation
If starts_at is null or starts_at <= now() at creation, the subscription goes directly to active (current behavior preserved).
Deferred-activation scan.
The existing scheduler (US-10.1) is extended with a second scan phase: SELECT * FROM subscriptions WHERE status = 'pending_start' AND starts_at <= now(). For each result, a pending_start → active transition is attempted with a WHERE status = 'pending_start' precondition (CAS pattern identical to US-10.2). Successfully transitioned subscriptions have their first charge enqueued immediately. The scan runs on the same 15-minute cadence as US-10.1. An ADR for the deferred-activation scheduler extension pattern is warranted before implementation — file as a companion [Decision] proposal referencing this US.
Schema fields.
Subscriptions table: starts_at TIMESTAMPTZ (nullable; null preserves immediate-activation current behavior); subscription status enum extended with 'pending_start'.
Cross-references. A3 spec-comprehensiveness matrix U-19; Spec e229c3d3; WCSubs feature requests #6 + #10 (79 votes combined); persona P3 Jordan (gift subscriptions purchased November, start January 1) + P1 Maya + P4 Sam; US-10.1 (scheduler extended with activation scan); US-5.9 (calendar anchor — commonly combined with future start on annual plans); US-6.1 (gifting flow sets starts_at for the recipient subscription).
UI states.
<!-- ui-states US-10.7 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Subscriber portal (Svelte/Tailwind) — /account/subscriptions and /subscriptions list detail row shows 'Subscription starts on YYYY-MM-DD' countdown and a 'Cancel pending subscription' button when status = pending_start. (2) Merchant Admin (React/BigDesign) — subscription list row shows a 'Pending' status badge with the starts_at date."
idle:
render: "Subscriber portal: the pending-start subscription row shows a 'Pending' status badge, 'Subscription starts on [date]' in bold, a days-remaining countdown ('[N] days until activation'), and a single 'Cancel pending subscription' secondary button — no pause, skip, or manage actions are offered. Admin list: the row shows a 'Pending' BigDesign Badge and the starts_at date in the Next charge column; no charge amount is shown."
primary_action: "'Cancel pending subscription' — subscriber-initiated cancellation before first activation; results in immediate transition to cancelled with no charge issued and no refund."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/cancel with a pending_start precondition guard on 'Cancel pending subscription' click."
render: "'Cancel pending subscription' button shows 'Cancelling…' and is disabled; no double-submit."
error:
surfaced_at: "Inline, directly beneath the 'Cancel pending subscription' button (role=alert), scoped to this subscription row — never a page-level banner."
render: "If the subscription has already activated (CAS race with the scheduler): 'This subscription has already started — you can cancel from the Manage menu.' If transient: 'Unable to cancel — please try again.'"
recovery: "If the subscription already activated, the row refreshes to show the active-subscription manage controls and the error guides the subscriber to cancel from there. For transient failures, a 'Try again' link retries the cancel request."
empty:
render: "Not a list surface — the pending-start state attaches to a single subscription row. The subscriptions-list empty state ('No subscriptions yet…') is owned by the portal list (US-17.1)."
cta: "n/a — single-subscription surface."
edge_status:
- status: "pending_start — starts_at is in the future"
affordance: "'Cancel pending subscription' is the only management action offered; a note warns 'Cancellation is immediate — your subscription will not activate and no charge will be made'"
- status: "pending_start — starts_at is today (hours away from scheduler activation)"
affordance: "Countdown shows '< 1 day until activation'; 'Cancel pending subscription' remains available; note reads 'Once the subscription activates today, use the standard cancel flow'"
- status: "Past-date validation error at checkout — subscriber submitted a starts_at in the past"
affordance: "Inline validation error on the start-date field at checkout: 'Start date must be a future date.' — the Subscribe CTA stays disabled until a valid future date is entered"
disabled_focus:
keyboard: "'Cancel pending subscription' is a real <button> in tab order — not a div-onClick; visible focus ring. Status badge and countdown text are non-interactive display elements. On successful cancellation, the row re-renders to cancelled state and an aria-live=polite region announces 'Subscription cancelled.' Admin list: the 'Pending' status badge is a non-interactive BigDesign Badge; no additional keyboard controls are added to the admin list row."
focus_move: "On cancel success, focus remains in the subscription row area; the aria-live=polite announcement reaches screen readers without requiring focus movement."
US-10.8: First-cycle proration for mid-year calendar-anchor sign-ups
<!-- traceability:start:US-10.8 --><!-- traceability:end:US-10.8 -->Prototype: First-cycle proration (calendar-anchored)
Phase: P2 · Persona: Subscriber / System
As a Subscriber signing up mid-year on a calendar-anchored annual plan, I want my first charge to cover only the partial period until the next anchor date, so that I am not asked to pay a full annual fee for a fraction of a year.
Acceptance criteria:
- Given an annual plan with
billing_anchor_month: 1andbilling_anchor_day: 1, When a subscriber signs up on July 1, Then the first charge covers July 1 → December 31 and is computed as(days_to_anchor / 365) × annual_pricerounded to 2 decimal places. - Given proration is applied for the stub period, When the first full renewal fires on January 1, Then the full annual price is charged and
anchor_dateis confirmed as January 1. - Given a plan has
proration_policy: 'skip_stub'and a subscriber signs up within 7 days of the anchor date, When the subscription is created, Then the stub cycle is skipped entirely and the first charge fires on the next anchor date at the full annual price. - Given a plan does not have
billing_anchor_monthset, When a subscription is created, Then no proration applies (US-10.3 standard anchor-date math is used unchanged). - Given the partial-period charge is computed, When the amount rounds to zero (extremely short stub window), Then the stub cycle is auto-skipped regardless of
proration_policyand the first full charge fires at the next anchor.
Surface constraint ([Decision] #1751). First-cycle proration is offered on the direct-API / admin path only (handleSubscriptionPost, where we create the first charge ourselves and control its amount), not on the bc_payments self-service storefront (the order webhook), where BC charges the catalog price at checkout and cannot express a dynamic per-signup-date prorated amount. Self-service annual plans renew on the anniversary (no proration). See US-5.9.
Schema fields.
Plans table: proration_policy ENUM('proportional', 'skip_stub') (default 'proportional'). Applies only when billing_anchor_month is non-null; ignored otherwise.
Cross-references. US-5.9 (billing_anchor_month/billing_anchor_day plan config); US-10.3 (standard anchor-date math — proration is layered on top); Spec e229c3d3; persona P1 Maya + P4 Sam (fiscal-year anchor sign-up scenarios).
UI states.
<!-- ui-states US-10.8 -->surface: "NOT YET BUILT — forward-looking contract. No subscriber-facing UI — first-cycle proration is a charge-amount computation on the direct-API / admin create path only (BRD Surface constraint, Decision #1751). The prorated first-charge amount surfaces as the charge total on the admin subscription detail charge history table and in the order confirmation email; no dedicated proration breakdown panel is offered to the subscriber."
idle:
render: "On the admin subscription detail page, the first charge row in the charge history table shows the prorated amount (computed as days_to_anchor ÷ 365 × annual_price, rounded to 2 decimal places) with a 'Pro-rated first cycle' label in the Description column. No proration calculator UI exists — the amount is computed server-side at creation and stored as the charge record amount."
primary_action: "No proration-specific CTA — the prorated amount is a display-only computed value in the charge history table."
loading:
trigger: "Proration computation runs synchronously during POST /api/v1/admin/subscriptions/create (the direct-API subscription-create endpoint). No async loading state — the caller receives the prorated first-charge amount in the create response."
render: "No dedicated proration loading state; the admin create form (US-22.1) shows its standard 'Creating…' submit state during the POST."
error:
surfaced_at: "If the plan lacks billing_anchor_month (required for proration) or proration_policy is invalid, the subscription-create endpoint returns a 400 validation error surfaced by the admin create form (US-22.1) as its standard submit-error Message above the buttons."
render: "Error copy in the US-22.1 submit-error Message: 'Plan does not support first-cycle proration — billing_anchor_month is not configured on this plan.'"
recovery: "The admin rep checks the plan configuration, ensures billing_anchor_month and billing_anchor_day are set, and retries the subscription creation."
empty:
render: "When billing_anchor_month is null on the plan, proration does not apply — standard US-10.3 anchor-date math is used and the first charge is the full plan price. The 'Pro-rated first cycle' label does not appear on the charge history row."
cta: "n/a — not a list surface; the admin charge history table always shows at least the first charge row once the subscription is created."
edge_status:
- status: "Stub period skipped (proration_policy = 'skip_stub' and sign-up is within the configured skip window of the anchor date)"
affordance: "The stub cycle is skipped; first charge fires on the next anchor date at the full annual price; admin charge history shows the first charge date as the anchor date, not the sign-up date; no 'Pro-rated first cycle' row appears"
- status: "Storefront (bc_payments self-service) subscription created — proration NOT applied"
affordance: "The webhook/order path does not support dynamic per-signup-date proration; the first charge is the full catalog price; admin charge history shows the full annual amount; no 'Pro-rated first cycle' label is shown"
disabled_focus:
keyboard: "No subscriber-facing interactive controls — proration is a server-side computation with no dedicated UI surface. The admin charge history table where the prorated amount appears is a read-only display table; its rows are non-interactive. No keyboard affordances specific to proration are required."
US-10.9: Capture timing configuration per store
<!-- traceability:start:US-10.9 --><!-- traceability:end:US-10.9 -->Prototype: Capture-timing config (settings)
Phase: Phase 1 (advisory) · Phase 2 (enforced) · Priority: P1 · Effort: S · Persona: Merchant Admin / System
As a Merchant Admin selling physical-goods subscriptions, I want to configure when charges are captured relative to fulfillment, so that my billing practice complies with EU payment regulations and aligns with my warehouse workflow.
Acceptance criteria:
- Given I open Payment Settings, When I view the capture timing section, Then I see a dropdown with three options:
Immediate(default),On fulfillment,On ship— each with a plain-language description of when capture fires. - Given I select
On fulfillmentorOn ship, When I save, Then the store'scapture_timingcolumn is updated and I see a Phase 1 advisory banner: "Deferred capture takes effect in Phase 2. Charges are currently captured immediately regardless of this setting." - Given
capture_timing = 'immediate'(default), When the charge scheduler fires, Thenadapter.charge()is called atomically (authorization + capture in one call) — behavior unchanged from pre-ADR-0038. - Phase 2 only: Given
capture_timing = 'on_ship', Whenstore/shipment/createdfires for a subscription order, Thenadapter.capture(authRef, amount)is called and the charge record transitions tocaptured; the authorization expiry sweep cancels any authorization that reachesauth_window_dayswithout a capture event. - Phase 2 only: Given
capture_timing = 'on_fulfillment', Whenstore/order/statusUpdatedfires with a fulfillment status, Thenadapter.capture()is triggered similarly. - Given the store ships to EU and
capture_timing = 'immediate', When the merchant views Payment Settings, Then a compliance warning is shown: "EU regulations require capture at shipment for physical goods — consider enabling On ship capture (Phase 2)."
Data contract.
- Schema:
stores.capture_timing TEXT NOT NULL DEFAULT 'immediate' CHECK (capture_timing IN ('immediate', 'on_fulfillment', 'on_ship'))— landed in migration 0019 (PR #789). - Type:
CaptureTimingexported from@bc-subscriptions/types. - Phase 2 adapter split:
adapter.authorize(ctx)+adapter.capture(authRef, amount)— specified in ADR-0038 Consequences. - Phase 2 BC webhooks:
store/shipment/created(payload{ type: "shipment", id, orderId }) andstore/order/statusUpdated.
Success metrics.
- Phase 1 functional:
stores.capture_timingpersists and is readable at runtime. - Phase 2 functional: zero immediate-capture incidents for stores configured
on_shiporon_fulfillment. - Compliance: EU-region merchants on
on_shipare capture-compliant by Phase 2 GA.
Dependencies.
- Upstream: ADR-0037 (stored-instruments vault — canonical charge rail); ADR-0038 (capture timing strategy — canonical spec).
- Downstream: US-10.2 (charge executor — Phase 2 adapter split extends step 7); Epic 11 (dunning — auth-expiry sweep may route to dunning on stale auth).
Non-functional.
- Phase 1: advisory only — no behavior change to the charge pipeline.
- Phase 2: idempotency keys must cover both the
authorize()andcapture()legs independently per ADR-0011.
Cross-references. ADR-0038 (canonical spec); synthesis #840; PRD §6.2 (multi-phase capture model note); STATE.md R-12 + R-13 (open risks until Phase 2 ships).
UI states.
<!-- ui-states US-10.9 -->surface: "Admin (React/BigDesign) Payment Settings — the 'Capture Timing' Panel in apps/admin/src/pages/settings/PaymentSettings.tsx:435-479. Persona: Merchant Admin / System. A BigDesign Select of three capture modes (CAPTURE_TIMING_OPTIONS, lines 38-42) persisted via updateStoreSettings (stores.ts:51-58); Phase 1 is advisory-only (ADR-0038)."
idle:
render: "The Panel shows intro copy, a static 'advisory only' Phase-1 Message (lines 440-448), the region advisory (US-2.7, line 451), a 'Capture Timing' Select pre-set to the store's capture_timing (immediate | on_fulfillment | on_ship, lines 453-460), a Small explaining each mode, and a 'Save' primary Button (lines 470-476)."
primary_action: "'Save' → handleSave (lines 188-202) PUTs { capture_timing } via updateStoreSettings; on success a 'Capture-timing setting saved.' Message renders (lines 417-423)."
loading:
trigger: "GET /api/v1/admin/store/settings via getStoreSettings on mount (lines 161-172); the Select and Save are gated on the loading flag (line 141)."
render: "While loading === true the 'Capture Timing' Select is disabled (line 455) and the 'Save' Button is disabled (line 472); the region advisory is withheld until !loading (line 451). No skeleton — the Panel chrome stays mounted. While saving === true the Save Button shows its isLoading spinner."
error:
surfaced_at: "Two inline BigDesign Messages, never toasts: a load failure renders 'Failed to load store settings: <reason>' near the bottom (loadError, lines 409-414); a save failure renders 'Save failed: <reason>' (saveError, lines 426-432, dismissible via onClose)."
render: "The thrown reason from getStoreSettings/updateStoreSettings (stores.ts:29-44), e.g. 'HTTP 500: <body>'."
recovery: "On a save failure setSaving(false) re-enables the still-populated Select + Save (finally, lines 199-201) so the merchant retries. GAP — the load-error Message has no Retry; a load failure leaves the Panel showing the default 'immediate' with reload-only recovery."
empty:
render: "Not a list surface — the Select always offers the three fixed CAPTURE_TIMING_OPTIONS (lines 38-42); there is no empty case. If settings fail to load the Select falls back to the 'immediate' default rather than rendering blank."
cta: "n/a — fixed-option settings control, not a collection."
inputs:
- field: "capture_timing"
control: "select"
allowed_values: "immediate | on_fulfillment | on_ship (CAPTURE_TIMING_OPTIONS, lines 38-42); value persisted to stores.capture_timing (CaptureTiming, stores.ts:14)."
edge_status:
- status: "eu_immediate — EU store on Immediate capture"
affordance: "RegionAdvisory warning (lines 68-80) instructs selecting 'On Ship'; the merchant changes the Select and Saves (enforcement activates Phase 2 per the advisory)."
- status: "eu_on_ship — EU store on On Ship"
affordance: "RegionAdvisory confirmation (lines 80-82) states the store is compliant; no change needed."
- status: "save_failed — PUT rejected"
affordance: "the 'Save failed' Message (lines 426-432) with onClose; the form stays populated and re-enabled so the merchant re-presses Save."
- status: "region_unknown_eu_suppressed — EU store with country_code absent (or settings load failed)"
affordance: "detectRegion returns 'other' (line 54) so the EU compliance warning is suppressed (RegionAdvisory null, line 110). NORTH-STAR: surface a neutral capture-timing warning when region can't be resolved so AC-6 is not silently skipped (see gaps)."
disabled_focus:
keyboard: "The 'Capture Timing' Select and the 'Save' Button are real BigDesign components wrapping native focusable elements — no div-onClick. Tab order is Select → Save; the Select is reachable via Tab with arrow-key option selection, the Button is Enter/Space-activatable. Native disabled removes both from tab order while loading/saving."
focus_move: "On save success/failure the result Message renders with no role='alert' / aria-live and no programmatic focus move (PaymentSettings.tsx imports no useRef, calls no .focus()), so a screen-reader merchant is not announced of the save outcome; focus stays on the re-enabled Save Button."
guard: "Saving capture timing is a single intentional, non-destructive click (Phase-1 advisory-only — no charge-pipeline effect); no typed-confirm; double-submit blocked by the isLoading + disabled Save Button while saving."
gaps: "silent-failure DEFECT — when country_code is missing or settings fail to load, detectRegion → 'other' (line 54) and the EU compliance warning (BRD AC-6) is suppressed for an EU merchant. low-severity: the Phase-1 'advisory only' Message (lines 440-448) renders unconditionally — including for 'immediate' — whereas AC-2 only requires the advisory banner after a deferred mode is selected. The load-error path has no inline Retry. Phase-2 ACs (adapter.capture() on shipment/fulfillment webhooks) are out of this surface's scope."