← All epicsBRD.md §9 · lines 10327–10932

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 25 — Subscription-aware promotions & discounts (derived view)

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

  • Stories (11): US-25.1, US-25.2, US-25.3, US-25.4, US-25.5, US-25.6, US-25.7, US-25.8, US-25.9, US-25.10, US-25.11
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 25 — Subscription-aware promotions & discounts

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

Prototype: Promo Builder · Active Promos · Stacking Rules · Subscribe & Save · Stacking Rules Admin · Tenure Ladder

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

Value: Merchants can run subscription-exclusive marketing with precise targeting and stacking rules.

Epic context. Promotion rules are a known source of accounting errors. Clarity on which promotion applies, in what order, and with what stacking rule is essential. Every applied discount must be traceable back to its source promotion.

US-25.1: Subscription-only coupon codes

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

Prototype: Promo Builder

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

Phase: MVP · Priority: P0 · Effort: L · Persona: Merchant Admin

As a Merchant Admin, I want to create coupon codes valid only for subscription purchases, so that I run subscribe-and-save promotions without leaking discounts to one-time buyers.

Acceptance criteria:

  • Given I create a coupon with applies_to: subscription_only, When a shopper applies it in cart, Then it discounts only subscription line items; one-time lines aren't affected.
  • Given the coupon has recurring: true, When the discount applies at checkout, Then subsequent renewals also carry the discount until expiration or cycle cap.

UX notes.

  • Surface: merchant promotion editor (new screen in admin)
  • Form: code, amount (% or $), applies_to: subscription_only | one_time_only | both, recurring: bool, cycle_range: [min,max], stacking rule, usage limits, expiry
  • Storefront: coupon code field in cart; we inject validation via Stencil API

Data contract.

  • Entity: promotions{id, store_hash, code, type, value, applies_to, recurring, cycle_range, stacking_rule, usage_limit, used_count, starts_at, ends_at}
  • At cart validation: our endpoint returns eligibility per line item
  • At renewal: if subscription has applied_promotion_id and recurring=true, apply discount; decrement any cycle-bounded counter

Success metrics.

  • Functional: promotions apply correctly in all combinations (subscription vs. mixed carts) with 100% test coverage
  • Product: promotion-attributable conversion lift measurable per campaign

Dependencies.

  • Cart validation hook (Stencil API or Catalyst plugin)
  • US-15.2 (renewal recalc applies ongoing promotions)

Non-functional.

  • Code collision prevented by unique constraint
  • Audit log: every promotion application logs {subscription_id, charge_id, promotion_id, amount_discounted}

UI states.

<!-- ui-states US-25.1 -->
surface: "Admin (React/BigDesign) promotions list — apps/admin/src/pages/promotions/PromotionsList.tsx (PromotionsList, H2 'merchantAdmin.promotions.list.title'). Persona: Merchant Admin. Lists BC promotions proxied from GET /api/v1/admin/promotions (listPromotions) and exposes a per-row Switch that PATCHes the subscription_only flag (patchPromotionSettings) with an optimistic update + rollback. NOT a create surface — coupons are created in BigCommerce first, then toggled here (file header lines 4-12)."
idle:
  render: "A BigDesign Table (keyField bc_promotion_id, stickyHeader, lines 246-253) with columns Name (+ #id), Type (coupon/automatic), Status badge (enabled/disabled), Uses (current/max), Expires, and a 'Subscription-only' column rendering a Switch per row (lines 182-207)."
  primary_action: "Per-row Switch toggles subscription_only; flips optimistically then persists via patchPromotionSettings (handleToggle, lines 85-116)."
loading:
  list: "Until the first listPromotions resolves (promotions === null && !loadError) a plain 'merchantAdmin.promotions.list.loading' Text is shown (lines 229-233) — no skeleton, no spinner."
  row: "While a toggle is in flight (rowState.saving) the Switch is disabled and a Small 'merchantAdmin.promotions.toggle.saving' label renders beside it (lines 187, 194-198); no double-submit."
error:
  surfaced_at: "List-load failure renders a BigDesign Message(type='error') banner above the table carrying the raw error text (loadError, lines 220-227). A per-row toggle failure renders a low-contrast Small(color='danger40') line beneath that row's Switch (rowError, lines 200-204) and rolls the Switch back to its prior value (handleToggle catch, lines 102-113)."
  recovery: "List-load failure has NO in-app retry control — the only path back is a full page reload (reload() runs only in the mount useEffect, lines 81-83). A failed row toggle leaves the table interactive: the merchant flips the Switch again to retry."
empty:
  render: "When listPromotions returns zero rows (promotions !== null && length === 0 && !loadError) a Message(type='info', 'merchantAdmin.promotions.list.empty') is shown instead of the table (lines 235-244). NO create-coupon CTA — the merchant must create the promotion in BigCommerce first, then return here to toggle it."
edge_status:
  - status: "row toggle in flight (rowState.saving)"
    affordance: "Switch disabled + Small 'Saving…' label; the merchant waits for the PATCH to settle (lines 187, 194-198)."
  - status: "row toggle failed — optimistic update rolled back"
    affordance: "Switch reverts to its prior value and a Small danger40 error line appears under the row (lines 200-204); the merchant flips the Switch again to retry. North-star: surface the failure with role=alert so the rollback is not silent (see gaps)."
inputs: []
disabled_focus:
  keyboard: "Each row's subscription_only control is a real BigDesign Switch (native checkbox) with an aria-label ('merchantAdmin.promotions.subscriptionOnly.tooltip') and a data-testid (lines 185-193) — reachable in Tab order, toggled with Space, never a div-onClick. Native disabled removes the Switch from the tab order while its row is saving."
  gaps: "The per-row error (Small color='danger40', lines 200-204) has no role='alert' and no aria-live region, and the Switch silently rolls back — a keyboard/screen-reader merchant gets no announcement that the save failed."
gaps: "[partial] This surface only toggles subscription_only on promotions that already exist in BigCommerce; the BRD §US-25.1 promotion editor (code / amount / applies_to / recurring / cycle_range / stacking_rule form) is not built here — merchants create the coupon in BC, then toggle it (file header lines 4-12). List-load failure exposes no retry affordance (reload-page only). The per-row save error is low-contrast Small text with a silent rollback, easy to miss."

US-25.2: Subscribe-and-save auto-promotion

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

Prototype: Subscribe & Save

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

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want to show a "Subscribe and save 10%" badge across my catalog without requiring a coupon code, so that subscribers do not have to enter a code at checkout.

