← All epicsBRD.md §9 · lines 3869–4154

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 9 — Cart & checkout subscription intent capture (derived view)

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

  • Stories (6): US-9.1, US-9.2, US-9.3, US-9.4, US-9.5, US-9.6
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 9 — Cart & checkout subscription intent capture

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

Prototype: Cart Line Capture · Checkout Flow · Post-Purchase Fallback · Subscription Created · First-cycle proration (calendar-anchored) · Post-Purchase Subscription Prompt

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

Value: Subscription intent flows reliably from cart → order creation with zero merchant manual mapping.

Epic context. This is the atomic-correctness crux: subscription intent must flow from cart to Subscription record without loss, duplication, or race. Failures here are painful to recover from because the BC order is already created and the subscriber already paid.

US-9.1: Line-item custom fields carry intent

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

Prototype: Cart Line Capture

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

Phase: MVP · Priority: P0 · Effort: S · Persona: System

As the System, I want subscription intent (plan_id, interval_key, quantity) captured in BC cart line-item custom fields, so that the post-checkout webhook can create subscriptions atomically with the order.

Acceptance criteria:

  • Given a subscriber adds a subscription item, When the cart POST fires, Then line_item.custom_fields includes {sub_plan_id, sub_interval_key, sub_quantity}.
  • Given the subscriber edits quantity in cart, When the line updates, Then custom fields stay consistent.

Data contract.

  • Storefront SDK/widget sets cart line-item custom fields on add-to-cart (keys namespaced: sub_plan_id, sub_interval_key, sub_quantity, sub_schema_version)
  • BC Stencil cart API: POST /api/storefront/carts or POST /carts/items with optionSelections and/or customFields
  • BC propagates custom fields to the resulting order line items (assumed — verify per theme)

Success metrics.

  • Functional (target): custom fields appear on store/order/created webhook payload ≥ 99% of subscription intents
  • Operational (target): custom-field-propagation failure rate < 0.5% (the remainder handled by US-9.3 fallback)

Dependencies.

  • US-8.1 (widget writes the fields)
  • US-9.2 (webhook reads the fields)

Non-functional.

  • Schema version allows graceful evolution — if we add a new field, legacy carts still work

Risks / open questions.

  • Some BC line-item schemas strip unknown custom fields. Validate against the current BC cart API spec before MVP.

US-9.2: store/order/created → Subscription creation

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

Prototype: Checkout Flow

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

Phase: MVP · Priority: P0 · Effort: L · Persona: System

As the System, I want the store/order/created webhook to create matching Subscription rows for any subscription-intent line items, so that subscriptions track their first order.

Acceptance criteria:

  • Given a new order contains a line with sub_plan_id, When the webhook processes, Then a Subscription row is created with status active, first charge marked succeeded with bc_order_id of the triggering order.
  • Given the order contains multiple subscription items, When processing, Then one Subscription per item is created (or one consolidated, per merchant setting).
  • Given the order creation fails our validation (missing plan, deactivated plan), When processing, Then we flag the order in the exception queue without blocking the BC checkout.

UX notes.

  • Not user-facing at time of webhook processing. But the resulting subscription appears in the subscriber portal immediately after thank-you page loads.

