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 15 — Inventory, tax, shipping recalculation at renewal (derived view)
Read-only per-epic slice of
BRD.md§9, lines 5708–5862. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-15.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (6): US-15.1, US-15.2, US-15.3, US-15.4, US-15.5, US-15.6
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 15 — Inventory, tax, shipping recalculation at renewal
<!-- traceability:start:BRD:Epic-15 --><!-- traceability:end:BRD:Epic-15 -->Prototype: Inventory Check · Price Strategy · Tax Address · Shipping Quote
Value: Every renewal behaves like a live checkout with current catalog state, preventing drift between what the subscriber expects and what the order contains.
US-15.1: Inventory check before charge
<!-- traceability:start:US-15.1 --><!-- traceability:end:US-15.1 -->Prototype: Inventory Check
Phase: MVP · Persona: System
As the System, I want to check inventory before charging, so that I don't charge for an order I can't fulfill.
Acceptance criteria:
- Given a charge is about to execute, When the inventory check runs, Then BC API returns current inventory per variant.
- Given inventory < required, When checked, Then per merchant's backorder policy: allow (continue) / skip (abandon charge) / substitute (swap to configured alternate SKU) / pause subscription.
US-15.2: Price recalculation at renewal
<!-- traceability:start:US-15.2 --><!-- traceability:end:US-15.2 -->Prototype: Price Strategy
Phase: MVP · Persona: System
As the System, I want renewal prices to reflect current catalog unless locked, so that price-list-driven strategies stay correct.
Acceptance criteria:
- Given a plan using "BC Price List" strategy, When the charge computes, Then the Price List is re-read at renewal time.
- Given a plan using "Fixed discount %," When the charge computes, Then current catalog price × discount is used.
- Given a subscription has
locked_price_cents, When the charge computes, Then locked price is used.
US-15.3: Tax recalculation at renewal
<!-- traceability:start:US-15.3 --><!-- traceability:end:US-15.3 -->Prototype: Tax Address
Phase: MVP · Persona: System
As the System, I want tax computed on current shipping address at renewal time, so that subscribers who moved pay correct tax.
Acceptance criteria:
- Given a subscription has shipping address and current line items, When the charge runs, Then BC's tax-calculation endpoint is called with current jurisdiction.
- Given BC's tax provider (BC Tax, Avalara, TaxJar via BC integration) returns a rate, When used, Then the final charge includes that tax.
US-15.4: Shipping cost recalculation
<!-- traceability:start:US-15.4 --><!-- traceability:end:US-15.4 -->Prototype: Shipping Quote
Phase: MVP · Persona: System
As the System, I want shipping cost computed at renewal, so that subscriber charges include correct shipping for their current address and current shipping rules.
Acceptance criteria:
- Given a subscription renews, When the charge computes, Then BC's shipping-quote endpoint is called and the merchant-selected shipping method's rate is applied.
- Given shipping method becomes invalid (service discontinued), When the quote fails, Then the subscription goes into exception queue with options: pick alternate method / pause.
US-15.5: Free shipping on subscription
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to offer free shipping on all subscription renewals as a loyalty perk, so that I incentivize continued subscription.
Acceptance criteria:
- Given plan setting "Free shipping for subscribers: on," When a renewal runs, Then shipping cost is zeroed regardless of cart value.
UI states.
<!-- ui-states US-15.5 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan settings — a 'Free shipping for subscribers' toggle (BigDesign Checkbox) on the plan edit page governing whether renewal shipping cost is zeroed for all subscribers on this plan. Persona: Merchant Admin."
idle:
render: "The plan edit page includes a 'Shipping' section containing a 'Free shipping for subscribers' BigDesign Checkbox, defaulting to unchecked (off). When checked, inline help text reads: 'Renewal shipping cost will be $0.00 for subscribers on this plan, regardless of cart value or shipping zone. Subscribers see no shipping line on their renewal receipt.' When unchecked, standard renewal shipping recalculation (US-15.4) applies and no override fires."
primary_action: "'Save plan' — includes free_shipping_enabled in the plan PATCH payload — POST /api/v1/admin/plans/{id} with {free_shipping_enabled: true|false}."
loading:
trigger: "PATCH /api/v1/admin/plans/{id} on save."
render: "The 'Save plan' button shows 'Saving…' and is disabled; the free_shipping_enabled checkbox and all other plan edit fields are disabled during the in-flight PATCH."
error:
surfaced_at: "Inline BigDesign Message(type='error') directly below the 'Free shipping for subscribers' checkbox (role=alert) when the plan save fails — scoped to this plan save, never a page-level banner that disassociates from the control that failed."
recovery: "The plan edit form stays populated and re-enables with the checkbox in its attempted state; the merchant corrects any other validation error on the form and resubmits. A 'Retry' action in the error message re-fires the PATCH if the failure was transient."
empty:
render: "Not a list surface — a singleton plan-edit form. The 'Free shipping for subscribers' checkbox always renders in the shipping section. There is no 'no data' empty state for this toggle."
edge_status:
- status: "toggle enabled while active subscriptions on this plan are mid-cycle"
affordance: "Inline help text on save clarifies: 'Free shipping applies to future renewals. Subscribers currently mid-cycle are not retroactively affected.' No retroactive charge recalculation fires."
- status: "toggle disabled (free shipping removed from plan)"
affordance: "Renewals for this plan resume standard shipping recalculation (US-15.4) from the next cycle. No subscriber notification fires automatically — the merchant is responsible for communicating the perk removal if desired."
- status: "plan is applied to a digital or virtual product with no shipping methods"
affordance: "The checkbox renders but a note beneath it reads: 'No shipping methods are configured on this plan — this setting has no effect for non-physical products.' The checkbox can be saved without error; the note persists as a reminder."
disabled_focus:
keyboard: "The 'Free shipping for subscribers' control is a real BigDesign Checkbox wrapping a native <input type=checkbox>, not a div-onClick. It is reachable via Tab in the plan edit form's natural Tab order alongside other plan fields. 'Save plan' is a real Button activatable with Enter/Space. No focus trap is introduced by this checkbox."
US-15.6: Pre-renewal OOS check — proactive inventory scan before charge with Exception Queue escalation
Phase: P2 · Persona: Merchant Admin / System
As a Merchant Admin, I want the system to check product inventory ahead of a renewal charge and escalate to an Exception Queue when stock is unavailable, so that I can resolve OOS issues before my subscriber is charged for an item I cannot ship. As the System, I want to enforce the plan's OOS renewal policy before firing the MIT charge, so that subscribers are never charged for unfulfillable orders.
Acceptance criteria:
- Given a plan with
oos_renewal_policy: 'pause', When the scheduler runs the pre-renewal OOS check and the product variant is out of stock, Then the subscription transitions topaused; the charge is not attempted; next renewal is rescheduled for+Nretry days (merchant-configured default); the subscriber receives an OOS notification email within 15 minutes of scheduled renewal time. - Given a plan with
oos_renewal_policy: 'skip_cycle', When the pre-renewal OOS check finds OOS, Then the current cycle is skipped (no charge, no BC order materialized);next_charge_atadvances to the next scheduled renewal date; the subscriber receives an OOS skip notification email. - Given a plan with
oos_renewal_policy: 'notify_and_wait', When the pre-renewal OOS check finds OOS, Then the subscription transitions toon_hold_oos; an Exception Queue entry ofexception_type: 'oos_renewal_blocked'is created surfacing subscriber name, product, variant,oos_since, and policy applied; the merchant sees three resolution actions — "Resume now" (re-check stock and charge if available), "Skip cycle", or "Substitute product"; the subscriber receives an OOS notification within 15 minutes. - Given a plan with
oos_renewal_policy: 'proceed'(default), When the pre-renewal check finds OOS, Then the charge proceeds identically to current behavior (backwards-compatible; US-15.1 at-charge-time policy still applies). - Given the product's variant has
track_inventory = false, When the pre-renewal OOS check runs, Then inventory is considered available regardless ofinventory_level; no OOS escalation occurs. - Given a merchant clicks "Resume now" on an Exception Queue OOS entry and the variant is back in stock, When the action is confirmed, Then the subscription exits
on_hold_oos; the charge fires immediately; the Exception Queue entry is resolved. - Given a merchant selects "Substitute product" on an OOS Exception Queue entry, When they confirm a replacement variant, Then
next_charge_atfires with the substitute variant as line item; original subscription product reference is preserved in history.
Schema fields.
Plans table: oos_renewal_policy ENUM('proceed','pause','skip_cycle','notify_and_wait') (default 'proceed').
Subscriptions status: new on_hold_oos state (terminal-recoverable; resumes via merchant action or auto-retry).
Exception Queue: new exception_type = 'oos_renewal_blocked' records subscription_id, product_id, variant_id, oos_since, policy_applied, renewal_scheduled_at.
Distinction from US-15.1. US-15.1 performs an automated at-charge-time inventory check with immediate machine-policy response (allow/skip/substitute/pause — no merchant UI loop). US-15.6 adds: (a) a proactive pre-renewal scan that runs ahead of the charge window giving the merchant a resolution window, (b) the notify_and_wait path that holds the charge pending explicit merchant action, (c) subscriber notification, and (d) the merchant-facing Exception Queue resolution UI.
Cross-references. A3 spec-comprehensiveness matrix U-18; Spec 08381c59; persona P1 Maya journey trace; BRD US-15.1 (automated-policy complement); BRD glossary "Exception Queue"; WCSubs feature request #5 (59 votes).
UI states.
<!-- ui-states US-15.6 -->surface: "Admin (React/BigDesign) exception queue — out-of-stock renewal rows ('oos_at_renewal') in apps/admin/src/pages/exceptions/ExceptionQueueList.tsx (ExceptionType, lines 32-38; TYPES, lines 54-61). Persona: Merchant Admin / Support. BRD §US-15.6 names the type 'oos_renewal_blocked' and the status 'on_hold_oos'; the impl uses 'oos_at_renewal' (db.ts ExceptionType, lines 1800-1806) — naming drift (see gaps)."
idle:
render: "An OOS renewal exception renders as a standard ExceptionRowEl (lines 389-433): severity bar, the translated type label via FormattedMessage id merchantAdmin.exceptions.type.<type> (line 395), customer, impact and age. Expanding (setExpandedId, line 390) reveals detected-at, the proposed-remediation copy ('Substitute the next charge with a back-in-stock variant or pause until restocked.', db.ts:2024), and the action row (lines 449-466)."
primary_action: "On an oos_at_renewal row only 'Open subscription' (lines 451-456) and 'Mark resolved' (lines 457-459) render. NORTH-STAR (BRD §US-15.6 AC): three OOS-specific actions — 'Resume now' (re-check stock and charge if available), 'Skip cycle', and 'Substitute product' — each backed by a distinct API call. None rendered (see gaps)."
loading:
trigger: "GET /api/v1/exceptions on mount and on filter/sort change (fetchExceptions, lines 129-147; useEffect, lines 184-200). The generic resolve POSTs /api/v1/admin/exceptions/{id}/resolve (resolveException, exceptions.ts:44-57)."
render: "While items === null && !error a plain 'Loading…' Text (merchantAdmin.exceptions.loading, lines 348-352); no skeleton. During a resolve the modal confirm Button shows isLoading and disables (lines 489-490)."
error:
surfaced_at: "(1) List-load failure: page-level BigDesign Message(type='warning', header merchantAdmin.exceptions.errorHeader) above the Layout (lines 280-288); (2) resolve failure: inline Message(type='error') inside the resolve Modal (lines 506-513)."
render: "The thrown reason verbatim — 'HTTP <status>' from fetchExceptions (line 144) or 'NO_AUTH_TOKEN' (line 133); resolve errors carry the authedFetch 'HTTP <status>: <body>' string (exceptions.ts:32-34)."
recovery: "List error: toggling a rail filter re-fires fetchExceptions (lines 184-200). Resolve error: resolving resets to false (line 232) so the rep retries the note, or Cancel dismisses (lines 482-485)."
empty:
render: "When the queue is empty (items && visible.length === 0) the shared 'All clear' card renders (success-check circle + merchantAdmin.exceptions.empty.title/.empty.body, lines 354-384). Note the live aggregator's classifyType (db.ts:1874-1882) never returns 'oos_at_renewal' — OOS exceptions are not produced from the charges feed today, so the OOS slice is empty in practice (see gaps)."
edge_status:
- status: "oos_at_renewal — renewal blocked, awaiting merchant action (BRD 'on_hold_oos')"
affordance: "NORTH-STAR: 'Resume now' / 'Skip cycle' / 'Substitute product', each a distinct API call. TODAY only 'Open subscription' + 'Mark resolved' (lines 451-459). The codebase already has the typed-multi-action resolve pattern for a sibling type — handleResolveShippingException accepts action 'pick_alternate_method' | 'pause' (shipping-resolve.ts:23-39) — but there is no oos_at_renewal equivalent endpoint or UI (see gaps)."
- status: "charge_failed (sibling type, for contrast)"
affordance: "charge_failed rows DO get a type-specific 'Refund customer' Button (lines 460-464); oos_at_renewal gets no equivalent type-specific action — that asymmetry is the gap."
- status: "oos resolved generically"
affordance: "'Mark resolved' (lines 457-459) opens the resolve Modal (note required, line 489); on success the row leaves the list (handleResolve, line 226) — but no stock re-check, skip, or substitution is performed."
disabled_focus:
keyboard: "The in-row actions and the resolve Modal (note Textarea + Cancel/Confirm) are real BigDesign Button/Textarea elements reachable in tab order; the Modal(variant='dialog') traps focus and is Esc-dismissable (onClose, line 478)."
guard: "Resolve requires a non-empty note (line 489); the generic resolve is non-destructive, so no typed-confirm. The north-star 'Substitute product' would need a variant picker + confirm — no such control exists."
gaps: "(1) None of the three BRD §US-15.6 OOS actions (Resume now / Skip cycle / Substitute product) exist — only the generic resolve modal. (2) No oos-specific resolve endpoint, though the typed-action pattern exists for shipping_quote_failed (shipping-resolve.ts:23-39). (3) The live aggregator never classifies an OOS row (classifyType, db.ts:1874-1882), so oos_at_renewal exceptions aren't produced from the charges feed. (4) Type/status naming drift: impl 'oos_at_renewal' vs BRD 'oos_renewal_blocked'/'on_hold_oos'. (5) The row expander is a div-onClick (line 390), not keyboard-focusable."