Acceptance criteria:

  • Given a plan's pricing strategy is fixed_discount_pct: 10, When a shopper views a subscribable PDP, Then the widget shows "Subscribe and save 10%" prominently.
  • Given the shopper subscribes, When they check out, Then the discount applies without coupon code entry.

UI states.

<!-- ui-states US-25.2 -->
surface: "Storefront PDP subscription widget — apps/storefront-svelte/src/lib/subscriptions/SubscriptionWidget.svelte — 'subscribe & save N%' badge + CTA suffix. Admin config: apps/admin/src/components/forms/PricingForm.tsx (discount_percent strategy radio + Counter) consumed by apps/admin/src/pages/products/PlanWizard.tsx and apps/admin/src/pages/plans/PlanEdit.tsx. Persona: Merchant Admin (config) → Subscriber (storefront promo)."
idle:
  render: "In subscribe mode the widget shows the subscribe price, the struck-through one-time price, and a 'member −N%' savings pill (line 564) when savingsPct is non-null; the CTA reads 'Start Auto-Refill · save N%' (line 733). The merchant sets this via PricingForm 'discount_percent' radio (lines 55-61) plus a Counter bounded 1–50% (lines 84-93)."
  primary_action: "Subscriber: 'Start Auto-Refill · save N%' adds the subscribe intent to the BC cart (handleSubscribe → handleSubscribeBcPayments, lines 347-404). Merchant: the discount_percent Counter writes plan.pricing_strategy on save (PlanWizard line 299 / PlanEdit line 105)."
loading:
  trigger: "Widget: apiClient.getPlans({ bcProductId }) on mount (lines 116-153). Admin: PlanWizard/PlanEdit submit POSTs the plan."
  render: "Widget shows the skeleton card (aria-busy, lines 470-475) until plans resolve; the savings pill only computes once selectedPlan and oneTimePriceCents are known. Admin: the wizard save button enters its BigDesign busy state."
error:
  surfaced_at: "Widget: inline role=alert in the one-time fallback (data-testid='widget-load-error', lines 522-528) on plan-load failure, and a role=alert below the CTA (lines 769-771) on add-to-cart failure — scoped to the widget, never a toast. Admin: PricingForm renders no error of its own; submit errors surface in the parent wizard."
  render: "Widget add-to-cart failure: 'Could not add to cart. Try again.' (line 398) or the raw err.message. Plan-load failure: the degrade-to-one-time notice."
  recovery: "Re-press the CTA after an add-to-cart failure (submitting resets in the finally block, line 402); on plan-load failure the shopper buys one-time and refreshes to retry."
empty:
  render: "Not a list surface — a single-product promo. The 'no promo' case is structural: when savingsPct is null (no base price, or subscribe price ≥ one-time) the pill and the '· save N%' CTA suffix simply do not render and the CTA falls back to plain 'Start Auto-Refill' (lines 732-733) — see gaps: this silently hides a configured discount when the BC base price is absent."
edge_status:
  - status: "Plan has discount_percent but the product has no BC base price (oneTimePriceCents null)"
    affordance: "Contract north-star: still show 'subscribe & save N%' derived from the plan’s own discount field. Today the badge is suppressed (savingsPct returns null, line 175) — the only recovery is the merchant setting a BC base price; see gaps + defects."
  - status: "Subscribe price ≥ one-time price (no real saving)"
    affordance: "No savings pill (correct); the subscribe option still renders so the shopper can subscribe for convenience and the CTA reads plain 'Start Auto-Refill'."
  - status: "Intro / cycle discount configured (cycle_discount_pct or cycle_discounts ladder)"
    affordance: "A distinct coral 'Intro offer' banner renders the first-order framing (introOffer, lines 210-270) above the steady-state pill."
inputs: []
disabled_focus:
  keyboard: "Widget: the subscribe radio, cadence buttons, and CTA are native focusable controls (disabled={submitting} during submit). Admin PricingForm: the strategy Radio controls and the discount Counter are BigDesign components wrapping native focusable inputs, each in a FormGroup with a label — reached in DOM tab order, no div-onClick."
gaps: "BUILT. The savings badge is PRICE-DERIVED, not read from the plan discount field: savingsPct = round(1 − amount_cents / oneTimePriceCents) and returns null whenever oneTimePriceCents is null/0 (lines 174-178). So a plan whose pricing_strategy is discount_percent shows NO 'subscribe & save' badge on any PDP whose product lacks a BC base price — the discount is known server-side (baked into amount_cents) but the storefront cannot render the percentage, so the subscriber-facing promo silently degrades to no-badge."

US-25.3: Cycle-scoped discount

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

Prototype: Tenure Ladder

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

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want to offer "50% off your first order" that applies to cycle 1 only, so that I boost first-cycle acquisition without discounting lifetime.

Acceptance criteria:

  • Given a coupon with cycle_range: [1,1], When cycle 1 charge computes, Then discount applies.
  • Given cycle 2+ charges compute, Then discount does not apply.

UI states.

<!-- ui-states US-25.3 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) promotion create/edit form — the cycle-range section within the promotion create/edit page, accessible from Settings → Promotions → New promotion (or edit an existing one). Persona: Merchant Admin. Configures cycle_range: [from, to] on a coupon so the discount applies only within the specified cycle window (e.g. [1,1] = first cycle only)."
idle:
  render: "A 'Cycle range' FormGroup within the promotion form. Two side-by-side BigDesign Input(type=number) fields — 'First eligible cycle' (min=1, default=1) and 'Last eligible cycle (blank = no limit)' (min=1, placeholder blank). A helper Small text beneath: 'Leave end blank to apply from cycle N onward indefinitely. Set both to 1 for first-cycle-only discounts.' The cycle-range section is visible only when 'Subscription-only' is toggled on for the promotion."
  primary_action: "Save promotion (the outer form's primary Button) — persists the promotion including cycle_range to the API."
loading:
  load: "While the promotion loads on the edit path, the cycle-range Inputs render disabled with no values until the fetch resolves."
  save: "While the outer form save is in-flight, the Save Button shows its BigDesign isLoading spinner; all Inputs including the cycle-range pair are disabled to prevent mid-save edits."
error:
  surfaced_at: "Inline, directly beneath the cycle-range FormGroup pair (role=alert), scoped to the cycle-range field — not a page-level banner. A separate Message(type='error') above the Save/Cancel buttons surfaces API-level save failures."
  recovery: "Correct the cycle values (ensure 'First eligible cycle' is ≤ 'Last eligible cycle') and resubmit. On API save failure the form re-enables with populated values so the merchant can fix and retry."
