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 5 — Plan design (derived view)
Read-only per-epic slice of
BRD.md§9, lines 1950–2489. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-5.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (9): US-5.1, US-5.2, US-5.3, US-5.4, US-5.5, US-5.6, US-5.7, US-5.8, US-5.9
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 5 — Plan design
<!-- traceability:start:BRD:Epic-5 --><!-- traceability:end:BRD:Epic-5 -->Prototype: Pricing Strategy · Pricing — BC Price List · Pricing — Fixed Price · Intervals · Trial & Commitment · Plan Comparison
Value: Merchants can model any common subscription pattern without custom engineering.
US-5.1: Multiple offered intervals
<!-- traceability:start:US-5.1 --><!-- traceability:end:US-5.1 -->Prototype: Intervals · Plan Comparison
Phase: MVP · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want to offer subscribers a choice of intervals (e.g., monthly, every 2 months, every 3 months), so that different buying patterns convert.
Acceptance criteria:
- Given plan design, When I add intervals, Then I can specify any combination from: every N days/weeks/months (N ≤ 24).
- Given multiple intervals are configured, When a subscriber reaches PDP, Then a dropdown/radio shows all options with per-interval savings indication.
UI states.
<!-- ui-states US-5.1 -->surface: "Admin (React/BigDesign) plan-wizard intervals step — apps/admin/src/components/forms/IntervalsForm.tsx (plan-name Input + a fixed cadence checkbox list driven by INTERVAL_OPTIONS, intervalOptions.ts) rendered inside apps/admin/src/pages/products/PlanWizard.tsx (step 'intervals'); AND the paired subscriber/PDP surface apps/storefront-svelte/src/lib/subscriptions/SubscriptionWidget.svelte where the configured cadences re-render as a 'Delivery cadence' radio group among multiple active plans with a 'member' savings badge (savingsPct). Persona: Merchant Admin (configure) / Subscriber (choose)."
idle:
render: "Admin: a Panel with a 'Plan name' Input and a column of BigDesign Checkboxes, one per INTERVAL_OPTIONS entry, that the merchant ticks to offer each cadence (IntervalsForm.tsx map over INTERVAL_OPTIONS). Subscriber/PDP: when more than one active plan exists the widget renders a 'Delivery cadence' segmented radio of 'every {count} {unit}' buttons defaulting to the shortest interval (selectedPlanId = active[0]), with the savings badge and a 'Next order {date}, then {cadence}' preview."
primary_action: "Admin: tick one or more cadence checkboxes + name the plan, then 'Continue' to the pricing step. Subscriber: tap a cadence button to switch selectedPlanId, then 'Start Auto-Refill' (data-demo widget-subscribe-btn)."
loading:
admin: "IntervalsForm is a controlled/synchronous component issuing no fetch, so the intervals step shows no spinner. The only async on this path is the plan-create POST at the wizard review step (submit/activate) where the primary Button shows isLoading and is disabled."
subscriber: "On mount the widget fetches active plans (apiClient.getPlans) and renders a pulse skeleton card (aria-busy) until plans resolve."
error:
surfaced_at: "Admin: a wizard-level BigDesign Message(type='error') above the Back/Continue buttons (PlanWizard.tsx fed by submitError), inline on the page never a toast. Subscriber: an inline role=alert notice inside the one-time fallback (data-testid widget-load-error) plus a role=alert at the foot of the plan card."
recovery: "Admin: the form keeps its state (setSubmitting(false)) so the merchant fixes the field and re-submits. Subscriber: a plans-load failure degrades to a one-time-only purchase path (plans stays []) and the alert tells the shopper to refresh to retry."
empty:
render: "Admin: not a collection, but with zero cadence boxes ticked the 'Continue' button is disabled (canAdvance requires intervals.length > 0 and a trimmed name), so an empty interval set cannot advance. Subscriber: when the product has no active plans the widget renders a one-time-only fieldset with an 'Add to cart' button rather than a blank cadence picker."
cta: "Admin: tick at least one cadence to enable Continue. Subscriber: 'Add to cart' (one-time) — the no-plans path still lets the shopper buy."
inputs:
- field: "plan_name"
control: "text"
label: "'Plan name' — BigDesign Input (IntervalsForm.tsx)"
validation: "non-empty (trimmed) required to advance — canAdvance gate in PlanWizard.tsx."
- field: "offered_intervals"
control: "checkbox"
label: "cadence checkbox list — one Checkbox per option (IntervalsForm.tsx map over INTERVAL_OPTIONS)"
allowed_values: "the fixed INTERVAL_OPTIONS (intervalOptions.ts): weekly, every 2 weeks, every 3 weeks, monthly, every 2 months, every 3 months, every 6 months. First-ticked is canonical (PlanWizard.tsx primaryInterval)."
edge_status:
- status: "no cadence ticked yet (admin)"
affordance: "Continue stays disabled (canAdvance) and the merchant must tick at least one cadence — no dead-end, the gate is visible on the disabled button."
- status: "single active plan (subscriber) — cadence radio suppressed"
affordance: "the cadence radio only renders when sortedPlans.length > 1; with one plan the shopper subscribes directly via the subscribe radio + CTA, no orphaned empty picker."
- status: "savings indeterminate — subscribe price >= one-time price (subscriber)"
affordance: "savingsPct returns null so the savings badge is hidden rather than showing a misleading 0% or negative; the cadence and price still render."
disabled_focus:
keyboard: "Admin: the plan-name Input and each cadence Checkbox are real BigDesign controls wrapping native focusable elements (no div-onClick); Tab reaches name then each checkbox then Continue, Space toggles a checkbox, and the disabled Continue button leaves tab order until canAdvance is true. Subscriber: each cadence option is a real <button> reachable by Tab and activatable by Enter/Space, and the subscribe CTA is a real <button>."
focus_move: "No programmatic focus move on cadence toggle (admin or subscriber) — selection is in-place; the subscriber price/preview updates reactively via $derived."
gaps: "AC1 says the merchant can specify 'any combination from: every N days/weeks/months (N <= 24)', but the admin form offers only a FIXED checkbox list capped at 'every 6 months' (intervalOptions.ts) — no day-based cadence, no arbitrary N, no yearly. The expressible interval space is narrower than the spec. Subscriber-side savings indication (AC2) is built (savingsPct badge); an admin-side per-interval savings preview is not shown on the intervals step."
US-5.2: Pricing strategy — fixed discount %
<!-- traceability:start:US-5.2 --><!-- traceability:end:US-5.2 -->Prototype: Pricing Strategy
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to offer "Subscribe and save 10%" off the current product price, so that pricing follows catalog changes automatically.
Acceptance criteria:
- Given I select "Fixed discount %" and enter 10, When a subscriber renews, Then the renewal charge is computed as
current_catalog_price * 0.9per unit. - Given the catalog price changes, When the next renewal runs, Then the renewal uses the new base price (unless "lock at subscription creation" is enabled per US-5.6).
UI states.
<!-- ui-states US-5.2 -->surface: "Admin (React/BigDesign) plan-wizard pricing step — apps/admin/src/components/forms/PricingForm.tsx, 'Fixed discount %' strategy, rendered inside apps/admin/src/pages/products/PlanWizard.tsx (step 'pricing'). Persona: Merchant Admin. A 3-way strategy Radio group (discount / price-list / fixed) where selecting discount_percent reveals a BigDesign Counter (1-50) and an info summary banner contrasting storefront vs subscription price."
idle:
render: "A Panel with three Radios — 'Fixed discount %' (discountStrategy), 'BC Price List' (priceListStrategy), 'Fixed price' (fixedStrategy). With discount_percent selected (the wizard default) a BigDesign Counter renders with min=1 max=50 step=1 bound to discountPercent, and below it an info Message banner showing the formatted storefront price vs the computedAmountCents subscription price."
primary_action: "Set the discount via the Counter (+/- or type), then 'Continue' to the options step — enabled only while 0 < discountPercent <= 50 (canAdvance in PlanWizard.tsx)."
loading:
render: "PricingForm issues no fetch — the pricing step is synchronous and shows no spinner; the summary banner recomputes instantly from local state. The only async is the plan-create POST at the review step (submit/activate) whose primary Button shows isLoading and is disabled."
error:
surfaced_at: "No inline field error on the discount Counter itself — the Counter clamps to [1,50] so an out-of-range value can't be entered. A failed plan-create surfaces in the wizard-level Message(type='error') above the nav buttons (PlanWizard.tsx, submitError)."
recovery: "The Counter's clamp prevents invalid submission up front; on a create failure the form state is preserved (setSubmitting(false)) so the merchant adjusts and re-submits."
empty:
render: "Not a collection surface — the pricing step always renders the 3 strategy radios. The summary banner always shows a value: formatCents tolerates a 0 base price and renders '$0.00' rather than blanking."
cta: "n/a (single-strategy form, no list)"
inputs:
- field: "pricing_strategy"
control: "select"
label: "3-way Radio group (PricingForm.tsx)"
allowed_values: "discount_percent | price_list | fixed_price"
- field: "discount_percent"
control: "number"
label: "'Fixed discount %' Counter (PricingForm.tsx)"
validation: "integer 1-50, clamped by the Counter min/max; Continue gated on 0 < value <= 50 (PlanWizard.tsx discountPercent gate)."
edge_status:
- status: "merchant wants a discount greater than 50%"
affordance: "the Counter hard-caps at max=50 and Continue blocks values over 50 (PlanWizard.tsx) — there is no path to exceed 50%; the cap is enforced but undocumented (see gaps)."
- status: "discount driven to 0 / below minimum"
affordance: "the Counter floors at min=1, so a 0% discount cannot be set and the plan can never be saved as a no-op discount."
disabled_focus:
keyboard: "All controls are real BigDesign components: the strategy Radios and the Counter wrap native focusable elements (no div-onClick). Tab reaches each Radio then the Counter's decrement / input / increment; arrow keys or typing adjust the Counter within its clamp; the disabled 'Continue' button stays out of tab order until canAdvance is true."
focus_move: "No programmatic focus move when toggling strategy — the discount Counter mounts in place below the radios and the summary banner updates reactively."
gaps: "The 50% maximum (PricingForm.tsx max={50}) and the 1% minimum are silent business rules — BRD US-5.2 states no bound, so a merchant who wants more than 50% off hits an unexplained ceiling with no inline message naming the rule. North-star: surface the cap as helper text on the Counter, or lift/justify the bound in spec."
US-5.3: Pricing strategy — BC Price List
<!-- traceability:start:US-5.3 --><!-- traceability:end:US-5.3 -->Prototype: Pricing — BC Price List
Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin
As a Merchant Admin, I want to point subscriptions at an existing BC Price List, so that subscription pricing reuses my B2B / customer-group pricing structures.
Acceptance criteria:
- Given I select "Price List" and pick one, When subscribers renew, Then charge amounts are read from the Price List's price for their variant.
- Given the Price List is deleted or archived in BC, When the renewal runs, Then the charge goes into the exception queue for merchant attention.
UX notes.
- Surface: plan wizard step 2, when "Price List" is selected
- Picker: searchable list of merchant's BC Price Lists; inline preview of prices for the selected product under each list
- Warning banner: if the picked Price List is inactive or assigned to customer groups that are scoped out of the plan — flag the mismatch
Data contract.
- BC API:
GET /v3/pricelists/{id}+GET /v3/pricelists/{id}/assignments(assignments are a separate resource, not an include-expansion on the list endpoint); per-product resolution at renewal time viaGET /v3/pricelists/{id}/records?product_id:in=X(the records filter is the:inlist form, not a bare equality) - Our DB:
plans.pricing_strategy = 'bc_price_list',plans.bc_price_list_id = {id} - Events:
plan.price_strategy_set
Success metrics.
- Functional: renewal charges always match the current Price List price for the subscriber's resolved Price List
- Product (target): % of Price List–strategy plans ≥ 30% (indicates the integration is used)
- Operational (target): Price List lookup adds < 200ms to charge computation P95
Dependencies.
- US-15.2 (renewal recalc must read Price List)
- Epic 7 scoping
Non-functional.
- If multiple Price Lists are assigned to a customer (customer group overlap), respect BC's native resolution order — do not override
- Cache Price List records per store for 5 minutes to reduce BC API load
UI states.
<!-- ui-states US-5.3 -->surface: "Admin (React/BigDesign) plan-wizard pricing step — apps/admin/src/components/forms/PricingForm.tsx, 'BC Price List' strategy (priceListStrategy radio + priceListId Input), rendered inside apps/admin/src/pages/products/PlanWizard.tsx (step 'pricing'). Persona: Merchant Admin. North-star: a searchable picker of the merchant's BC Price Lists with an inline per-product price preview and an inactive/scoped-out warning banner. Built today: only the strategy radio plus a raw free-text price-list-ID Input — the picker, preview, and warning banner are not wired (see gaps)."
idle:
render: "Built: selecting the 'BC Price List' Radio reveals a single BigDesign Input for the price-list ID, free-text, bound to priceListId (PricingForm.tsx). North-star: this Input is replaced by a searchable Select/combobox listing the merchant's price lists (id + name) sourced from the existing admin proxy GET /api/v1/admin/pricelists (handleAdminPriceListsList), with an inline price-preview row for the current product and a warning banner when the picked list is inactive or scoped out of the plan's customer groups (BRD US-5.3 UX notes)."
primary_action: "Pick a price list (north-star: from the searchable picker; built: type its numeric ID), then 'Continue' — enabled only while priceListId is non-empty (canAdvance in PlanWizard.tsx)."
loading:
render: "North-star: the picker shows a loading state while GET /api/v1/admin/pricelists resolves, and the per-product preview shows a spinner while resolving the list's record for this product. Built: NONE — the free-text Input does no fetch and shows no loading; the only async is the plan-create POST at the review step (submit/activate, isLoading button)."
error:
surfaced_at: "North-star: an inline validation/warning beneath the picker — 'that price list no longer exists' for an unknown ID, and a distinct warning banner for an inactive or customer-group-scoped-out list. Built: there is NO input-time validation; the only error surface is the wizard-level Message(type='error') above the nav buttons (PlanWizard.tsx submitError), which fires after the create POST, not against the typed ID."
recovery: "North-star: the merchant re-picks a valid/active list from the picker and the warning clears. Built: a bad ID is accepted and persisted (a merchant can save a nonexistent price-list ID); the mismatch only surfaces later at renewal, where the charge is routed to the exception queue for merchant attention (AC2, server-side resolveVariantPriceFromList in price-list-resolver.ts)."
empty:
render: "North-star: when the store has zero BC Price Lists the picker shows an empty state ('No price lists found') with a CTA to create one in BigCommerce. Built: the free-text Input never renders an empty state — it stays an empty text box, giving no signal that the store has no price lists."
cta: "North-star: 'Create a price list in BigCommerce' deep-link; built: none."
inputs:
- field: "bc_price_list_id"
control: "select"
label: "north-star searchable BC Price List picker (BRD US-5.3 UX notes); built today as a raw BigDesign Input (PricingForm.tsx priceListId)"
allowed_values: "the merchant's existing BC Price Lists (id + name) from GET /api/v1/admin/pricelists (handleAdminPriceListsList) — NOT free text. The current free-text Input is the missing-control defect (see gaps)."
edge_status:
- status: "picked price list is inactive or scoped out of the plan's customer groups"
affordance: "north-star: an inline warning banner flags the mismatch and the merchant picks a different list or adjusts scope (BRD US-5.3 UX notes); NOT built today."
- status: "price list deleted/archived in BC after the plan is live (AC2)"
affordance: "at renewal the charge is routed to the exception queue for merchant attention (resolveVariantPriceFromList, price-list-resolver.ts) — not a silent drop; the merchant resolves it from the exceptions surface."
- status: "nonexistent price-list ID typed (built free-text path)"
affordance: "north-star: input-time validation against GET /api/v1/admin/pricelists blocks save; built today it saves silently (defect) and only surfaces at renewal via the exception queue."
disabled_focus:
keyboard: "Built: the price-list-ID Input is a real BigDesign Input reachable by Tab; 'Continue' is disabled until priceListId is non-empty (PlanWizard.tsx) and stays out of tab order until then. North-star: the searchable picker must be a keyboard-navigable combobox — Tab to focus, type-ahead to filter, ArrowUp/Down to move through options, Enter to select, Esc to close — never a mouse-only dropdown."
focus_move: "North-star: opening the picker moves focus into the option list; selecting a list returns focus to the picker trigger and reveals the price preview. Built: no focus management (plain Input)."
gaps: "PARTIAL. Backend is ready — GET /api/v1/admin/pricelists (handleAdminPriceListsList, apps/api/src/routes/admin/pricelists.ts) proxies BC GET /v3/pricelists?include=assignments — but the admin frontend never calls it: PricingForm.tsx still renders a raw free-text Input. Missing vs BRD US-5.3 UX notes: (1) searchable price-list picker, (2) inline per-product price preview, (3) inactive/scoped-out warning banner, (4) any input-time validation (a nonexistent ID saves silently and only fails at renewal)."
US-5.4: Pricing strategy — fixed price
<!-- traceability:start:US-5.4 --><!-- traceability:end:US-5.4 -->Prototype: Pricing — Fixed Price
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to set an absolute price for a subscription cycle (e.g., $29/month), so that the subscription price is fully decoupled from catalog fluctuations.
Acceptance criteria:
- Given I select "Fixed price" and enter amount + currency, When renewals run, Then the charge amount ignores catalog changes.
UI states.
<!-- ui-states US-5.4 -->surface: "Admin (React/BigDesign) plan-wizard + plan-edit pricing — 'Fixed price' strategy in apps/admin/src/components/forms/PricingForm.tsx (fixedStrategy radio + amount Input type=number). Reached two ways: the create wizard apps/admin/src/pages/products/PlanWizard.tsx (step 'pricing'), which also renders a currency Select when the store has more than one enabled currency (CurrencySelector), and the edit page apps/admin/src/pages/plans/PlanEdit.tsx (PricingForm). Persona: Merchant Admin. Sets an absolute per-cycle price decoupled from catalog price."
idle:
render: "Selecting the 'Fixed price' Radio reveals a BigDesign Input type=number bound to fixedPriceDollars (PricingForm.tsx). In the create wizard, when getCurrencies returns more than one enabled currency a 'Plan currency' Select also renders (PlanWizard.tsx CurrencySelector), defaulted to the store's primary (is_default) currency; single-currency stores see no selector by design and inherit the store default. On the edit page the amount Input pre-fills from amount_cents/100 (deriveFormState in PlanEdit.tsx)."
primary_action: "Enter the dollar amount (and pick currency where shown), then 'Continue' (wizard, enabled while fixedPriceDollars > 0 via canAdvance) or 'Save' (edit page, enabled only when dirty)."
loading:
render: "PricingForm itself issues no fetch. Wizard: the currency Select waits on getCurrencies — until it resolves the selector simply does not render (no spinner). The plan-create POST shows isLoading on the primary Button (PlanWizard.tsx); the edit-page Save shows isLoading on its primary Button. The edit page also gates the whole form behind a load — until getPlan resolves it renders a plain 'Loading' text."
error:
surfaced_at: "Wizard: a Message(type='error') above the nav buttons (PlanWizard.tsx submitError). Edit page: a load failure renders a Message(type='error') with a Back button (loadError) and a save failure renders a Message(type='error') above the action row (saveError)."
recovery: "Wizard: form state is preserved (setSubmitting(false)) so the merchant fixes the amount and re-submits. Edit page: on save failure setSaving(false) keeps the edited values for retry; on load failure the Back button returns to /plans."
empty:
render: "Not a collection surface — the fixed-price step always renders the amount Input. The currency Select is conditionally absent (single-currency stores) which is the intended empty-of-choices state, not a blank control. The edit page renders no currency control at all (gap, see gaps)."
cta: "n/a (single-amount form, no list)"
inputs:
- field: "fixed_price_amount"
control: "number"
label: "'Fixed price' Input type=number (PricingForm.tsx)"
validation: "wizard Continue gated on fixedPriceDollars > 0 (canAdvance); edit-page patch only applies when cents > 0 (PlanEdit.tsx). The Input has no min/step attribute, so a negative/zero can be typed but is blocked from advancing/saving (see gaps)."
- field: "currency"
control: "select"
label: "'Plan currency' BigDesign Select — wizard only, multi-currency stores (PlanWizard.tsx CurrencySelector)"
allowed_values: "the store's BC-enabled currency codes from getCurrencies (BC /v2/currencies); default is the is_default primary currency. Not free text."
edge_status:
- status: "single-currency store (wizard)"
affordance: "the currency Select is suppressed (currencies.length > 1 gate) and the plan inherits the store's primary currency automatically — no dead-end, the merchant simply enters the amount."
- status: "amount entered as 0 or negative"
affordance: "the wizard Continue stays disabled (fixedPriceDollars > 0) and the edit-page patch skips the change (cents > 0), so an invalid price cannot be persisted."
- status: "editing a fixed-price plan's currency"
affordance: "north-star: the edit page should expose the same currency Select as the wizard; today it does NOT (PlanEdit.tsx renders PricingForm with no currency control) so currency is effectively immutable post-create on multi-currency stores — fix is to render CurrencySelector on PlanEdit (see gaps)."
disabled_focus:
keyboard: "All controls are real BigDesign components: the strategy Radios, the amount Input, and (where shown) the currency Select wrap native focusable elements (no div-onClick). Tab reaches the selected-strategy Radio then amount Input then currency Select (wizard) then the primary action; the disabled Continue/Save buttons stay out of tab order until their gate (canAdvance / dirty) is satisfied. The currency Select is keyboard-openable (BigDesign Select)."
focus_move: "No programmatic focus move when switching to the fixed-price strategy — the amount Input mounts in place; on the edit page no focus move occurs when the save/load error Messages appear (consistent with the US-22.1 admin pattern)."
gaps: "AC requires 'enter amount + currency', but the EDIT page renders PricingForm with no currency control (PlanEdit.tsx) — a multi-currency merchant can set currency at create (wizard CurrencySelector) but cannot view or change it when editing, so it is effectively immutable post-create. Minor: the fixed-price Input has no min/step/currency-symbol adornment, so a negative or zero amount can be typed (blocked at the Continue/Save gate, not with inline feedback)."
US-5.5: Free or paid trial
<!-- traceability:start:US-5.5 --><!-- traceability:end:US-5.5 -->Prototype: Trial & Commitment
Phase: MVP · Priority: P1 · Effort: M · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want to offer a free or discounted trial period before the first full charge, so that I reduce friction for first-time subscribers.
Acceptance criteria:
- Given I enable a trial of N days, When a subscriber signs up, Then the first charge is skipped (free trial) or discounted (paid trial) and the second charge is scheduled
N + intervaldays out. - Given a trial is active, When a subscriber cancels before the trial ends, Then no charge occurs and no cancel-penalty applies.
- Given a trial ends and payment method is invalid, When the first full charge runs, Then standard dunning applies.
UX notes.
- Plan wizard: trial section with toggle + days input + "Paid trial at $X" sub-option
- Storefront widget: badge "14-day free trial" prominently displayed if trial enabled
- Subscriber portal: trial status banner with days-remaining countdown; clear "Your first full charge will be $X on YYYY-MM-DD"
Data contract.
- Our DB:
plans.trial_days,plans.trial_amount_cents(nullable) - Subscription creation: first charge scheduled at
created_at + trial_dayswith amount per plan logic (0 for free trial, trial_amount for paid trial); first post-trial charge scheduled attrial_end + interval - Events:
trial.started,trial.ending_soon(T-3 days),trial.converted(first full charge succeeded),trial.cancelled(subscriber cancelled during trial)
Success metrics.
- Functional: trial-to-paid conversion rate measurable per plan
- Product: trial offering target ≥ 15% lift in sign-up rate vs. non-trial control (calibrate per merchant segment)
- Operational (target): "free trial gaming" rate (same customer signing up repeatedly) < 2%
Dependencies.
- US-10.3 (anchor-date math must accommodate trial offset)
- Fraud detection (defer to P2 — for now rely on processor fraud signals)
Non-functional.
- During free trial, no charge attempt = no processor call = no dunning possible. Ensure the scheduler treats trial-ending charges like normal charges from T-0.
UI states.
<!-- ui-states US-5.5 -->surface: "Admin plan wizard Options step (apps/admin/src/pages/products/PlanWizard.tsx step==='options' renders apps/admin/src/components/forms/OptionsForm.tsx) — free-trial toggle + days counter. Storefront PDP widget (apps/storefront-svelte/src/lib/subscriptions/SubscriptionWidget.svelte) renders the trial badge + deferred-first-charge line. Persona: Merchant Admin / Subscriber. The subscriber-portal trial-status banner with days-remaining countdown is ABSENT (see gaps)."
idle:
render: "The Options step renders OptionsForm as a vertical Panel of bordered ToggleRow cards. The Free-trial card (OptionsForm.tsx:66-84) is a BigDesign Switch, OFF by default (PlanWizard.tsx:142 trialEnabled:false); flipping it reveals a BigDesign Counter 'Trial days' (default 14, min 1, max 90, OptionsForm.tsx:73-82). On the storefront, when the selected plan carries trial_days>0 the widget shows a navy 'N-day free trial' badge (SubscriptionWidget.svelte:594-605) and a 'Free today — first charge {date}, then {cadence}' line (SubscriptionWidget.svelte:658-663)."
primary_action: "'Continue' advances the wizard (canAdvance is unconditional on the options step, PlanWizard.tsx:230-251); 'Activate' on the Review step commits via submit('active') (PlanWizard.tsx:333), persisting trial_days = trialEnabled ? trialDays : 0 (PlanWizard.tsx:289)."
loading:
submit: "On Activate / Save draft, submitting=true (PlanWizard.tsx:155): the primary Activate Button shows its isLoading spinner (PlanWizard.tsx:503) and Back / Cancel / Save-draft are disabled (PlanWizard.tsx:484-501); no double-submit."
currencies: "Currency list loads on mount (currenciesLoading, PlanWizard.tsx:163); the Options step renders no spinner of its own."
error:
surfaced_at: "Inline BigDesign Message(type='error') below the step body and above the nav buttons (PlanWizard.tsx:472-480), never a toast. Carries submitError — the raw err.message from createPlan (PlanWizard.tsx:328)."
recovery: "On failure setSubmitting(false) re-enables the still-populated wizard (PlanWizard.tsx:329) so the merchant fixes the field and re-activates; or 'Save draft' / 'Back' to retreat."
empty:
render: "Not a list surface. The closest empty is an unknown product — findProduct miss renders the 'plan wizard not found' Panel with a back-to-products Button (PlanWizard.tsx:214-228). The Options step itself always renders all three toggle cards."
inputs:
- field: "trial_enabled"
control: "switch"
label: "Free-trial toggle — BigDesign Switch (OptionsForm.tsx:66-84)"
validation: "boolean; when off, trial_days persists as 0 (PlanWizard.tsx:289)"
- field: "trial_days"
control: "counter"
label: "'Trial days' — BigDesign Counter (OptionsForm.tsx:73-82); shown only when trial_enabled"
validation: "integer 1-90, step 1 (min/max enforced by the Counter)"
edge_status:
- status: "trial enabled — storefront PDP"
surfaced_at: "SubscriptionWidget trialBadge banner + nextCharge trial line (SubscriptionWidget.svelte:594-663)"
affordance: "shopper sees the 'N-day free trial' badge and the deferred 'first charge {date}' so they know when the first real charge lands before subscribing."
- status: "trial active — subscriber portal"
surfaced_at: "north-star: a portal trial banner; today 'trialing' falls to the NEUTRAL badge branch (subscriptionStatus.ts:29-41) with the raw label (subscriptionStatus.ts:50-57)"
affordance: "north-star contract: show a days-remaining countdown + 'Your first full charge will be $X on YYYY-MM-DD' and a 'cancel before the trial ends to avoid the charge' link. NOT rendered today (see gaps)."
- status: "trial ends, payment method invalid"
surfaced_at: "sub flips to past_due; the portal status surface (SubscriptionStatusActions) owns the past_due affordance"
affordance: "standard dunning applies (BRD AC3); subscriber updates the payment method / retries the first full charge."
disabled_focus:
keyboard: "BigDesign Switch and Counter wrap native focusable controls. Tab order: trial Switch -> (when enabled) Counter decrement/increment + number field -> commitment/max-cycles cards -> nav <button>s (Back / Cancel / Save draft / Continue|Activate) in DOM order. Native disabled removes the nav buttons from tab order while submitting; the primary Button is activatable with Enter/Space."
gaps: "No focus management on the submitError Message reveal (PlanWizard.tsx:472-480) — no role='alert', no aria-live, no .focus() move (mirrors the US-22.1 admin gap); a keyboard/SR merchant gets no announcement when Activate fails."
gaps: "(1) Subscriber-portal trial banner ABSENT — 'trialing' renders the NEUTRAL badge with the raw 'trialing' label and no trial CTA (subscriptionStatus.ts:26,29-57); no days-remaining countdown and no 'first full charge $X on YYYY-MM-DD' disclosure, contradicting the BRD UX note. (2) Paid-trial sub-option absent from the admin form — OptionsFormValue carries no trial_amount_cents (types.ts:27-35) and OptionsForm renders only the free-trial days Counter (OptionsForm.tsx:66-84); the BRD 'Paid trial at $X' UX and plans.trial_amount_cents data contract are unbuilt on this surface."
US-5.6: Lock price at subscription creation
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to optionally lock a subscriber's price at the price they signed up at, so that existing subscribers don't face surprise increases.
Acceptance criteria:
- Given I enable "Lock price at creation" in plan settings, When a new subscription starts, Then
locked_price_centsis snapshotted on the subscription record. - Given a locked-price subscription renews, When the charge is computed, Then it uses
locked_price_centsregardless of catalog or Price List changes.
UI states.
<!-- ui-states US-5.6 -->surface: "Admin plan wizard Options step 'Lock price at creation' Switch (apps/admin/src/pages/products/PlanWizard.tsx -> apps/admin/src/components/forms/OptionsForm.tsx, create path) and apps/admin/src/pages/plans/PlanEdit.tsx, where the same OptionsForm renders with a read-only note (immutable after plan creation). Persona: Merchant Admin. ui_kind=settings."
idle:
render: "In the wizard Options step the Lock-price card (OptionsForm.tsx:126-145) is a BigDesign Switch bound to lockPriceAtCreation, ON by default at create (PlanWizard.tsx:148) with a title + Small description. In PlanEdit the same OptionsForm renders (PlanEdit.tsx:304-308) and a Small note below it explains immutability (lockPriceNote, PlanEdit.tsx:309-313)."
primary_action: "Wizard Activate persists lock_price_at_creation = data.lockPriceAtCreation (PlanWizard.tsx:298). In PlanEdit the field is intentionally omitted from the PATCH (buildPatch, PlanEdit.tsx:128-130) — immutable per backend IMMUTABLE_FIELDS — so Save enables only for other dirty fields."
loading:
load: "PlanEdit loads the plan by id on mount (getPlan useEffect, PlanEdit.tsx:155-171); while plan===null||form===null it renders the loading text (PlanEdit.tsx:195-203)."
save: "On Save, saving=true → the primary Save Button shows its isLoading spinner and Cancel is disabled (PlanEdit.tsx:338-348). Wizard Activate uses submitting (PlanWizard.tsx:155,503)."
error:
surfaced_at: "PlanEdit load failure → Message(type='error') with a Back button (PlanEdit.tsx:178-193); save failure → Message(type='error') above the buttons carrying saveError (PlanEdit.tsx:326-334). Wizard Activate failure → Message (PlanWizard.tsx:472-480). Inline, never toasts."
recovery: "Load error: Back to /plans then reload — no inline retry control (PlanEdit.tsx:187-189). Save error: setSaving(false) re-enables the populated form to fix and resave (PlanEdit.tsx:219)."
empty:
render: "Not a list surface. Closest empty: plan-not-found renders the load-error Message (PlanEdit.tsx:178-193); the wizard product-not-found Panel covers the create path (PlanWizard.tsx:214-228)."
inputs:
- field: "lock_price_at_creation"
control: "switch"
label: "'Lock price at creation' — BigDesign Switch (OptionsForm.tsx:131-137)"
validation: "boolean; persisted at create (PlanWizard.tsx:298). IMMUTABLE in edit — the PlanEdit Switch is interactive but its value is dropped from the PATCH (PlanEdit.tsx:128-130) and toggling it alone leaves the form not-dirty (dirty via buildPatch, PlanEdit.tsx:173-176), so Save stays disabled."
edge_status:
- status: "plan creation — lock on"
surfaced_at: "wizard Options Switch + Review row priceLock (PlanWizard.tsx:813-820)"
affordance: "merchant leaves the Switch on; locked_price_cents is snapshotted on each new subscription at start (BRD AC1)."
- status: "existing plan — lock immutable"
surfaced_at: "PlanEdit OptionsForm Switch + the lockPriceNote (PlanEdit.tsx:304-313)"
affordance: "the note explains the lock can't change after creation; to change it the merchant creates a new plan (the edit Switch is a no-op at save)."
- status: "locked-price renewal"
surfaced_at: "informational (charge engine, BRD AC2)"
affordance: "charge uses locked_price_cents regardless of catalog / Price-List change — no merchant action required."
disabled_focus:
keyboard: "The Lock-price BigDesign Switch wraps a native checkbox-role control reachable via Tab (after the trial / commitment / max-cycles cards) and toggled with Space. In PlanEdit it precedes the Save / Cancel <button>s in DOM order; Save is disabled (removed from actuation) until the form is dirty (PlanEdit.tsx:341-348)."
gaps: "In PlanEdit the immutable Lock-price Switch renders as an ENABLED, interactive control with no disabled/readOnly state — only the adjacent Small note signals immutability. Toggling it animates but silently no-ops at save (buildPatch drops it, PlanEdit.tsx:128-130) and never marks the form dirty, so a merchant can flip it expecting a change and get zero feedback."
gaps: "PlanEdit renders the immutable lock-price toggle as an interactive Switch rather than a disabled/read-only control; the only immutability signal is the adjacent lockPriceNote (PlanEdit.tsx:309-313). The feature itself (snapshot at create, immutable in edit) is built and correct."
US-5.7: Commitment (minimum cycles)
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to require subscribers commit to a minimum number of cycles, so that I improve LTV on high-CAC products.
Acceptance criteria:
- Given I configure
min_cycles: 6, When a subscriber signs up, Then they are shown "Requires 6-cycle commitment" in the widget. - Given a subscriber attempts to cancel before reaching
min_cycles, When they reach the cancel flow, Then they see "Commitment until YYYY-MM-DD" and the Cancel action is blocked or allowed with an early-termination fee per merchant config.
UI states.
<!-- ui-states US-5.7 -->surface: "Admin plan wizard Options step commitment toggle + cycles counter (apps/admin/src/pages/products/PlanWizard.tsx -> apps/admin/src/components/forms/OptionsForm.tsx) and the storefront widget 'Minimum N [interval]s commitment' note (apps/storefront-svelte/src/lib/subscriptions/SubscriptionWidget.svelte). Persona: Merchant Admin / Subscriber. The subscriber-portal cancel-flow commitment-enforcement UI is ABSENT (server enforces; UI does not surface it — see gaps)."
idle:
render: "The Commitment card (ToggleRow, OptionsForm.tsx:86-104) is a BigDesign Switch OFF by default (PlanWizard.tsx:144); enabling it reveals a Counter 'Commitment cycles' (default 3, min 2, max 24, OptionsForm.tsx:93-102). On the storefront, when the selected plan carries commitment_cycles>0 the widget renders the note '≡ Minimum N [interval]s commitment' (commitmentNote derived SubscriptionWidget.svelte:297-303; rendered :666-668)."
primary_action: "Wizard Activate persists commitment_cycles = commitmentEnabled ? commitmentCycles : 0 (PlanWizard.tsx:296)."
loading:
submit: "On Activate, submitting=true (PlanWizard.tsx:155): the Activate Button shows its isLoading spinner (PlanWizard.tsx:503) and Back / Cancel / Save-draft are disabled (PlanWizard.tsx:484-501)."
error:
surfaced_at: "Inline BigDesign Message(type='error') below the step body, above the nav buttons (PlanWizard.tsx:472-480), carrying submitError (PlanWizard.tsx:328). Never a toast."
recovery: "setSubmitting(false) re-enables the populated wizard (PlanWizard.tsx:329) so the merchant fixes the field and re-activates; or Save draft / Back."
empty:
render: "Not a list surface. Closest empty: unknown product → the 'plan wizard not found' Panel with a back-to-products Button (PlanWizard.tsx:214-228). The Options step always renders the commitment card."
inputs:
- field: "commitment_enabled"
control: "switch"
label: "Commitment toggle — BigDesign Switch (OptionsForm.tsx:86-90)"
validation: "boolean; when off, commitment_cycles persists as 0 (PlanWizard.tsx:296)"
- field: "commitment_cycles"
control: "counter"
label: "'Commitment cycles' — BigDesign Counter (OptionsForm.tsx:93-102); shown only when commitment_enabled"
validation: "integer 2-24, step 1 (min/max enforced by the Counter)"
edge_status:
- status: "commitment configured — storefront PDP"
surfaced_at: "SubscriptionWidget commitmentNote (SubscriptionWidget.svelte:666-668)"
affordance: "shopper sees the 'Minimum N commitment' note before subscribing, satisfying BRD AC1."
- status: "subscriber attempts self-cancel inside the commitment window"
surfaced_at: "server returns 409 (cancel_locked / cancel_contact_support / cancel_requires_fee) with lock_expires_at from POST /api/portal/subscriptions/:id/cancel (cancel.ts:106-156); today the UI shows only the raw 409 err.message in CancelSubscriptionButton's error alert (CancelSubscriptionButton.svelte:120-122) AFTER the subscriber confirms"
affordance: "north-star contract: BEFORE the cancel CTA, show 'Commitment until YYYY-MM-DD' and either block the cancel or route per early_cancel_policy (block / contact_us support link / fee). NOT surfaced as a proactive affordance today (see gaps)."
- status: "commitment satisfied (cycles_completed >= commitment_cycles)"
surfaced_at: "funnel → confirm → doCancel (CancelSubscriptionButton.svelte:79-93)"
affordance: "cancel proceeds normally through the churn funnel to confirmation."
disabled_focus:
keyboard: "Admin: the commitment Switch + Counter are native-focusable BigDesign controls reachable via Tab. Portal: the cancel-funnel controls (openFunnel button, Pause / Apply offer buttons, 'Still want to cancel', confirm 'Yes, cancel' / 'Go back') are real <button>s reachable in order, with a labeled close (aria-label, CancelSubscriptionButton.svelte:151-161)."
gaps: "CancelSubscriptionButton receives no commitment/lock props (Props, CancelSubscriptionButton.svelte:20-28) so it cannot render the commitment state; the confirm dialog's 'This cannot be undone' copy (CancelSubscriptionButton.svelte:117-119) shows even for a commitment-locked sub the server will refuse, and the lock is revealed only as a raw error after the subscriber clicks 'Yes'."
gaps: "Portal cancel UI does not surface commitment: no 'Commitment until YYYY-MM-DD' warning, no proactive block, and no parsing of the 409 lock_expires_at / contact_us / fee body into an affordance — it relies entirely on the server 409 (cancel.ts:106-156), which surfaces as a raw error string. BRD AC2 is enforced server-side but not presented client-side."
US-5.8: Cancel lock plan policy — minimum term + early-cancellation policy
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to configure a minimum commitment period and an early-cancellation policy on a plan, so that discount commitments and B2B contracts are contractually enforced without per-subscriber manual overrides.
Acceptance criteria:
- Given I set
minimum_term_cycles: 3on a plan withearly_cancel_policy: 'block', When a subscriber signs up, Thenlock_expires_atis computed ascreated_at + (3 × interval)and stored on the subscription record. - Given a plan has
minimum_term_days: 90(and nominimum_term_cycles), When a subscriber signs up, Thenlock_expires_at = created_at + 90 days. - Given both
minimum_term_cyclesandminimum_term_daysare null, When a subscriber signs up, Thenlock_expires_atis null (no lock — current behavior preserved). - Given
early_cancel_policy: 'contact_us'is set, When the subscriber attempts self-cancel during the lock window, Then the portal routes to the merchant-configured support contact path rather than blocking outright. - Given
early_cancel_policy: 'fee', When the subscriber cancels during the lock window, Then an early-termination fee charge is attempted before cancellation is confirmed (fee amount field reserved; behavior deferred to a future ADR). - Given the plan has
minimum_term_cyclesset, When displayed in the storefront subscription widget, Then "Minimum [N]-[unit] commitment" is shown alongside the price. - Given the subscriber is at checkout for a locked plan, When they confirm the order, Then the confirmation screen states the minimum term end date.
Schema fields.
Plans table: minimum_term_cycles INT (nullable; null = no lock), minimum_term_days INT (nullable; cycle-based and day-based are mutually exclusive — if both set, cycles take precedence), early_cancel_policy ENUM('block','fee','contact_us') (default 'block').
Subscriptions table: lock_expires_at TIMESTAMPTZ (nullable; computed at subscription creation; null if plan has no lock).
Cross-references. BRD US-5.7 (stub predecessor — min_cycles without policy enum); BRD US-18.10 (portal and API enforcement of lock at cancellation time); A3 spec-comprehensiveness matrix U-17; Spec bf1873db; WCSubs feature request #3 (147 votes); persona P4 Sam (B2B contract) + P5 Taylor (commitment-discount plan).
UI states.
<!-- ui-states US-5.8 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan edit page — 'Commitment & cancellation policy' settings section (minimum_term_cycles, minimum_term_days, early_cancel_policy), plus subscriber portal (Svelte/Tailwind) cancel-flow enforcement gate keyed off lock_expires_at. Persona: Merchant Admin (configuration) / Subscriber (cancel enforcement)."
idle:
render: "A 'Commitment & cancellation policy' Panel section within the plan edit form. Three fields: 'Minimum term (cycles)' number Input (empty = no lock), 'Minimum term (days)' number Input (empty = no lock; a helper note states cycles take precedence when both are set), and 'Early-cancellation policy' Select. A contextual inline note explains that enabling a minimum term computes lock_expires_at on each new subscription at sign-up. BRD ACs 6–7 (storefront 'Minimum N-cycle commitment' widget + checkout confirmation of lock end date) render on separate storefront surfaces — documented here so they are not silently skipped."
primary_action: "'Save plan' Button at the form footer commits the full plan including the policy block."
loading:
trigger: "PATCH /api/v1/admin/plans/{planId} on 'Save plan' click."
render: "'Save plan' Button shows isLoading with label 'Saving…' and is disabled; both term Inputs and the policy Select are disabled for the duration of the request."
error:
surfaced_at: "Inline BigDesign Message(type='error') appearing directly below the 'Commitment & cancellation policy' Panel header — never a page-level toast displaced from the failing fields."
render: "The API error string, e.g. 'Minimum term cycles and days cannot both be set together' or 'early_cancel_policy must be one of block, fee, contact_us'."
recovery: "The form stays populated with the Merchant's last input; they correct the conflicting field (clear one of the two term Inputs or pick a valid policy) and click 'Save plan' again."
empty:
render: "When no minimum term is configured (both term fields null, policy defaulting to 'block'), the Panel section renders in its unset default state — both term Inputs empty and the policy Select showing 'Block self-service cancellation (default)'. The section is always present on the plan edit form; it is never hidden or absent."
inputs:
- field: "minimum_term_cycles"
control: "number"
label: "'Minimum term (cycles)' — positive integer; empty clears the lock; defines how many billing cycles the subscriber is committed to"
- field: "minimum_term_days"
control: "number"
label: "'Minimum term (days)' — positive integer; empty clears the lock; mutually exclusive with minimum_term_cycles (API rejects both set simultaneously)"
- field: "early_cancel_policy"
control: "select"
allowed_values: "block | fee | contact_us"
label: "'Early-cancellation policy' — BigDesign Select; display labels: 'Block self-service cancellation', 'Charge early-termination fee', 'Route to support contact'"
edge_status:
- status: "subscriber attempts self-cancel during lock window; early_cancel_policy = block"
affordance: "Subscriber portal cancel flow shows a locked-plan notice ('Your plan includes a minimum commitment through [lock_expires_at formatted date]') and hides the self-cancel confirm button; a 'Contact support' link is offered so the subscriber can escalate."
- status: "subscriber attempts self-cancel during lock window; early_cancel_policy = contact_us"
affordance: "Subscriber portal cancel flow routes to the merchant-configured support contact path instead of the self-cancel confirmation screen; cancellation is possible via support, not self-serve."
- status: "subscriber attempts self-cancel during lock window; early_cancel_policy = fee"
affordance: "Subscriber portal cancel flow surfaces an early-termination fee disclosure step before the cancellation confirmation; exact fee amount is reserved per a future ADR and displayed as TBD until that ADR lands."
- status: "plan already has active subscribers and minimum_term_cycles is changed"
affordance: "A warning Message(type='warning') below the term Inputs states: 'Changes to the minimum term apply only to new subscriptions created after saving. Existing subscriber lock dates are unchanged.' Merchant can still save."
disabled_focus:
keyboard: "Both term number Inputs and the policy Select are real BigDesign components inside FormGroups with label props — reachable in DOM tab order; the policy Select is navigable with arrow keys through the three enum values (block / fee / contact_us); 'Save plan' is a real Button activatable with Enter or Space. On the subscriber portal side, the locked-plan notice and 'Contact support' link are native anchor or button elements in tab order — never div-onClick dead-ends."
US-5.9: Calendar-anchored billing plan anchor
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to configure fixed calendar renewal dates (billing_anchor_month and billing_anchor_day) on a plan, so that all subscribers renew on the same annual date (e.g., January 1) regardless of their individual sign-up date.
Acceptance criteria:
- Given I enable calendar anchoring and set
billing_anchor_month: 1andbilling_anchor_day: 1on an annual plan, When a subscriber signs up on any date in the year, Then theirnext_charge_atis computed as the nearest future January 1. - Given a subscriber signs up mid-year on an annually-anchored plan, When the first-cycle charge is computed, Then a proration amount is applied for the partial period (see US-10.8 for proration semantics).
- Given a plan has
billing_anchor_monthandbilling_anchor_dayset, When existing subscribers on that plan renew, Then all renewal dates converge to the configured anchor; no per-subscriber drift occurs. - Given the configured anchor date is February 29, When it falls in a non-leap year, Then the charge fires on February 28.
- Given the store has a configured timezone, When anchor dates are computed, Then all date arithmetic uses the store's timezone (consistent with US-10.3 behavior).
- Given I set
billing_anchor_monthwithoutbilling_anchor_day(or vice versa), When I attempt to save the plan, Then a validation error is returned: "Both billing_anchor_month and billing_anchor_day must be set together."
Surface constraint ([Decision] #1751). Calendar anchoring is offered on the direct-API / admin subscription-creation path only (handleSubscriptionPost), not on the bc_payments self-service storefront (the order webhook). On the storefront path BC charges the catalog price at checkout and cannot express a dynamic per-signup-date proration (US-10.8), so an anchored first cycle would mis-bill; a storefront annual plan therefore renews on the anniversary. The anchor + proration engine is built and serves the B2B / fiscal-year personas (Maya, Sam) via the direct-API path. A future $0-checkout-then-charge-via-processor model could bring it to the storefront — its own spec'd decision, not built.
Schema fields.
Plans table: billing_anchor_month INT (1–12, nullable), billing_anchor_day INT (1–31, nullable; validated against the configured month). Both fields must be null or both must be non-null — partial configuration is rejected at the API layer. In Phase 2 these fields apply only to plans with interval_unit = 'year'; monthly-cycle anchoring is a Phase 3 extension (per scope of Spec e229c3d3 and architectural complexity of the scheduler extension required).
Cross-references. A3 spec-comprehensiveness matrix U-20; Spec e229c3d3; WCSubs feature request #7 (47 votes); persona P1 Maya (annual wine club, fiscal-year renewals on Jan 1) + P4 Sam (B2B corporate wellness, Acme April 1 fiscal year); US-10.3 (per-subscription anchor-date math — calendar anchor is an extension of this); US-10.8 (first-cycle proration for mid-year sign-ups on anchored plans).
UI states.
<!-- ui-states US-5.9 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan edit page — 'Calendar billing anchor' settings section (billing_anchor_month, billing_anchor_day), rendered only when interval_unit = 'year'. Direct-API / admin subscription-creation path only; storefront annual plans renew on anniversary per Decision #1751. Persona: Merchant Admin."
idle:
render: "A 'Calendar billing anchor' Panel section within the plan edit form, rendered only when interval_unit = 'year'. An 'Enable calendar anchor' Checkbox reveals, when checked, two Selects side-by-side: 'Anchor month' (January through December) and 'Anchor day' (1–31). An inline help note reads: 'All subscribers renew on this fixed annual date. Mid-year sign-ups are prorated for the partial first cycle. Available on direct-API subscription creation only — storefront sign-ups renew on their anniversary.'"
primary_action: "'Save plan' Button commits all plan fields including the anchor pair."
loading:
trigger: "PATCH /api/v1/admin/plans/{planId} on 'Save plan' click."
render: "'Save plan' shows isLoading / 'Saving…' and is disabled; the enable Checkbox and both anchor Selects are disabled during the request."
error:
surfaced_at: "Inline BigDesign Message(type='error') positioned directly below the anchor Selects — not a page-level toast."
render: "Validation error strings from the API: 'Both billing_anchor_month and billing_anchor_day must be set together' (partial config), or 'Anchor day [N] is not valid for [Month]' (e.g. February 30)."
recovery: "The form stays populated; the Merchant sets both fields to a valid pairing (or clears both by unchecking the Checkbox) and clicks 'Save plan' again."
empty:
render: "When calendar anchoring is disabled (Checkbox unchecked, both fields null), the month and day Selects are hidden and the Panel shows only the 'Enable calendar anchor' Checkbox with the inline help note — the section is never blank or absent from the form."
inputs:
- field: "calendar_anchor_enabled"
control: "checkbox"
label: "'Enable calendar anchor' — controls visibility of month and day Selects; unchecking resets both fields to null"
- field: "billing_anchor_month"
control: "select"
allowed_values: "1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12"
label: "'Anchor month' — BigDesign Select displaying month names (January = 1 through December = 12); revealed only when anchor is enabled"
- field: "billing_anchor_day"
control: "number"
label: "'Anchor day' — number Input (1–31); server validates the day against the selected month; Feb 29 is accepted and resolves to Feb 28 in non-leap years per BRD AC4"
edge_status:
- status: "anchor set to Feb 29"
affordance: "An inline helper note below the day Input reads: 'In non-leap years this date resolves to February 28.' Save is allowed; no error is raised at save time."
- status: "existing subscribers on this plan when anchor is first enabled"
affordance: "A warning Message(type='warning') appears: 'Enabling an anchor will shift all existing subscribers' next renewal to [formatted anchor date] — this may cause an early or delayed charge. Check the box below to confirm.' A required 'I understand, apply anchor to existing subscribers' Checkbox must be checked before 'Save plan' is enabled."
- status: "partial anchor config — month set but day empty, or vice versa"
affordance: "The API returns a 400 validation error; the error Message surfaces 'Both billing_anchor_month and billing_anchor_day must be set together' and both fields remain populated so the Merchant fills the missing one before saving."
disabled_focus:
keyboard: "The 'Enable calendar anchor' Checkbox, 'Anchor month' Select (arrow-key navigable through 12 month options), 'Anchor day' number Input, and 'Save plan' Button are real BigDesign components inside FormGroups with label props. The month Select and day Input are conditionally rendered (not merely hidden) when the Checkbox is unchecked, so they leave and re-enter tab order correctly. All activatable controls respond to Enter/Space; no div-onClick dead-ends."