← All epicsBRD.md §9 · lines 5864–6096

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 16 — Order bundling across a subscriber's subscriptions (derived view)

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

  • Stories (5): US-16.1, US-16.2, US-16.3, US-16.4, US-16.5
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 16 — Order bundling across a subscriber's subscriptions

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

Prototype: Subscriber Opt-in · Merchant View · Shipping Split · Failure Handling

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

Value: Subscribers with multiple active subscriptions can opt to receive them as consolidated shipments, reducing their shipping cost and the merchant's fulfillment cost.

US-16.1: Subscriber opts into combined shipments

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

Prototype: Subscriber Opt-in

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

Phase: P1 (pulled forward 2026-05-16) · Persona: Subscriber

Naming ([Spec] #1649 Part A): the user-facing feature is "combined shipments" — N independent subscriptions aligned to a common charge/ship date so they arrive in one box. The word bundle is reserved for the commerce sense (a combined-product package / build-a-box), which this is not. Internal identifiers retain the bundle* prefix (bundles/bundle_charges tables, bundle_anchor_day, bundle_window_days, bundle_opt_in, runBundleSweep, the [BUNDLE] order marker) — see the Combined-shipments glossary for the internal↔external mapping; renaming those is a tracked follow-up, not this pass.

As a Subscriber with more than one subscription to the same address, I want to combine their deliveries so they arrive together, so that I consolidate packages — and I want to choose this at the moment it matters, not have my charge dates changed behind my back.

Mechanism (ADR-0070, supersedes ADR-0069): alignment is a consent-driven, point-in-time action, not a background sweep, and it does not prorate. Physical-goods subscriptions are per-shipment — moving a charge date gives the subscriber the same box on a different day, not "fewer days" of anything — so there is nothing to prorate against. We align the date and keep the price. The merchant anchor-day (bundle_anchor_day) survives only as an optional "everyone bills on the 15th" convenience.

Acceptance criteria:

  • Given I am creating a new subscription and already have ≥ 1 active subscription shipping to the same address with a compatible cadence, When I reach scheduling, Then I am offered "Combine deliveries with your existing subscription?" with the resulting first-delivery date shown.
  • Given I have two or more existing same-address, compatible-cadence subscriptions, When I open the portal, Then I see a "Combine these deliveries" action that aligns them to a common date.
  • Given I accept an alignment offer, When the subscription's next charge is set, Then its date moves to the target date at the same price — no proration (per-shipment value is unchanged; ADR-0070 §2), and its subsequent cadence continues normally from the new date.
  • Given an alignment would move a charge, When the target date is chosen, Then the system picks the nearest occurrence that does not push me past my current cycle (no stockout), so a whole shipment is never silently skipped (ADR-0070 §3).
  • Given two same-address subscriptions have incompatible cadences (e.g. weekly + annual), When I view the offer, Then alignment is not offered for that pair — it would not produce recurring co-delivery (ADR-0070 §5; eligibility = same address + same/compatible interval·interval_count).
  • Given I accept combining, When the renewals are charged together, Then the promise is aligned billing and a single combined ordernot a guaranteed single physical parcel: products with different processing times or fulfillment centers may still ship separately, and the system does not model per-product processing windows (ADR-0070 §6).
  • Given I am a Merchant Admin, When I configure combined shipments, Then I may optionally set a per-store billing anchor day (bundle_anchor_day, 1–28) as a convenience; it is not required and is not load-bearing for consolidation (ADR-0070 §6–§7).

Decision lineage: the reframe from a background co-scheduling sweep with scale-by-shift proration to consent-driven, no-proration alignment — and why proration was a category error for per-shipment goods — is recorded in ADR-0070. The superseded sweep/proration/anchor design (options weighed, then discarded) is in ADR-0069 (Issue #1647; reframed via [Spec] #1649).

UI states.

<!-- ui-states US-16.1 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) — subscription list page and/or subscription detail view; a 'Combine these deliveries' offer card shown when the subscriber has ≥2 active subscriptions sharing the same shipping address with compatible cadences. Also offered as an inline consent step at scheduling during storefront subscription creation. Persona: Subscriber."
idle:
  render: "On the portal subscription list, when ≥2 active same-address, compatible-cadence subscriptions exist, a 'Combine these deliveries' offer card appears above the subscription rows showing the participating plan names, the resulting first co-delivery date, and the disclaimer 'Aligned billing creates one order — physical parcels may still ship separately (ADR-0070 §6).' At storefront creation the scheduling step shows an inline 'Combine deliveries with your existing [Plan Name] subscription?' card with the aligned start date before the subscriber finalises."
  primary_action: "Combine deliveries → POST /api/v1/portal/subscriptions/combine with the participating subscription IDs and the confirmed target date; on success the subscriptions show updated next_charge_at values."
loading:
  trigger: "POST /api/v1/portal/subscriptions/combine while the request is in-flight."
  render: "'Combine deliveries' button shows 'Aligning…' and is disabled; the 'Not now' dismiss link is also disabled so the action cannot double-fire; no double-submit."
error:
  surfaced_at: "Inline beneath the 'Combine deliveries' button inside the offer card, as a role=alert paragraph — scoped to this action and never a vanishing page-level toast."
  render: "The API failure reason (e.g. 'Subscriptions are no longer at the same address' or 'No valid alignment date found within the current cycle')."
  recovery: "The 'Combine deliveries' button re-enables so the subscriber can retry; the 'Not now' dismiss link remains available to exit the offer without combining."
empty:
  render: "When the subscriber has only one active subscription, or no same-address pairs with compatible cadences exist, the 'Combine these deliveries' offer card is not rendered. The subscription list shows the standard active-subscription rows with no combine prompt — the absence is intentional, no empty-state placeholder is shown."
  cta: "n/a (no combinable pairs exist)"
edge_status:
  - status: "combined — subscriptions aligned, awaiting first co-delivery"
    badge: "Aligned delivery"
    affordance: "The offer card is replaced by a confirmation line on each participating subscription row: 'Combined with [other plan] — next delivery [date].' No undo affordance is offered (alignment is a consent-driven point-in-time action, no proration per ADR-0070 §2)."
  - status: "storefront creation — combine offer at scheduling step"
    affordance: "Inline at the scheduling step: 'Combine deliveries with your existing subscription?' with the nearest valid aligned start date shown; subscriber can accept (aligns to that date at the same price) or skip (picks an independent date). No-proration note is displayed."
  - status: "incompatible cadences (e.g. weekly + annual)"
    affordance: "Combine offer is not shown for this pair — eligibility requires same shipping address and compatible interval/interval_count (ADR-0070 §5). Ineligibility is silent; the subscriber sees only pairs that do qualify, or no offer at all."
  - status: "alignment would push past the current billing cycle (no valid nearest date within constraint)"
    affordance: "The system picks the nearest occurrence that does not skip a cycle (ADR-0070 §3); if no valid date exists, the pair is ineligible and the offer is suppressed — the subscriber is not notified of the ineligibility."
disabled_focus:
  keyboard: "'Combine deliveries' is a real <button> reachable in tab order, Enter/Space-activatable, with a visible focus ring; disabled only for the duration of the in-flight POST. 'Not now' is a real <button> or <a> reachable via Tab — never a div-onClick. On success the offer card is replaced by the aligned-delivery confirmation; updated next_charge_at values on the subscription rows are announced via aria-live=polite."
  guard: "Combining is consent-driven, non-destructive, no-proration (per-shipment price unchanged); no typed-confirm is required. The offer appears only for the subscriber who owns both subscriptions."

US-16.2: Single BC order per combined shipment

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

Prototype: Failure Handling

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

Phase: P1 (pulled forward 2026-05-16) · Persona: System

As the System, I want combined renewals to produce a single BC order with combined line items, so that fulfillment sees one shipment.

Acceptance criteria:

  • Given two charges in a combined shipment succeed on the same cycle, When creating the BC order, Then one order is posted with line items from both subscriptions, tagged with all member subscription_ids and charge_ids.
  • Given a charge fails while others succeed, When creating the order, Then only the succeeded subscriptions' line items are included; the failed one continues its own dunning.

US-16.3: Merchant view of combined shipments

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

Prototype: Merchant View

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

Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin

As a Merchant Admin, I want to see which orders are combined shipments and which subscriptions contributed, so that my ops team understands the shipment composition.

Acceptance criteria:

  • Given a combined-shipment order exists, When I view it in BC, Then the staff_notes show the [BUNDLE] marker (internal identifier) and list contributing subscription IDs.
  • Given I click into the combined-shipment detail in our admin, When it renders, Then I see line-item → subscription attribution.

UI states.

<!-- ui-states US-16.3 -->
surface: "Merchant admin combined-shipment views — apps/admin/src/pages/bundles/BundlesList.tsx (list) and apps/admin/src/pages/bundles/BundleDetail.tsx (detail) — React/BigDesign. List shows status badge, anchor date, member count, total, BC order link; detail shows line-item → subscription attribution + lifecycle events. Persona: Merchant Admin."
idle:
  render: "List: a BigDesign Table of bundles — 'Combined shipment' id link, Status Badge, Anchor date, Members (included/total), Total, BC order link (BundlesList lines 110-166). Detail: a header stat grid (anchor/members/total/BC order), a 'Member charges' table with Role / Subscription link / Plan / Customer / Charge / Shipping / Status per row, and a 'Lifecycle events' table (BundleDetail lines 156-304)."
  primary_action: "List: click a bundle id → /v2/bundles/:id (RouterLink line 117). Detail: click a subscription id → /subscriptions/:id (line 210), or the BC order deep-link (bcAdminOrderUrl)."
loading:
  trigger: "List: listBundles() in useEffect (BundlesList lines 73-83). Detail: getBundleDetail(id) in useEffect (BundleDetail lines 108-119)."
  render: "Plain text 'Loading…' while bundles===null (BundlesList line 100) and while data===null (BundleDetail lines 132-138) — no skeleton."
error:
  surfaced_at: "Full-panel BigDesign Message(type='error') replacing the view — never a toast. List: 'Failed to load bundles' (BundlesList lines 85-91). Detail: 'Failed to load combined shipment' WITH a '← Back to combined shipments' link (BundleDetail lines 121-130)."
  render: "The header plus the error message text (err.message)."
  recovery: "Detail offers the Back link to return to the list; the list has no in-app retry control — recovery is a page reload."
empty:
  render: "List: a bordered panel 'No combined shipments yet. When eligible subscribers opt in and their renewals fall within the configured combined-shipment window, the scheduler will create them here.' (BundlesList lines 101-108). Detail lifecycle events sub-table has its own empty: 'No lifecycle events recorded.' (BundleDetail line 270)."
edge_status:
  - status: "materialized — BC order created"
    affordance: "Green badge; open the detail or follow the 'BC order #…' deep-link (bcAdminOrderUrl) to inspect the combined order."
  - status: "partially_failed — some member charges excluded"
    affordance: "Warning badge; open the detail where excluded members show an 'excluded' badge + charge.failure_message (BundleDetail lines 247-253) so the merchant can see which subscription dropped and why."
  - status: "pending / locked — scheduler has not yet materialized"
    affordance: "Secondary badge; no action needed — the scheduler materializes on the anchor day; open the detail to inspect the composition meanwhile."
  - status: "cancelled — the combined shipment was voided"
    affordance: "Danger badge; open the detail to review the lifecycle events that recorded the cancellation."
inputs: []
disabled_focus:
  keyboard: "All interactive cells are native focusable elements: the bundle-id and subscription-id RouterLinks render anchors, and the BC order links are real anchors (target=_blank) — reached in DOM tab order (BundlesList lines 117, 151-160; BundleDetail lines 210, 179). No div-onClick. The BigDesign Table rows are not themselves clickable; the link inside each row is the focus target."
gaps: "BUILT, with a raw-enum defect. The Status Badge is rendered with label={status} directly (BundlesList line 126, BundleDetail line 153) and member charge status with label={m.charge.status} (line 255), so raw enum strings reach the merchant UI — 'materialized', 'partially_failed' (with the underscore), 'locked', 'pending', 'cancelled'. statusVariant() (lines 27-40) maps the enum to a COLOUR but there is no human-readable LABEL mapping; the contract north-star is a humanized label ('Partially failed', 'Materialized', …) alongside the existing colour variant."

US-16.4: Shipping cost allocation

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

Prototype: Shipping Split

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

Phase: P1 (pulled forward 2026-05-16) · Persona: System

As the System, I want shipping cost on a combined shipment to be fair, so that no subscription carries disproportionate cost.

Acceptance criteria:

  • Given a combined-shipment renewal runs, When shipping is quoted, Then the single shipping rate is applied to the combined order and charged proportionally (by line-item value) back to each subscription's charge amount.
Glossary — combined shipments
External (user-facing) Internal (held identifiers)
Combined shipments / Combine shipments bundles table, bundle_charges table
Combined-shipment window stores.bundle_window_days
Combined-shipment anchor day stores.bundle_anchor_day
Opt into combined shipments subscriptions.bundle_opt_in
(the co-scheduling sweep) runBundleSweep, findBundleCandidates, cron/bundle-sweep.ts
Combined-shipment order marker [BUNDLE] in BC staff_notes
combined_shipment.* (future) bundle.* events (bundle.created, …)

Renaming the internal bundle* identifiers (tables, columns, functions, routes, events) is a tracked follow-up under [Spec] #1649 Part A — held this pass because a schema/route rename is a migration with no user benefit. The mapping above is the contract so the internal↔external gap is documented, not silent drift.

US-16.5: Decoupled billing and shipping cadence — Phase 3 scaffold

<!-- traceability:start:US-16.5 --> <!-- traceability:end:US-16.5 -->

Phase: P3 · Persona: Merchant Admin / System

Phase 3 — architecture per future ADR. This user story is a deferred-build scaffold. It captures the capability boundaries and data model for Phase 3 planning. Full AC, scheduler implementation, and BC API integration require a dedicated [Decision] ADR before work begins. Do not implement until that ADR is ratified.

As a Merchant Admin, I want to configure separate billing and shipping cadences on a plan (e.g., bill annually, ship monthly), so that I can offer subscription-box clubs, pre-paid seasonal shipments, and B2B contracts that decouple payment from fulfillment.

Capability summary (Phase 3 scope).

  • plans.billing_cadence — existing interval_unit/interval_count fields renamed/aliased to mark them as billing-only
  • plans.shipping_cadence — new separate shipping_interval_unit/shipping_interval_count fields specifying the fulfillment schedule independently of billing
  • Decoupled schedulers: billing scheduler (Epic 10) fires MIT charges on the billing cadence; fulfillment scheduler fires BC order creation on the shipping cadence without an additional MIT charge (fulfillment-only BC order, payment_status: captured backed by the prior billing charge)
  • delivery_instances table (PRD §8 reference) — each physical delivery tracked separately from billing cycles; one billing cycle may produce N delivery records if billing_cadence > shipping_cadence
  • Fulfillment-only BC orders: BC orders created by the fulfillment scheduler carry no MIT charge; the backing charge is identified by delivery_instances.charge_id (the billing-cycle charge that covers this delivery period)

Demand signal. WCSubs community feature-request portal: 727 votes (#1 feature request). Zero of 9 BC marketplace subscription app competitors support native decoupled billing/shipping cadence. This is the single highest-priority blue-ocean differentiator for bc-subscriptions (A3 spec-comprehensiveness matrix U-16; persona P1 Maya — wine club bills annually, ships monthly).

Phase 3 blockers (do not build until resolved).

  1. Architecture ADR required: scheduler split design (single cron with dual scan phases vs. two independent crons), delivery_instances schema finalization, and fulfillment-only BC order payment_status semantics.
  2. Phase 2 fundamentals must be live: billing scheduler (Epic 10), BC order creation (Epic 14), and exception queue (Epic 21) are all prerequisite.
  3. BC API: confirm POST /v2/orders behavior for orders with payment_status: captured without a preceding MIT call — may require BC partner coordination.

Cross-references. A3 spec-comprehensiveness matrix U-16; Spec 9b0f68d7; WCSubs feature request #1 (727 votes); persona P1 Maya journey trace; Epic 10 US-10.1/US-10.2 (billing scheduler — extended in Phase 3); Epic 14 (BC order generation — fulfillment-only variant); PRD §8 (delivery_instances table reference).


UI states.

<!-- ui-states US-16.5 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) — plan configuration form, 'Billing and shipping cadence' section; a Phase 3 deferred-build scaffold for setting separate billing and fulfillment cadences on a subscription plan. Architecture ADR not yet ratified; the panel renders as a read-only gated preview until ratification. Persona: Merchant Admin."
idle:
  render: "On the plan edit page, a 'Billing and shipping cadence' BigDesign Panel shows two cadence rows: 'Billing cadence' (how often the subscriber is charged — the existing interval fields) and 'Shipping cadence' (a new independent fulfillment schedule — how often an order is created). Each row has an interval-count number Input and an interval-unit Select (Week / Month / Year). A summary line reads 'Bill every [X unit], ship every [Y unit].' A Phase 3 notice BigDesign Message(type='info') renders above the section while the architecture ADR is unratified; the save button for this section is disabled in that state."
  primary_action: "Save plan → PATCH /api/v1/admin/plans/{id} with billing_interval + billing_interval_count + shipping_interval + shipping_interval_count fields; on success the plan reflects the decoupled cadence."
loading:
  trigger: "PATCH /api/v1/admin/plans/{id} while the submit request is in-flight."
  render: "The save Button shows 'Saving…' with BigDesign isLoading; both the billing and shipping cadence Inputs and Selects are disabled; no double-submit."
error:
  surfaced_at: "Inline within the 'Billing and shipping cadence' Panel as a BigDesign Message(type='error') below the cadence inputs and above the save Button — never a toast; scoped to this configuration section."
  render: "The API or validation failure reason (e.g. 'Shipping cadence cannot be shorter than billing cadence' or 'Decoupled cadence requires Phase 3 scheduler — ADR not yet ratified')."
  recovery: "The BigDesign Message persists and all form controls re-enable; the merchant corrects the cadence values and re-saves, or acknowledges the architecture gate and defers action until Phase 3 is live."
empty:
  render: "Not a list surface. The plan form always has default cadence values: billing cadence inherits the plan's existing interval; shipping cadence defaults to match billing cadence as a no-op starting state. There is no blank state."
  cta: "n/a (configuration form with defaults always present)"
edge_status:
  - status: "architecture ADR not ratified (Phase 3 gate)"
    badge: "Coming in Phase 3"
    affordance: "The cadence Inputs and Selects are read-only and the decoupled-cadence save Button is disabled. A BigDesign Message(type='info') reads 'Decoupled billing and shipping cadences are a Phase 3 capability. The architecture ADR defining the scheduler split and delivery_instances schema must be ratified before this setting can be activated.'"
  - status: "shipping_cadence shorter than billing_cadence (invalid — fulfillment outpaces billing)"
    badge: "Validation error"
    affordance: "Inline validation beneath the shipping cadence inputs: 'Shipping cadence cannot be shorter than billing cadence — each billing cycle must cover at least one shipment.' Merchant adjusts the shipping interval to be ≥ billing interval and re-saves."
  - status: "billing_cadence equals shipping_cadence (no-op decoupling)"
    affordance: "Allowed, surfaced with a BigDesign Message(type='info'): 'Billing and shipping cadences match — this plan behaves identically to a standard plan. Set a longer billing cadence than the shipping cadence to enable the wine-club or pre-paid shipment pattern.'"
  - status: "active subscriptions exist on this plan (cadence change is prospective only)"
    badge: "Note"
    affordance: "A BigDesign Message(type='warning') reads 'Changing cadences affects new subscriber cycles only — existing subscribers remain on their current schedule until their next renewal.' Merchant acknowledges and saves to proceed."
inputs:
  - field: "billing_interval_unit"
    control: "select"
    label: "Billing interval unit"
    allowed_values: "Week | Month | Year"
  - field: "billing_interval_count"
    control: "number"
    label: "Bill every (N)"
    validation: "integer ≥ 1"
  - field: "shipping_interval_unit"
    control: "select"
    label: "Shipping interval unit"
    allowed_values: "Week | Month | Year"
  - field: "shipping_interval_count"
    control: "number"
    label: "Ship every (N)"
    validation: "integer ≥ 1; shipping cadence must be ≥ billing cadence"
disabled_focus:
  keyboard: "All four cadence controls (two BigDesign number Inputs, two BigDesign Selects) and the save Button are real BigDesign components wrapping native focusable elements — no div-onClick. Tab order follows DOM source: billing count → billing unit → shipping count → shipping unit → Save → Cancel. Controls are disabled (removed from tab order) while the save is in-flight and while the Phase 3 architecture gate is unratified."
  focus_move: "On successful save the form re-renders with the confirmed cadence values; a BigDesign Message(type='success') announces the change via aria-live=polite. On validation error the Message renders and is announced via aria-live=assertive."