empty:
  render: "Not a list surface — a single-promotion form. The form starts with cycle_range defaulting to first=1, last=blank (discount applies from cycle 1 indefinitely); the merchant narrows the range to restrict to a specific cycle window. When no promotions exist at all, that empty state is owned by the promotions list page, not this form."
inputs:
  - field: "cycle_from"
    control: "number"
    label: "'First eligible cycle' — BigDesign Input type=number, min=1, default=1"
    validation: "integer >= 1; required when cycle range is configured"
  - field: "cycle_to"
    control: "number"
    label: "'Last eligible cycle (blank = no limit)' — BigDesign Input type=number, min=1"
    validation: "integer >= cycle_from when set; blank = open-ended"
  - field: "discount_pct"
    control: "number"
    label: "'Discount percentage' — BigDesign Input type=number, min=1, max=100"
    validation: "integer 1–100; required"
edge_status:
  - status: "cycle_to < cycle_from (invalid range)"
    affordance: "Inline validation error beneath the cycle-range FormGroup blocks Save; merchant corrects 'Last eligible cycle' to be ≥ 'First eligible cycle'."
  - status: "no active plans exist to attach the promotion to"
    affordance: "A Message(type='info') in the plan-association step notes no active plans are configured; a 'Create a plan' link routes to the plan creation flow so the merchant can unblock themselves without hitting a dead end."
disabled_focus:
  keyboard: "Both cycle-range Inputs are real BigDesign Input components inside FormGroups with label props — reachable via Tab in DOM source order (cycle_from → cycle_to). The discount_pct Input is the next Tab stop. The outer Save Button is reachable via Tab and activatable with Enter or Space. All controls are disabled (not hidden) during load and save, which removes them from the tab order while in-flight."

US-25.4: Tenure-based loyalty discount

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

Prototype: Stacking Rules

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

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

As a Merchant Admin, I want subscribers to earn a larger discount the longer they stay (e.g., 5% after 6 cycles, 10% after 12), so that I reward loyalty automatically.

Acceptance criteria:

  • Given tenure tiers are configured, When a renewal computes, Then the discount matching current cycle count is applied.
  • Given the subscriber cancels and rejoins within N days, When tiering computes, Then prior cycle count is restored (per merchant config).

Data contract.

  • Entity: loyalty_tiers{id, plan_id, min_cycles, discount_pct}
  • On renewal: evaluate subscriber's cycles_completed against tiers, apply highest matching
  • Events: loyalty.tier_reached, loyalty.discount_applied

Success metrics.

  • Product: tenure-reward-enabled plans show measurable retention lift vs. control at cycle 12 (target ≥ 1.15×)

Dependencies.

  • US-25.5 (stacking rules if combined with promotions)

UI states.

<!-- ui-states US-25.4 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan loyalty-tier configuration — a 'Loyalty rewards' section within the plan edit form, accessed via Plans → edit plan → Loyalty rewards. Persona: Merchant Admin. Configures loyalty_tiers rows (min_cycles, discount_pct) on a plan and an optional rejoin grace window."
idle:
  render: "An enable/disable Button for 'Loyalty rewards'. When enabled: a Message(type='info') propagation notice — 'Discount tiers apply at the next renewal cycle for active subscribers on this plan.' Below it a row-based tier table; each row has 'After N completed cycles' Input(type=number, min=1), 'Discount %' Input(type=number, min=1, max=100), and a remove Button (aria-label='Remove tier'). Below the table a '+ Add tier' Button. Below the table a 'Rejoin grace window' FormGroup: a numeric Input(type=number, min=0) for grace days with helper text 'Subscribers who cancel and rejoin within this many days have their prior cycle count restored. Set to 0 to disable.'"
  primary_action: "Save the plan — persists loyalty_tiers and rejoin_grace_days via PATCH to the plan API."
loading:
  load: "While the plan loads, the loyalty section renders disabled with no tier rows."
  save: "While save is in-flight, the Save Button shows its BigDesign isLoading spinner; all tier Inputs, the '+ Add tier' Button, the enable toggle, and the rejoin grace Input are disabled."
error:
  surfaced_at: "Inline beneath the offending tier row (role=alert) for validation errors such as duplicate min_cycles values. A Message(type='error') banner above the Save/Cancel buttons for API save failures, carrying the error detail string."
  recovery: "Correct the offending tier values (e.g. deduplicate min_cycles thresholds across rows) and resubmit. On API save failure the form re-enables with preserved values so the merchant can retry."
empty:
  render: "When loyalty rewards are enabled but no tiers have been added yet — the tier table shows 'No tiers configured. Add one below.' helper text alongside the '+ Add tier' Button. The Save Button remains disabled until at least one valid tier is defined."
inputs:
  - field: "tier_min_cycles"
    control: "number"
    label: "'After N completed cycles' — BigDesign Input type=number, min=1"
    validation: "integer >= 1; must be unique across tiers on this plan"
  - field: "tier_discount_pct"
    control: "number"
    label: "'Discount %' — BigDesign Input type=number, min=1, max=100"
    validation: "integer 1–100"
  - field: "rejoin_grace_days"
    control: "number"
    label: "'Rejoin grace window (days)' — BigDesign Input type=number, min=0"
    validation: "integer >= 0; 0 = grace disabled; max=365"
edge_status:
  - status: "duplicate min_cycles across tiers"
    affordance: "Inline validation error beneath the conflicting rows; Save is blocked. Merchant removes or renumbers the duplicate tier to make thresholds unique."
  - status: "loyalty tiers enabled — propagates to active subscribers at next renewal"
    affordance: "The propagation Message(type='info') above the tier table is visible before Save; the merchant reviews the blast radius and, if they want to protect in-flight subscriptions, considers whether to enable US-25.11 ladder_lock before saving."
  - status: "rejoin grace active — returning subscriber rejoins within grace window"
    affordance: "The API restores prior cycle count at re-subscription; the merchant can adjust the grace window or set it to 0 on this same plan edit form to disable the restoration behavior."
disabled_focus:
  keyboard: "The enable/disable Button, each tier's 'After N completed cycles' and 'Discount %' Inputs, per-row remove Buttons (aria-label='Remove tier'), the '+ Add tier' Button, and the rejoin grace Input are all real BigDesign components reachable in DOM tab order. The outer Save Button is reachable via Tab and activatable with Enter or Space. Controls are disabled (not hidden) during load and save, removing them from the tab order while in-flight."

US-25.5: Promotion stacking rules

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

Prototype: Stacking Rules Admin

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

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

As a Merchant Admin, I want to define which promotions can stack and which are exclusive, so that I don't over-discount.