Data contract.

  • Input: BC webhook store/order/created with {store_id, data: {type: "order", id}}
  • Our workflow:
    1. Fetch order header via GET /v2/orders/{id} and line items via GET /v2/orders/{id}/products (V2 doesn't document an ?include=products form on /v2/orders/{id}; the canonical pattern for line items is the dedicated subresource).
    2. For each line with sub_plan_id custom field: a. Validate plan exists + is active b. Validate variant is eligible c. Validate customer (create/update our Customer record) d. Create subscriptions row with status: active, anchor_date = now, next_charge_at = now + interval e. Create charges row for cycle 0 with status: succeeded, bc_order_id: order.id, amount_cents: line.price_inc_tax
    3. PATCH order with our linkage custom fields (US-14.3)
  • Events: subscription.created, charge.recorded (cycle 0)

Success metrics.

  • Functional (target): 100% of subscription-intent orders result in a Subscription within 60s
  • Product (target): median time from thank-you page → subscription visible in portal ≤ 10s
  • Operational (target): webhook P95 processing < 500ms (workflow is async beyond that)

Dependencies.

  • US-9.1 (custom fields present)
  • US-14.3 (first-order linkage)

Non-functional.

  • Idempotency: same webhook delivery twice must not create two subscriptions (dedupe on bc_order_id)
  • Ordering: store/order/updated may arrive before store/order/created in rare cases — buffer updates until created is handled
<!-- normative-requirements US-9.2 - artifact: charge.recorded kind: event fit: emitted when the cycle-0 charge row is created at subscription inception closes: grep:apps/api/src -->

US-9.3: Post-purchase fallback capture

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

Prototype: Post-Purchase Subscription Prompt

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

Phase: MVP · Priority: P0 · Effort: M · Persona: Subscriber / System

As the System, I want a post-purchase fallback flow, so that subscribers on themes where cart custom fields don't propagate still get their subscriptions created.

Acceptance criteria:

  • Given an order is created without subscription custom fields, When our thank-you-page JS detects the subscriber intended a subscription (from pre-checkout session storage), Then it POSTs to our fallback endpoint to create the subscription post-hoc.
  • Given the fallback triggers, When it succeeds, Then the subscription is linked to the just-created order.
  • Given the fallback fails, When it times out or errors, Then the subscriber sees a clear message with a support link.

Data contract.

  • Pre-checkout: widget writes subscription intent to sessionStorage
  • Post-checkout: thank-you page JS detects completion, reads sessionStorage, POSTs to /api/v1/storefront/orders/{orderId}/attach-subscription with subscription intent + order ID
  • Server: validate order ownership (matches authenticated customer), validate intent, create Subscription
  • Fallback error: if POST fails, show message "We couldn't complete your subscription setup — please contact support with order #XXXX"

Success metrics.

  • Functional (target): when line-item custom fields fail to propagate, fallback recovers ≥ 90% of cases
  • Operational (target): fallback-triggered rate < 10% of subscription purchases (if higher, US-9.1 is broken)

Non-functional.

  • Must complete within 5s of thank-you page load; otherwise shopper navigates away and loses session intent

UI states.

<!-- ui-states US-9.3 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber storefront (Svelte/Tailwind) — post-purchase fallback capture overlay; a JS snippet injected into the Stencil thank-you page; fires on page load when subscription intent is detected in sessionStorage."
idle:
  render: "On thank-you page load, if sessionStorage contains a subscription intent key, a slim inline banner appears below the order confirmation heading: 'Setting up your subscription… (order #XXXX)' with a spinner. If sessionStorage contains no subscription intent, nothing renders — the standard BC thank-you page is unaltered."
  primary_action: "No subscriber action — the fallback fires automatically on page load."
loading:
  trigger: "On page load the snippet reads sessionStorage; if intent is present it immediately POSTs to /api/v1/storefront/orders/{orderId}/attach-subscription. Must resolve within 5 s before the subscriber navigates away."
  render: "'Setting up your subscription… (order #XXXX)' banner with a spinner is the loading state; no subscriber controls are shown during the POST."
error:
  surfaced_at: "Inline, replacing the spinner banner in the same position on the thank-you page (role=alert): 'We couldn't complete your subscription setup — please contact support with order #XXXX.' A support link is included. Never a vanishing toast."
  render: "Error banner: 'We couldn't complete your subscription setup — please contact support with order #XXXX.' The order number is included for support context."
  recovery: "Subscriber contacts support with the order number. No in-page retry — a repeat POST risks duplicate subscription creation. Support uses the admin manual-creation flow (US-22.1) to attach the subscription post-hoc."
empty:
  render: "When sessionStorage contains no subscription intent (the normal case for non-subscription orders), the snippet exits silently and nothing renders — the thank-you page remains the standard BC-rendered page."
  cta: "n/a — the fallback surface is absent for orders without subscription intent."
edge_status:
  - status: "Fallback POST succeeded — subscription created post-hoc"
    affordance: "The banner updates to 'Your subscription is set up! View it in your account ›' with a real <a href> link to the subscriber portal; the sessionStorage intent key is cleared to prevent re-fire on page refresh"
  - status: "POST timed out (> 5 s without response)"
    affordance: "The same error banner fires with the order number and support link; timeout is treated identically to a server error — no retry to avoid duplicate subscription creation"
  - status: "sessionStorage unavailable (private browsing / cross-origin iframe)"
    affordance: "The snippet detects no intent key and renders nothing; the merchant's support team must handle subscription creation via admin (US-22.1) if the subscriber contacts support"
disabled_focus:
  keyboard: "The error banner's support link is a real <a href> in tab order; the success banner's 'View in your account' link is a real <a href> in tab order — no div-onClick links. The banner carries role=status (success) or role=alert (error) so screen readers announce the outcome without requiring the subscriber to move focus."

US-9.4: Mixed-cart checkout (subscription + one-time)

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

Prototype: Post-Purchase Fallback

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

Phase: MVP · Persona: Subscriber

As a Subscriber, I want to buy subscription and one-time items in a single BC checkout, so that I don't check out twice.

Acceptance criteria:

  • Given a mixed cart, When checkout completes, Then the order contains both item types and only the subscription lines create Subscription rows.

UI states.

<!-- ui-states US-9.4 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber storefront (Svelte/Tailwind) — mixed-cart checkout annotation layer; subscription line items in the BC checkout order summary carry a 'Subscribe · Every [cadence]' sub-label beneath the product name; one-time items are unlabeled."
idle:
  render: "The BC checkout order summary panel shows subscription line items annotated with a 'Subscribe · Every [cadence]' sub-label (e.g. 'Subscribe · Monthly'). One-time items show no annotation. The affirmative consent disclosure (US-9.6) appears above the 'Place order' CTA for the subscription lines. Totals, tax, and shipping are computed for the combined order."
  primary_action: "Standard BC checkout 'Place order' CTA — subscription intent for qualifying lines has already been captured in the cart (US-9.1 + US-9.6); no separate subscription CTA is shown."
loading:
  trigger: "On 'Place order', the BC checkout processes payment and redirects to the thank-you page; the store/order/created webhook triggers subscription-row creation asynchronously. No subscription-specific loading indicator during checkout submission."
  render: "Standard BC checkout processing state — the subscription annotation layer is a passive display and does not introduce a separate loading indicator."
error:
  surfaced_at: "If subscription-row creation fails after the BC order is successfully submitted (webhook processing error), the error is surfaced by the thank-you page fallback snippet (US-9.3) — never on the checkout page itself, since payment is already processed."
  render: "The US-9.3 fallback snippet banner on the thank-you page: 'We couldn't complete your subscription setup — please contact support with order #XXXX.' One-time items are fulfilled regardless of the subscription-creation error."
  recovery: "Subscriber contacts support; admin manually attaches the subscription (US-22.1). Order fulfillment for one-time items proceeds normally."
empty:
  render: "If the cart contains no subscription items (all one-time), no subscription annotation labels are shown — the standard BC checkout renders unaltered. The annotation layer is a no-op when no lines have subscription intent."
  cta: "n/a — no mixed-cart empty state; the annotation layer is absent when there are no subscription lines."
edge_status:
  - status: "Mixed cart — subscription and one-time items present"
    affordance: "Subscription lines are annotated with cadence; affirmative consent checkbox (US-9.6) is required before 'Place order' enables; one-time lines are unlabeled and unaffected by the consent gate"
  - status: "Subscription-only cart"
    affordance: "All lines are annotated; affirmative consent checkbox (US-9.6) is required; no one-time-line distinction is shown"
  - status: "Subscription line item's plan is no longer active at checkout time"
    affordance: "Cart shows a validation error on the subscription line ('This subscription plan is no longer available'); the subscriber must remove the item or contact the merchant before proceeding to payment"
disabled_focus:
  keyboard: "The 'Subscribe · Every [cadence]' annotation is display-only text (non-interactive); the affirmative consent checkbox (US-9.6) is a real <input type=checkbox> in tab order; the 'Place order' Button is a BC native checkout control (real <button>); no custom div-onClick elements are introduced by the subscription annotation layer."

US-9.5: Third-party checkout support declaration

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

Prototype: Subscription Created

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

Phase: P2 · Persona: Developer

As a Developer using Bolt / Fast / other third-party checkout, I want a documented integration path or an explicit incompatibility declaration, so that I know what works.

Acceptance criteria:

  • Given the merchant uses a third-party checkout that doesn't support line-item custom fields, When they install the app, Then the dashboard shows an "Incompatible checkout" banner with named fallbacks (header script, thank-you fallback, manual admin reconciliation).

US-9.6: Affirmative consent capture at subscribe (auto-renewal disclosure)

Phase: P1 · Priority: P0 · Effort: M · Persona: Subscriber / Merchant Admin

As a Subscriber, I want the recurring-charge terms disclosed clearly before I pay and my affirmative consent captured at that moment, so that I knowingly agree to the subscription — and as a Merchant, so that enrollment meets negative-option law (federal ROSCA + state automatic-renewal laws such as California's, amended by AB 2863, effective 2025-07-01).

Compliance note (research red team S3, synthesis #1822, ADR-0079). Federal ROSCA (15 U.S.C. §8403) and state automatic-renewal laws require clear, conspicuous pre-payment disclosure of the recurring terms and the consumer's affirmative consent before the charge. The FTC's federal "click-to-cancel" Negative Option Rule was vacated by the 8th Circuit (July 2025), but ROSCA and state ARLs remain in force. Final compliance scope is counsel-gated (ADR-0079; tracked as an attestation, not asserted here as legal sufficiency).

Acceptance criteria:

  • Given a subscription is being added to cart/checkout (US-9.1), When the subscriber reaches the point of payment, Then the recurring terms are disclosed clear-and-conspicuous adjacent to the consent control: product, recurring amount, billing frequency, when the first and subsequent charges occur, and how to cancel.
  • Given the disclosure is shown, When the subscriber consents, Then consent is captured by an affirmative action (an explicit, un-pre-checked control) — not a pre-checked box or buried-terms inference — and the subscription cannot be created without it.
  • Given a free trial / free-to-pay conversion, When the subscriber enrolls, Then the disclosure additionally states the trial length and the post-trial recurring amount + date (CA ARL AB 2863 expressly covers free-to-pay conversions).
  • Given consent is captured, When the subscription is created, Then a consent record is persisted (US-28.7) with the disclosed terms, timestamp, actor, and the surface/version of the disclosure shown.

UX notes.

  • Surface: cart/checkout subscribe control + the storefront widget pre-purchase panel (US-8.6)
  • The consent control is un-pre-checked; the "Subscribe" CTA is disabled until consent is given
  • Disclosure copy is merchant-themeable but the required fields are non-removable

Data contract.

  • Captured at the cart-intent boundary (US-9.1 cart-metafield carry): consent_version, consent_text_ref, disclosed_terms (amount, interval, first/next charge dates, cancel method)
  • On subscription create: emit subscription.consent_captured with the consent payload; persist via US-28.7 retention
  • No new BC platform primitive — rides the existing cart-intent + subscription-create path

Success metrics.

  • Functional: 100% of subscription enrollments carry a persisted affirmative-consent record (a created subscription without one is a defect)
  • Operational (target): consent-disclosure render adds under 100ms to the subscribe control's first paint
  • Product (target): subscribe-flow completion stays within 2 points of the pre-disclosure baseline (a well-built disclosure should not materially depress conversion)

Dependencies.

  • US-9.1 (cart-intent capture — carries the consent payload)
  • US-8.6 (pre-purchase education panel — disclosure surface)
  • US-28.7 (consent-record retention)
  • ADR-0079 (compliance posture); counsel attestation for final legal sufficiency

Non-functional.

  • Required disclosure fields are enforced server-side at subscription-create — a storefront that omits them is rejected, not silently accepted
  • Consent records are immutable once written (append-only)

Risks / open questions.

  • Per-jurisdiction disclosure variation (state ARLs differ) — ADR-0079 scopes the covered jurisdictions; counsel confirms
  • Headless storefronts (US-8.3) must carry the same disclosure — the SDK surfaces the required fields, but enforcement is server-side

UI states.

<!-- ui-states US-9.6 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber storefront (Svelte/Tailwind) — affirmative consent and auto-renewal disclosure block within the PDP subscription widget and BC checkout subscribe control; un-pre-checked consent checkbox required before the Subscribe CTA enables."
idle:
  render: "Directly above the disabled 'Subscribe' CTA, the disclosure block shows the recurring billing terms in clear, conspicuous language: product name, recurring amount, billing frequency, first-charge date, subsequent-charge dates, and cancellation method. An un-pre-checked <input type=checkbox> with label 'I agree to these recurring charge terms' is rendered. The Subscribe CTA is aria-disabled until the checkbox is checked. For free-trial enrollments, the disclosure additionally shows the trial length, post-trial recurring amount, and the date the first charge will occur (CA ARL AB 2863)."
  primary_action: "Check the consent checkbox → Subscribe CTA enables. Click Subscribe → subscription creation begins with the captured consent payload."
loading:
  trigger: "After the consent checkbox is checked and Subscribe is clicked, POST via the cart-intent path (US-9.1) to create the subscription."
  render: "The Subscribe button shows 'Subscribing…' with a spinner and is disabled; the consent checkbox is also disabled to prevent unchecking mid-flight; no double-submit."
error:
  surfaced_at: "Inline, directly beneath the consent checkbox and Subscribe CTA (role=alert): the failure reason. Never a vanishing toast. The consent record is not persisted if subscription creation fails."
  render: "Error copy: 'Unable to set up subscription — please try again.' or a plan-specific message (e.g. 'This subscription plan is no longer available — please contact the merchant')."
  recovery: "The consent checkbox returns to its checked state and Subscribe CTA re-enables so the subscriber can retry. If the plan is inactive, error copy directs the subscriber to contact the merchant."
empty:
  render: "If the plan configuration is missing required disclosure fields (amount / frequency / cancel method), the Subscribe CTA is suppressed entirely and a message reads 'Subscription configuration incomplete — please contact the merchant.' The consent block does not render for an incomplete plan."
  cta: "n/a — the consent block is always present for a properly configured subscription plan; the 'empty' case is a misconfigured-plan guard, not a list surface."
inputs:
  - field: "consent_checkbox"
    control: "checkbox"
    label: "Affirmative consent — un-pre-checked <input type=checkbox> labeled 'I agree to these recurring charge terms'; the Subscribe CTA is aria-disabled until this is checked; pre-checking is forbidden (ROSCA / state ARL compliance)"
edge_status:
  - status: "Free-trial enrollment — subscription starts with a trial period before first charge"
    affordance: "Disclosure block additionally shows trial length, post-trial recurring amount, and first-charge date; checkbox label reads 'I agree to these recurring charge terms, including post-trial billing'; un-pre-checked"
  - status: "Consent checkbox unchecked (initial / default state)"
    affordance: "Subscribe CTA is aria-disabled and visually muted; screen reader announces 'Subscribe, unavailable'; no purchase action is possible until the subscriber explicitly checks the box"
  - status: "Plan disclosure fields missing server-side (misconfigured plan)"
    affordance: "Subscribe CTA is suppressed; subscriber sees 'Subscription configuration incomplete — please contact the merchant'; merchant admin is flagged in the widget configuration panel"
disabled_focus:
  keyboard: "The consent <input type=checkbox> is a real, labeled checkbox element in tab order — Tab to reach it, Space to toggle. The Subscribe <button> follows in tab order; it is aria-disabled (not HTML-disabled) while unchecked, keeping it focusable so a screen reader can report its inactive state. All disclosure text is in the DOM as real text nodes, readable by screen readers without interaction."
  guard: "The consent checkbox is un-pre-checked by design — the subscriber must take an affirmative action. No typed-confirm is required beyond the checkbox itself; the aria-disabled guard prevents Submit activation until consent is given."