Acceptance criteria:

  • Given I set stacking_rule: exclusive on a coupon, When another discount is present on a renewal, Then only the higher-value one applies.
  • Given stacking_rule: stackable, When multiple discounts apply, Then they're combined per the merchant's "additive vs multiplicative" setting.

UI states.

<!-- ui-states US-25.5 -->
surface: "Admin (React/BigDesign) settings — apps/admin/src/pages/settings/store/Promotions.tsx (Promotions). Persona: Merchant Admin. Settings → Store → Promotions. TODAY a coming-soon stub: it renders an info banner + a cross-link to the promotions list and exposes NO stacking-rule configuration (file header lines 1-16). The north-star surface configures global stacking (exclusive vs stackable), the additive-vs-multiplicative combine mode, and subscription-only defaults."
idle:
  render: "H1 'Promotions', a description line (lines 33-37), a Message(type='info', header 'Coming soon') stating global promotion settings land in a follow-up (lines 39-50), and a 'Promotion management' Panel containing an 'Open promotions list' primary Button linking to /v2/promotions (lines 52-62). North-star: a settings form with the stacking-rule controls below."
  primary_action: "Today: 'Open promotions list' Button → /v2/promotions (per-promotion config). North-star: a 'Save' Button persisting the global stacking rules."
loading:
  render: "None today — the stub renders synchronously with no data fetch. North-star: while GET /api/v1/admin/settings/store resolves, the stacking-rule controls render disabled (mirroring General.tsx / FeatureFlags.tsx loading discipline)."
error:
  surfaced_at: "None today — nothing async can fail in the stub. North-star: load/save failures surface as a BigDesign Message(type='error') banner above the form (same pattern as General.tsx loadError/saveError)."
  recovery: "North-star: retry the save after correcting input, or reload on a load failure. Today the only forward path from this page is the 'Open promotions list' cross-link."
empty:
  render: "Not a list surface — a singleton store-settings page. North-star: an unset stacking rule defaults to 'exclusive' (do-not-over-discount default); today no settings value is shown at all."
edge_status:
  - status: "stacking configuration unavailable (Phase-1 stub)"
    affordance: "The 'Coming soon' Message explains per-promotion config is available today, and the 'Open promotions list' Button routes the merchant to /v2/promotions so they do not hit a dead end (lines 39-62)."
inputs:
  - field: "stacking_rule"
    control: "select"
    label: "'Promotion stacking' (north-star — not built)"
    allowed_values: "exclusive | stackable (BRD §US-25.5). North-star control; today no input renders."
  - field: "combine_mode"
    control: "select"
    label: "'When stackable, combine discounts' (north-star — not built)"
    allowed_values: "additive | multiplicative (BRD §US-25.5). North-star control; today no input renders."
  - field: "subscription_only_default"
    control: "checkbox"
    label: "'Default new promotions to subscription-only' (north-star — not built)"
    validation: "boolean; north-star control, today no input renders."
disabled_focus:
  keyboard: "Today the only focusable control is the 'Open promotions list' Button — a real BigDesign Button wrapped in a react-router Link (lines 58-60), reachable via Tab and activatable with Enter/Space. North-star: the stacking-rule Select, combine-mode Select, subscription-only Checkbox, and Save Button are all real BigDesign components in DOM/tab order."
gaps: "[partial] The story's core — configuring exclusive-vs-stackable stacking rules and the additive/multiplicative combine mode — is NOT reachable from any admin surface; this page is a coming-soon stub (lines 39-50). The inputs block above is the forward-looking north-star contract (uncited; not built). The page is not a hard dead-end (it cross-links to the per-promotion list at /v2/promotions), but no global stacking configuration exists."

US-25.6: Promotional free shipping

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

Prototype: Active Promos

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

Phase: P2 · Persona: Merchant Admin / Subscriber

As a Merchant Admin, I want to offer free shipping on subscriptions above $X or on Nth+ cycles, so that I reduce cart-abandonment.

Acceptance criteria:

  • Given a free-shipping rule with threshold or cycle-condition, When a renewal matches, Then shipping is zeroed on that BC order.

UI states.

<!-- ui-states US-25.6 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) free-shipping promotion rule create/edit form — accessed from Settings → Promotions → New free-shipping rule (or edit an existing one). Persona: Merchant Admin. Configures a threshold-based or cycle-condition-based rule that zeros shipping on matching renewal orders."
idle:
  render: "A 'Rule type' Select ('Order total threshold' | 'Cycle condition') as the first control. Conditionally: when 'Order total threshold' is selected, a 'Minimum order total' FormGroup with a BigDesign Input(type=number, prefix='$', min=0.01); when 'Cycle condition' is selected, a 'Starting from cycle' FormGroup with a BigDesign Input(type=number, min=1). A helper text beneath the conditional field: 'When a renewal order matches this rule, shipping is zeroed on the BC order.' Below that, a 'Plans this rule applies to' Select ('All plans' | 'Selected plans'). Primary Save Button and secondary Cancel Button."
  primary_action: "Save — persists the free-shipping rule to the API; shipping is zeroed on matching renewal orders from the next qualifying cycle."
loading:
  load: "While existing rule data loads on the edit path, all form controls render disabled."
  save: "While save is in-flight, the Save Button shows its BigDesign isLoading spinner; the rule-type Select, threshold/cycle Input, plan-scope Select, and Cancel Button are all disabled."
error:
  surfaced_at: "Inline beneath the offending conditional field for validation errors (role=alert). A Message(type='error') banner above the Save/Cancel buttons for API failures, carrying the error detail string."
  recovery: "Correct the offending value (e.g. enter a positive threshold amount or a cycle number >= 1) and resubmit. On API failure the form re-enables with populated values."
empty:
  render: "Not a list surface — a single-rule form. The form defaults rule_type to 'Order total threshold' with the threshold Input focused; the cycle-condition variant is one select-change away. When no free-shipping rules exist yet, that empty state is owned by the promotions list page."
inputs:
  - field: "rule_type"
    control: "select"
    label: "'Rule type'"
    allowed_values: "threshold | cycle_condition"
  - field: "threshold_amount"
    control: "number"
    label: "'Minimum order total' — BigDesign Input type=number, prefix=$, visible when rule_type=threshold"
    validation: "positive number (> 0); required when rule_type=threshold"
  - field: "min_cycles"
    control: "number"
    label: "'Starting from cycle' — BigDesign Input type=number, min=1, visible when rule_type=cycle_condition"
    validation: "integer >= 1; required when rule_type=cycle_condition"
  - field: "plan_scope"
    control: "select"
    label: "'Plans this rule applies to'"
    allowed_values: "all | selected"
edge_status:
  - status: "threshold_amount is zero or negative"
    affordance: "Inline validation error beneath the 'Minimum order total' Input; Save is blocked. Merchant enters a positive dollar value to proceed."
  - status: "no active shipping methods configured on the BC store"
    affordance: "A Message(type='warning') on the rule-save confirmation banner notes that no shipping methods are active on the store; the merchant must configure shipping via BC Admin for the free-shipping zeroing to take effect on renewal orders."
  - status: "rule_type switched mid-form (threshold ↔ cycle_condition)"
    affordance: "The previously entered value for the hidden field is cleared when rule_type changes, preventing stale values from being saved silently; the newly visible Input receives programmatic focus so the merchant can enter the correct value without hunting."
disabled_focus:
  keyboard: "The 'Rule type' Select, the conditional threshold/cycle Input (whichever is visible), the plan-scope Select, Save Button, and Cancel Button are all real BigDesign components reachable in DOM tab order. When rule_type changes, the newly visible Input receives programmatic focus. Controls are disabled (not hidden) during save."

US-25.7: Subscription-exclusive bundles

Phase: P3 · Persona: Merchant Admin

As a Merchant Admin, I want to offer "Subscribers only: Get X for free every 3rd cycle," so that I incentivize retention through perks.

Acceptance criteria:

  • Given a free-gift rule with cycle_modulo: 3, When cycle 3/6/9/… renews, Then the gift SKU is added to that cycle's BC order at $0 price.
  • Given the gift SKU is OOS, When the gift cycle runs, Then the merchant's fallback (skip gift / substitute) applies.

UI states.

<!-- ui-states US-25.7 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) subscription-exclusive bundle-perk rule create/edit form — accessed from Settings → Promotions → Bundle perks → New perk (or plan edit → Exclusive perks section). Persona: Merchant Admin. Configures cycle_modulo, gift_sku_id, and OOS fallback behavior for a free-gift SKU added to matching renewal orders."
idle:
  render: "A form with: 'Gift SKU' FormGroup containing a BigDesign Input(type=text, placeholder 'Search by SKU or product name') that resolves to a selected-product row showing product name + BC SKU; 'Apply every N cycles' FormGroup with a BigDesign Input(type=number, min=1, placeholder='e.g. 3 for cycles 3, 6, 9…') and helper text 'A value of 3 gifts on cycles 3, 6, 9, …'; 'When gift SKU is out of stock' Select ('Skip gift that cycle' | 'Substitute with another SKU'); conditionally when 'Substitute' is chosen — a 'Substitute SKU' FormGroup with a BigDesign Input(type=text, placeholder 'Search by SKU'). A helper note: 'The gift SKU is added to the matching cycle's BC order at $0.' Primary Save Button and secondary Cancel Button."
  primary_action: "Save — persists cycle_modulo, gift_sku_id, oos_fallback, and optional substitute_sku_id to the API."
loading:
  load: "While existing perk data loads on the edit path, all fields render disabled."
  save: "While save is in-flight, the Save Button shows its BigDesign isLoading spinner; all Inputs, the oos_fallback Select, and Cancel Button are disabled."
error:
  surfaced_at: "Inline beneath the 'Gift SKU' Input if the SKU search returns no catalog match (role=alert, e.g. 'SKU not found in this store's catalog'). A Message(type='error') banner above the Save/Cancel buttons for API-level save failures."
  recovery: "Correct the SKU reference (re-search or enter a valid SKU) and resubmit. On API failure the form re-enables with populated values."
empty:
  render: "Not a list surface — a single-perk form. When no bundle perks have been configured, that empty state is owned by the perks list page. This form is reached via 'New perk' and starts blank; the merchant fills in the gift SKU and cycle cadence."
inputs:
  - field: "gift_sku_id"
    control: "text"
    label: "'Gift SKU' — BigDesign Input type=text with a catalog-search resolver"
    validation: "must resolve to a valid BC product SKU in this store; required"
  - field: "cycle_modulo"
    control: "number"
    label: "'Apply every N cycles' — BigDesign Input type=number, min=1"
    validation: "integer >= 1"
  - field: "oos_fallback"
    control: "select"
    label: "'When gift SKU is out of stock'"
    allowed_values: "skip | substitute"
  - field: "substitute_sku_id"
    control: "text"
    label: "'Substitute SKU' — BigDesign Input type=text, visible when oos_fallback=substitute"
    validation: "must resolve to a valid BC product SKU; required when oos_fallback=substitute"
edge_status:
  - status: "gift SKU is currently out of stock (detected at save time or on perk detail view)"
    affordance: "A Message(type='warning') on the saved perk detail page flags the current OOS state; the merchant edits the perk to change oos_fallback from 'skip' to 'substitute' and designates an in-stock substitute SKU without recreating the perk."
  - status: "substitute SKU is also out of stock when oos_fallback=substitute"
    affordance: "Inline warning beneath the substitute SKU Input after catalog resolution; the merchant picks a different in-stock substitute. The perk saves, but qualifying cycles will fall through to skip-gift behavior until a stocked substitute is set."
  - status: "cycle_modulo = 1 (gift applies to every renewal)"
    affordance: "A Message(type='info') beneath the 'Apply every N cycles' Input warns that a value of 1 adds the gift to every renewal, which increases fulfilment cost significantly; the merchant reviews and either adjusts the cadence or saves intentionally."
disabled_focus:
  keyboard: "The gift SKU search Input, cycle_modulo Input, oos_fallback Select, and (when visible) the substitute SKU Input are real BigDesign components reachable in DOM tab order. When oos_fallback changes to 'substitute', the substitute SKU Input receives programmatic focus. Save and Cancel are real Buttons activatable with Enter or Space. Controls are disabled (not hidden) during load and save."

US-25.8: Gift-with-subscription

Phase: P3 · Persona: Subscriber / Merchant Admin

As a Merchant Admin, I want to include a one-time free gift with any new subscription to specific products, so that I drive sign-ups.

Acceptance criteria:

  • Given a "Gift with sub" rule, When a subscriber signs up for an eligible product, Then the gift SKU is added to the first BC order at $0.

UI states.

<!-- ui-states US-25.8 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) gift-with-subscription rule create/edit form — accessed from Settings → Promotions → Gift with subscription → New gift rule (or plan edit → Sign-up gift section). Persona: Merchant Admin. Configures a one-time free gift SKU added to the first BC order when a subscriber signs up for an eligible product."
idle:
  render: "A form with: 'Gift SKU' FormGroup containing a BigDesign Input(type=text, placeholder 'Search by SKU or product name') that resolves to a selected-product row showing product name + BC SKU; 'Eligible products' section with a 'Scope' Select ('All subscribable products' | 'Selected products only'); when 'Selected products only' is chosen — a product search Input and a list of selected products each with a remove Button. A helper note: 'The gift SKU is added to the subscriber's first BC order at $0.' Primary Save Button and secondary Cancel Button."
  primary_action: "Save — persists gift_sku_id and eligible product scope to the API; the gift is injected into the BC order for the first cycle only."
loading:
  load: "While existing rule data loads on the edit path, all controls render disabled."
  save: "While save is in-flight, the Save Button shows its BigDesign isLoading spinner; all Inputs, the scope Select, eligible-product list controls, and Cancel Button are disabled."
error:
  surfaced_at: "Inline beneath the 'Gift SKU' Input if the SKU does not resolve to a catalog match (role=alert). A Message(type='error') banner above the Save/Cancel buttons for API-level save failures, carrying the error detail."
  recovery: "Correct the gift SKU or eligible-product selection and resubmit. On API failure the form re-enables with populated values."
empty:
  render: "When 'Selected products only' scope is chosen but no products have been added yet, the eligible-products panel shows 'No products selected. Use the search above to add eligible products.' The product search Input is focused so the merchant can begin adding without a Tab press."
inputs:
  - field: "gift_sku_id"
    control: "text"
    label: "'Gift SKU' — BigDesign Input type=text with a catalog-search resolver"
    validation: "must resolve to a valid BC product SKU in this store; required"
  - field: "eligible_scope"
    control: "select"
    label: "'Eligible products'"
    allowed_values: "all | selected"
  - field: "eligible_product_ids"
    control: "text"
    label: "'Add products' — BigDesign Input type=text with a catalog-search resolver, multi-value; visible when eligible_scope=selected"
    validation: "at least one product required when eligible_scope=selected; each must be a valid BC product in this store"
edge_status:
  - status: "eligible_scope=selected but no products added"
    affordance: "Save is blocked; an inline message beneath the products panel states 'Add at least one eligible product or switch to All subscribable products.' The merchant adds a product or changes the scope Select to unblock."
  - status: "an active gift rule already exists for overlapping eligible products"
    affordance: "A Message(type='warning') on the create form when overlap is detected warns the merchant that a qualifying sign-up could receive two gifts; the merchant can edit the existing rule (link provided) or adjust the eligible_scope to avoid overlap before saving."
  - status: "gift SKU is currently out of stock"
    affordance: "A Message(type='warning') on the saved rule detail page flags the current OOS state; a link to edit the rule lets the merchant update the gift SKU to an in-stock alternative without recreating the rule."
disabled_focus:
  keyboard: "The gift SKU Input, eligible_scope Select, eligible_product_ids search Input (when visible), per-product remove Buttons (aria-label='Remove [product name]'), Save Button, and Cancel Button are all real BigDesign components reachable in DOM tab order. When eligible_scope changes from 'All' to 'Selected', the product search Input receives programmatic focus. Controls are disabled (not hidden) during load and save."

US-25.9: Promotion reporting

Phase: P2 · Persona: Merchant Admin

As a Merchant Admin, I want to see promotion performance (redemptions, revenue, lift vs. non-promo), so that I know which offers work.

Acceptance criteria:

  • Given promotions have been active, When I open promotion reporting, Then per-promo: redemptions, total discount given, attributable MRR, estimated lift, retention lift.

UI states.

<!-- ui-states US-25.9 -->
surface: "Admin (React/BigDesign) promotion report — apps/admin/src/pages/promotions/PromotionReport.tsx (PromotionReport), routed via apps/admin/src/routes/v2/promotions/index.tsx. Persona: Merchant Admin. Reads GET /api/v1/admin/subscription-promotions/:id/report?from&to (getPromotionReport) for the :id route param; date range defaults to the last 90 days (nDaysAgoIso(90) → todayIso, lines 109-111)."
idle:
  render: "A back-link to /promotions (line 161), title + promo id, a 'Date range' SectionCard with From/To date Inputs (lines 180-204), then once data loads three SectionCards: KPI grid (redemptions, total discount, active subs with promo, attributed MRR — KpiGrid, lines 226-271), Source breakdown (subs vs bc — lines 274-305), and Retention lift (with-promo vs without-promo avg cycles + lift_ratio — lines 308-370)."
  primary_action: "Editing either date Input re-runs getPromotionReport via the useEffect keyed on apiFrom/apiTo (lines 120-145); there is no explicit 'Apply' button."
loading:
  render: "On the first fetch (loading && !data) a plain 'merchantAdmin.promotions.report.loading' Text renders (lines 215-221). On a re-fetch triggered by a date change, loading is true but stale data persists, so the guard suppresses the indicator and the previous numbers stay on screen with no spinner (see gaps)."
error:
  surfaced_at: "A BigDesign Message(type='error') banner with header 'merchantAdmin.promotions.report.error.header' renders below the date picker carrying the fetch error text (error state, lines 206-213). A missing :id route param short-circuits to a bare Message(type='error', 'merchantAdmin.promotions.report.missingId') with no surrounding chrome (lines 147-156)."
  recovery: "No dedicated retry button — the merchant adjusts the From/To range to re-trigger the effect, or reloads the page. The missing-id branch offers no in-page navigation back (see gaps / edge_status)."
empty:
  render: "When the report loads with no activity, the KPI cards render zeros (formatNumber(0) / formatCents(0)) and the Retention card falls back to its insufficient-data state; there is NO dedicated 'this promotion has no redemptions yet' empty card."
edge_status:
  - status: "retention sample below threshold (retention_data.insufficient_data)"
    affordance: "The Retention card swaps to a Message(type='info') stating the observed sample vs the 30-sample threshold (lines 312-328); the merchant widens the date range to gather enough samples."
  - status: "missing :id route param"
    affordance: "Today renders a bare error Message with no way back (lines 147-156). North-star affordance: a back-link to the promotions list so the merchant can re-select a promotion (see gaps)."
inputs:
  - field: "from"
    control: "date"
    label: "'merchantAdmin.promotions.report.range.from' — BigDesign Input type=date in a FormGroup (lines 185-193); default 90 days ago."
  - field: "to"
    control: "date"
    label: "'merchantAdmin.promotions.report.range.to' — BigDesign Input type=date in a FormGroup (lines 194-202); default today."
disabled_focus:
  keyboard: "The two date Inputs are real BigDesign Input components inside FormGroups with labels and data-testids (promo-report-from / promo-report-to, lines 185-202) — reachable via Tab in source order (from → to), editable from the keyboard. The KPI / breakdown / retention cards are static display nodes (styled divs), correctly non-focusable. The back-link is a real RouterLink (line 161)."
gaps: "[built, with defects] (1) BRD §US-25.9 AC lists 'estimated lift' as a distinct KPI; the component renders retention_data.lift_ratio as a ratio card (formatRatio, line 363) but no separate 'estimated lift vs non-promo' KPI card in the KpiGrid (lines 226-271). (2) The back-link targets /promotions, not /v2/promotions (line 161) — under the v2 shell this hops to the legacy shell; mitigated by the FlagGate bounce, documented in the route file (index.tsx lines 9-15). (3) The missing-:id branch (lines 147-156) is a dead-end — bare error Message with no navigation. (4) A date-driven re-fetch shows stale numbers with no loading indicator (loading && !data guard, line 215), and there is no retry control on fetch error."

US-25.10: Time-tiered discount ladder

Phase: P2 · Priority: P2 · Effort: M · Persona: Merchant Admin / Subscriber

As a Merchant Admin, I want to configure a stepped discount schedule per plan (e.g. 20% off cycles 1–2, 15% off cycles 3–6, 10% thereafter), so that I reward subscriber loyalty automatically without building separate promotions for each tier.

Acceptance criteria:

  • AC-1: Given I configure cycle_discounts on a plan as [{from:1,to:2,pct:20},{from:3,to:6,pct:15},{from:7,pct:10}], When cycle 1 renews, Then resolveCycleDiscount returns 20%.
  • AC-2: Given the same plan, When cycle 3 renews, Then resolveCycleDiscount returns 15%; cycle 7+ returns 10%.
  • AC-3: Given a plan with cycle_discounts set, When a subscription is created, Then snapshotPlanCycleDiscount writes no subscription_discounts row for this plan (disjoint-path guard, ADR-0080).
  • AC-4: Given a plan with only cycle_discount_pct (no cycle_discounts), When a subscription renews, Then the existing snapshot-at-creation path applies unchanged (backward-compat).
  • AC-5: Given a merchant edits the ladder tiers, When the next renewal fires, Then the updated tiers apply to all active subscriptions on that plan (read-at-charge semantics, ADR-0080).

Data contract.

  • Column: plans.cycle_discounts TEXT — JSON array of {from: number, to?: number, pct: number} (migration 0043). null = use legacy path.
  • Pure helper: resolveCycleDiscount(tiers, cycleCount, fallbackPct) — first-match wins; open-ended last tier (to absent) matches any cycle ≥ from.
  • Charge handler: reads plan.cycle_discounts at renewal time; resolves and applies ladderPct additively with snapshot-path effectivePercent (disjoint by construction — ladder plans never write snapshot rows).

Admin UX.

  • Plan edit form: row-based sequence table (from / to / pct) replacing single cycle_discount_pct field.
  • Propagation warning: "Changes apply to future renewals of all active subscriptions on this plan."

Storefront UX.

  • When >1 tier: "20% off today · 15% off cycles 3–6 · 10% after that" summary in the intro-offer banner.
  • When 1 tier: renders as the existing single-offer "X% off your first order" headline.

Dependencies.

  • ADR-0080 (this feature's architecture decision).
  • US-25.3 (existing snapshot path must remain unaffected).

UI states.

<!-- ui-states US-25.10 -->
surface: "Admin PlanEdit cycle-discount tier editor (apps/admin/src/pages/plans/PlanEdit.tsx renders apps/admin/src/components/forms/DiscountLadderForm.tsx) and the storefront widget tiered intro-offer summary (apps/storefront-svelte/src/lib/subscriptions/SubscriptionWidget.svelte introOffer). Persona: Merchant Admin / Subscriber. ui_kind=form."
idle:
  render: "DiscountLadderForm is a bordered Box with an Enable/Disable Button (toggleEnabled, DiscountLadderForm.tsx:94-115). When enabled it shows the propagation Message, then one row per tier — each row a 'From cycle' Input(number,min=1), a 'To cycle (blank = open-ended)' Input(number), a 'Discount %' Input(number,min=1,max=100), and a '✕' remove Button (DiscountLadderForm.tsx:141-200) — plus a '+ Add tier' Button (:202-207). On the storefront, when plan.cycle_discounts has >1 tier the widget renders the stepped '20% off today · 15% off cycles 3-6 · 10% after that' banner (introOffer derived SubscriptionWidget.svelte:210-270, tiered branch :222-236; rendered :574-585); a single tier falls back to the 'X% off your first order' headline (:238-249)."
  primary_action: "PlanEdit Save serializes the ladder via discountLadderToJson and PATCHes plan.cycle_discounts only when changed (buildPatch, PlanEdit.tsx:132-137); edits apply to future renewals of all active subs (read-at-charge, ADR-0080)."
loading:
  load: "PlanEdit loads the plan by id on mount (getPlan, PlanEdit.tsx:155-171); while plan/form null it shows the loading text (PlanEdit.tsx:195-203)."
  save: "On Save, saving=true → the Save Button shows its isLoading spinner and Cancel is disabled (PlanEdit.tsx:338-348)."
error:
  surfaced_at: "Load failure → Message(type='error') + Back button (PlanEdit.tsx:178-193); save failure → Message(type='error') above the buttons carrying saveError (PlanEdit.tsx:326-334). DiscountLadderForm has no internal error state — persistence errors surface via PlanEdit's saveError."
  recovery: "Load error: Back to /plans then reload (PlanEdit.tsx:187-189). Save error: setSaving(false) re-enables the form so the merchant fixes the tiers and resaves (PlanEdit.tsx:219)."
empty:
  render: "Two empties. (1) Ladder enabled with zero tiers → 'No tiers yet. Add one below.' text + the '+ Add tier' button (DiscountLadderForm.tsx:132-139,202-207). (2) Ladder disabled / cycle_discounts null → the editor collapses to just the Enable button and the storefront shows no tiered banner (introOffer returns null with no cycle_discounts, SubscriptionWidget.svelte:251-254)."
inputs:
  - field: "tier_from"
    control: "number"
    label: "'From cycle' — BigDesign Input type=number (DiscountLadderForm.tsx:144-155)"
    validation: "integer >= 1"
  - field: "tier_to"
    control: "number"
    label: "'To cycle (blank = open-ended)' — BigDesign Input type=number (DiscountLadderForm.tsx:158-170)"
    validation: "blank = open-ended (null); otherwise integer >= from"
  - field: "tier_pct"
    control: "number"
    label: "'Discount %' — BigDesign Input type=number (DiscountLadderForm.tsx:173-185)"
    validation: "integer 1-100"
edge_status:
  - status: "ladder enabled, multi-tier"
    surfaced_at: "storefront introOffer tiered branch (SubscriptionWidget.svelte:222-236)"
    affordance: "storefront renders the stepped summary banner; merchant edits / adds / removes rows in the editor."
  - status: "ladder enabled, single tier"
    surfaced_at: "storefront introOffer single-tier branch (SubscriptionWidget.svelte:238-249)"
    affordance: "storefront renders the single 'X% off your first order' intro headline."
  - status: "ladder disabled / null"
    surfaced_at: "DiscountLadderForm Enable button (DiscountLadderForm.tsx:111-115); legacy path (SubscriptionWidget.svelte:252-269)"
    affordance: "legacy cycle_discount_pct snapshot path (or no discount) applies; merchant re-enables via the Enable button."
  - status: "edit propagates to active subscribers"
    surfaced_at: "propagation Message above the tier rows (DiscountLadderForm.tsx:120-130)"
    affordance: "the banner warns the merchant the change hits future renewals of ALL active subs BEFORE Save — but it renders as a low-emphasis blue type='info' banner, not type='warning' (see gaps)."
disabled_focus:
  keyboard: "Every control is a native-focusable BigDesign primitive — the Enable/Disable Button, each tier's three number Inputs, the per-row '✕' remove Button (aria-label removeTier, DiscountLadderForm.tsx:187-198), and '+ Add tier' — reachable in DOM order via Tab. The PlanEdit Save Button is disabled (removed from actuation) until the form is dirty (dirty useMemo, PlanEdit.tsx:173-176)."
  gaps: "No aria-live on the saveError Message reveal (PlanEdit.tsx:326-334) and the propagation Message is informational (not role='alert'); a keyboard/SR merchant gets no announcement when a ladder save fails. No client-side validation that tier ranges are contiguous/non-overlapping — malformed tiers persist and resolveCycleDiscount applies first-match-wins server-side."
gaps: "The propagation warning renders type='info' (blue) rather than type='warning' (yellow) (DiscountLadderForm.tsx:120-130), under-signaling the AC-5 blast radius — an edit changes the future renewals of every active subscriber on the plan. A merchant could miss the warning."

US-25.11: Cycle-discount ladder — admin propagation controls

Phase: P3 · Priority: P2 · Effort: L · Persona: Merchant Admin

As a Merchant Admin, I want to optionally lock the ladder for in-flight subscriptions (so an edit to the plan's tiers does NOT change active subs), so that I can honor existing subscriber terms when restructuring a plan.

Acceptance criteria:

  • AC-1: Given a plan-level ladder_lock flag, When set to true, When a merchant edits the ladder, Then existing subscriptions are unaffected (snapshot the ladder at creation like ADR-0052).
  • AC-2: Given ladder_lock = false (default), Then edits propagate per ADR-0080 (current behavior).

Note: Phase-3 scope. Requires schema addition + snapshotPlanCycleDiscount extension. Deferred until usage data shows need.


UI states.

<!-- ui-states US-25.11 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan edit form — 'Ladder lock' control within the cycle-discount tier editor section (described in US-25.10's BRD story), inside the plan edit page under Plans → edit plan → Cycle discounts. Persona: Merchant Admin. Sets a plan-level ladder_lock Boolean that controls whether ladder edits propagate to in-flight subscriptions (false, ADR-0080 behavior) or are snapshotted at creation and locked for existing subscribers (true, ADR-0052 behavior)."
idle:
  render: "Within the cycle-discount tier editor (visible only when the ladder is enabled), a 'Lock discount for active subscribers' BigDesign Checkbox with helper text: 'When checked, changes to these tiers apply only to new sign-ups — existing subscribers keep the ladder they signed up with. When unchecked (default), tier edits apply to all active subscribers at the next renewal.' A Message(type='warning') appears adjacent to the Save Button when the ladder is enabled, ladder_lock is unchecked, and the tiers have been dirtied — warning 'This change will apply to future renewals of all active subscribers on this plan.'"
  primary_action: "Save plan — persists ladder_lock alongside cycle_discounts via PATCH to the plan API."
loading:
  load: "While the plan loads, the ladder-lock Checkbox renders disabled alongside the rest of the tier editor."
  save: "While save is in-flight, the Save Button shows its BigDesign isLoading spinner; the ladder-lock Checkbox and all tier Inputs are disabled."
error:
  surfaced_at: "Save failures surface as a Message(type='error') banner above the Save/Cancel buttons (the same save-error pattern as the surrounding plan edit form), carrying the API error detail. The Checkbox itself has no client-side validation path — it is a binary Boolean."
  recovery: "The form re-enables after a save failure with ladder_lock and tier values preserved; the merchant retries the save without re-entering data."
empty:
  render: "Not a list surface — a single-plan form. The ladder-lock Checkbox is not rendered when the cycle-discount ladder is disabled; it appears only once the ladder is toggled on. When first enabled, ladder_lock defaults to false (unchecked) — the propagation warning (type='warning') Message immediately becomes visible to surface the blast radius before any save occurs."
inputs:
  - field: "ladder_lock"
    control: "checkbox"
    label: "'Lock discount for active subscribers' — BigDesign Checkbox, default unchecked (ladder_lock=false)"
    validation: "boolean; false = edits propagate to all active subscribers on next renewal (ADR-0080); true = new sign-ups only get updated tiers, existing subscribers are snapshotted at creation (ADR-0052 behavior)"
edge_status:
  - status: "ladder_lock=false (default) with dirty tier edits — edits will propagate to all active subscribers"
    affordance: "A Message(type='warning') surfaces above the Save Button warning the merchant of the propagation blast radius; the merchant can check ladder_lock to protect in-flight subscriptions before saving, or save with propagation intentionally."
  - status: "ladder_lock=true — existing subscribers keep their creation-time snapshot"
    affordance: "The propagation warning is suppressed; a Message(type='info') note reads 'Active subscribers are locked to their sign-up discount tiers. Only new subscriptions will see the updated ladder.' The merchant unchecks ladder_lock at any time to revert to propagation behavior for future saves."
  - status: "ladder is disabled — ladder_lock control is not shown"
    affordance: "The Checkbox does not render when cycle_discounts is null; the merchant enables the ladder via the Enable button in the tier editor to access the lock control."
disabled_focus:
  keyboard: "The 'Lock discount for active subscribers' Checkbox is a real BigDesign Checkbox with a label prop — reachable via Tab within the tier editor in DOM source order, togglable with Space. It sits between the propagation Message and the tier rows in source order. The control is disabled (removed from tab order) during plan load and during save."