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 26 — Eligibility, inclusion & exclusion rules (derived view)
Read-only per-epic slice of
BRD.md§9, lines 10934–11512. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-26.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (10): US-26.1, US-26.2, US-26.3, US-26.4, US-26.5, US-26.6, US-26.7, US-26.8, US-26.9, US-26.10
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 26 — Eligibility, inclusion & exclusion rules
<!-- traceability:start:BRD:Epic-26 --><!-- traceability:end:BRD:Epic-26 -->Prototype: Rules Editor · Catalog Preview · Audit Tool · Mutex & Dependencies · Product Exclusion · Variant Eligibility · Custom Field Eligibility · Segment Eligibility · Geo & Channel Restrictions · Quantity Min / Max
Value: Merchants can precisely control which products and which customers can participate in which subscription behaviors.
US-26.1: Product inclusion by category
<!-- traceability:start:US-26.1 --><!-- traceability:end:US-26.1 -->Prototype: Rules Editor · Catalog Preview
Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin
As a Merchant Admin, I want to specify subscription eligibility by BC category (include only X, Y categories), so that I opt-in whole sections of my catalog quickly.
Acceptance criteria:
- Given a rule
include_categories: [5, 12], When I enable a plan with this rule, Then the subscribe widget renders only on products in those categories. - Given a product is added to an included category later, When I view it on storefront, Then it automatically becomes subscribable.
UX notes.
- Surface: plan wizard eligibility step or dedicated rules editor
- Form: category tree picker (from BC taxonomy)
- Preview: sample products that would be eligible (live-updating)
Data contract.
- Entity:
eligibility_rules—{id, plan_id, rule_type, operator, value}(normalized rule storage allows AND/OR composition) - Rule types:
include_category,exclude_category,include_brand,exclude_brand,include_product,exclude_product,require_custom_field,include_customer_group,exclude_customer_group,allowed_country,allowed_channel,min_qty,max_qty,mutex,requires_prior_purchase - Evaluation:
evaluate(rules, context) → {eligible: bool, violated_rules: []} - On storefront widget render: call our eligibility endpoint; response controls widget visibility
Success metrics.
- Functional: 100% alignment between widget visibility and configured rules
- Product (target): merchants with >5 eligibility rules show plan activation rate comparable to simpler merchants (indicates the complexity is manageable)
Dependencies.
- US-26.10 (audit tool depends on the rule engine)
Non-functional.
- Rule evaluation cached per product × customer-group × channel for 5 min
- Rule changes invalidate relevant cache entries
UI states.
<!-- ui-states US-26.1 -->surface: "Merchant Admin (React/BigDesign) — category-inclusion allowlist CRUD. apps/admin/src/pages/products/CategoryInclusionsList.tsx, routed at /v2/products/category-inclusions (App.tsx:92, component imported App.tsx:42). Reached by direct URL only — SideNav.tsx exposes a top-level '/products' entry but no category-inclusions sub-entry. Persona: Merchant Admin. Empty allowlist => no category restriction; non-empty => a product is subscription-eligible only if one of its BC categories is on the list."
idle:
render: "An H2 title + Small subtitle (lines 144-149), then a state banner Message reading 'unrestricted' (type=info) when the allowlist is empty or 'restricting' (type=warning) when configured (allowlistConfigured, lines 138, 156-171). Below it an add Form: a numeric BC category ID Input (type=number, placeholder 'e.g. 123', lines 176-188) and an optional note Textarea (max 500 chars, lines 191-202), then the submit Button (lines 210-218). A back-to-products subtle Button sits top-right (lines 151-153)."
primary_action: "Type a positive integer BC category ID and submit the add Form (handleAdd -> addCategoryInclusion -> reload(), lines 79-119). Remove is a per-row subtle Button (lines 286-298)."
loading:
initial: "On mount reload() runs (lines 75-77); while rows === null a plain 'Loading…' Text renders (lines 235-238) — no skeleton, no spinner."
submit: "While submitting === true the Add Button is disabled and shows its submitting label (lines 210-218)."
remove: "While removingId === row.bc_category_id that row's Remove Button is disabled and shows its removing label (lines 63, 286-298)."
error:
surfaced_at: "Two inline BigDesign Message(type=error) banners, never toasts. Add/validation failures render a Message directly below the form fields (submitError, lines 204-208). Load and remove failures render a Message with a header above the list (loadError, lines 223-233). Client validation (non-positive ID lines 84-89, note too long lines 90-98) and the 409 duplicate (CategoryInclusionConflictError -> 'already on the allowlist', lines 106-112) all surface in the submit Message."
recovery: "On a submit/validation error the form stays populated and re-enabled (setSubmitting(false), line 117) so the merchant corrects the ID/note and resubmits. Load/remove errors have NO in-app retry control — reload() runs only on mount (lines 75-77), so the merchant must reload the page to retry."
empty:
render: "When rows.length === 0 a BigDesign Message(type=info) renders the 'empty' copy (lines 239-249) instead of the Table; the add Form above it stays available so the merchant can add the first category. Never a blank pane."
cta: "Add a BC category ID via the always-present add Form (handleAdd, lines 79-119)."
inputs:
- field: "bc_category_id"
control: "number"
label: "'BC category ID' — BigDesign Input type=number with label + description (lines 176-188)"
allowed_values: "n/a — an unbounded BC catalog category identifier (positive integer), not one of the lint's enumerable domains. NORTH-STAR: a BC-taxonomy category-tree picker that resolves names (see gaps)."
validation: "trimmed, must parse to a positive integer (lines 82-89); duplicates rejected server-side as 409 (lines 106-112)."
- field: "note"
control: "text"
label: "'Note' — optional BigDesign Textarea, max 500 chars (lines 191-202)"
validation: "length <= 500 enforced client-side (lines 90-98)."
edge_status:
- status: "allowlist empty — no category restriction in force"
affordance: "info banner 'unrestricted' (lines 156-171) tells the merchant every product is eligible; they add a category ID via the add Form to begin restricting."
- status: "allowlist configured — only listed categories are subscription-eligible"
affordance: "warning banner 'restricting' (lines 156-171); merchant adds more IDs or removes rows (Remove Button lines 286-298, guarded by window.confirm line 126) to widen/narrow the allowlist."
- status: "duplicate_category (409)"
affordance: "submit Message names the already-listed BC category id (CategoryInclusionConflictError, lines 106-112); merchant enters a different ID and resubmits."
- status: "invalid_category_id (client validation)"
affordance: "submit Message 'invalid id' (lines 84-89); merchant corrects to a positive integer and resubmits."
disabled_focus:
keyboard: "All controls are real BigDesign components wrapping native focusable elements (Input/Textarea/Button) — no div-onClick dead-ends. Tab order follows DOM source order: back Button -> BC category ID Input -> note Textarea -> Add Button -> each row's Remove Button. Native disabled removes the Add Button (while submitting) and a Remove Button (while that row removes) from tab order. The destructive Remove is guarded by a native window.confirm (line 126), which is keyboard-operable."
focus_move: "NO focus management on dynamic reveal: the submit/load error Messages (lines 204-208, 223-233), the state banner, and the empty Message carry no role='alert' and no aria-live, and the file calls no .focus() — a keyboard/screen-reader merchant gets no announcement when an add fails or the list reloads (see gaps)."
gaps: "1) BRD US-26.1 UX spec calls for a BC-taxonomy category-tree picker and a live 'sample eligible products' preview; the built UI accepts a raw numeric category ID and the Table renders only 'BC #N' (line 258) with no name resolution and no preview — merchants must cross-reference BC admin to know what a category is (missing-control defect). 2) Error/empty/state Messages are not announced to assistive tech (no role=alert / aria-live / focus move), the same a11y gap the US-22.1 exemplar documents."
US-26.2: Product exclusion
<!-- traceability:start:US-26.2 --><!-- traceability:end:US-26.2 -->Prototype: Product Exclusion
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to exclude specific products/SKUs/categories/brands from subscription eligibility (e.g., gift cards, hazmat items), so that they can't accidentally be subscribed.
Acceptance criteria:
- Given a rule
exclude_products: [...]orexclude_categories: [...]orexclude_brands: [...], When a shopper views an excluded product, Then no subscribe widget renders. - Given exclusion lists + inclusion lists are both set, When a product matches both, Then exclusion wins.
UI states.
<!-- ui-states US-26.2 -->surface: "Merchant Admin (React/BigDesign) — product-exclusion list CRUD. apps/admin/src/pages/products/ExclusionsList.tsx, routed at /v2/products/exclusions (App.tsx:91, component imported App.tsx:41). Reached by direct URL only — SideNav.tsx has a top-level '/products' entry but no exclusions sub-entry. Persona: Merchant Admin. Excluded BC products are blocked from subscription enablement. Covers product-level exclusion ONLY; BRD US-26.2 AC also names exclude_categories and exclude_brands, which have no admin UI here (see gaps)."
idle:
render: "An H2 title + Small subtitle (lines 138-143), then an add Form: a numeric BC product ID Input (type=number, placeholder 'e.g. 2961', lines 153-161) and an optional reason Textarea (max 500 chars, lines 164-173), then the submit Button (lines 181-189). A back-to-products subtle Button sits top-right (lines 145-147). Unlike CategoryInclusionsList there is no restricting/unrestricted state banner."
primary_action: "Type a positive integer BC product ID and submit the add Form (handleAdd -> addProductExclusion -> reload(), lines 77-115). Remove is a per-row subtle Button (lines 254-266). The product-ID Table cell is a link that navigates to the product detail (lines 219-230)."
loading:
initial: "On mount reload() runs (lines 73-75); while rows === null a plain 'Loading…' Text renders (lines 204-207) — no skeleton, no spinner."
submit: "While submitting === true the Add Button is disabled and shows its submitting label (lines 181-189)."
remove: "While removingId === row.bc_product_id that row's Remove Button is disabled and shows its removing label (lines 61, 254-266)."
error:
surfaced_at: "Two inline BigDesign Message(type=error) banners, never toasts. Add/validation failures render a Message directly below the form fields (submitError, lines 175-179). Load and remove failures render a Message with a header above the list (loadError, lines 194-202). Client validation (non-positive ID lines 82-85, reason too long lines 86-94) and the 409 duplicate (ProductExclusionConflictError -> 'already excluded', lines 102-108) all surface in the submit Message."
recovery: "On a submit/validation error the form stays populated and re-enabled (setSubmitting(false), line 113) so the merchant corrects the ID/reason and resubmits. Load/remove errors have NO in-app retry control — reload() runs only on mount (lines 73-75), so the merchant must reload the page to retry."
empty:
render: "When rows.length === 0 a BigDesign Message(type=info) renders the 'empty' copy (lines 208-212) instead of the Table; the add Form above it stays available so the merchant can add the first exclusion. Never a blank pane."
cta: "Add a BC product ID via the always-present add Form (handleAdd, lines 77-115)."
inputs:
- field: "bc_product_id"
control: "number"
label: "'BC product ID' — BigDesign Input type=number with label + description (lines 153-161)"
allowed_values: "n/a — an unbounded BC catalog product identifier (positive integer), not one of the lint's enumerable domains."
validation: "trimmed, must parse to a positive integer (lines 80-85); duplicates rejected server-side as 409 (lines 102-108)."
- field: "reason"
control: "text"
label: "'Reason' — optional BigDesign Textarea, max 500 chars (lines 164-173)"
validation: "length <= 500 enforced client-side (lines 86-94)."
edge_status:
- status: "duplicate_product (409)"
affordance: "submit Message names the already-excluded BC product id (ProductExclusionConflictError, lines 102-108); merchant enters a different ID and resubmits."
- status: "invalid_product_id (client validation)"
affordance: "submit Message 'invalid id' (lines 82-85); merchant corrects to a positive integer and resubmits."
- status: "product excluded — row present in the list"
affordance: "each row shows the linked BC product (lines 219-230) and a Remove Button (lines 254-266, guarded by window.confirm line 122) to lift the exclusion."
disabled_focus:
keyboard: "All controls are real BigDesign components wrapping native focusable elements (Input/Textarea/Button), plus a real anchor for the product link (lines 219-230) — no div-onClick dead-ends. Tab order follows DOM source order: back Button -> BC product ID Input -> reason Textarea -> Add Button -> each row's product link + Remove Button. Native disabled removes the Add Button (while submitting) and a Remove Button (while that row removes) from tab order. The destructive Remove is guarded by a native window.confirm (line 122), which is keyboard-operable."
focus_move: "NO focus management on dynamic reveal: the submit/load error Messages (lines 175-179, 194-202) and the empty Message carry no role='alert' and no aria-live, and the file calls no .focus() — a keyboard/screen-reader merchant gets no announcement when an add fails or the list reloads (see gaps)."
gaps: "1) PARTIAL coverage: BRD US-26.2 AC names exclude_products, exclude_categories AND exclude_brands; this admin surface only manages product-ID exclusions — there is no rule-type control for category or brand exclusions, so those rule types are backend/spec-only with no merchant UI (missing-control / partial). 2) Error/empty Messages are not announced to assistive tech (no role=alert / aria-live / focus move), the same a11y gap the US-22.1 exemplar documents."
US-26.3: Variant-level eligibility
<!-- traceability:start:US-26.3 --><!-- traceability:end:US-26.3 -->Prototype: Variant Eligibility
Phase: MVP · Persona: Merchant Admin
As a Merchant Admin, I want to include/exclude at the variant level (e.g., only certain sizes are subscribable), so that I model realistic catalog constraints.
Acceptance criteria:
- Given a per-variant eligibility list, When a shopper picks an excluded variant, Then the widget switches to "one-time only" and hides the subscribe option.
UI states.
<!-- ui-states US-26.3 -->surface: "Merchant Admin (React/BigDesign) — variant-level eligibility, the EligibilityStep of the 6-step plan-create wizard. apps/admin/src/pages/products/PlanWizard.tsx (EligibilityStep, lines 576-708), routed at /v2/products/:bcProductId/wizard (App.tsx:94). Persona: Merchant Admin. The merchant picks 'all variants eligible' or 'restricted' and, when restricted, checks the eligible variant subset; activate() POSTs eligible_variant_ids only when scope=restricted (lines 308-311). NOTE: the BRD US-26.3 storefront half ('switch to one-time only on an ineligible variant') is NOT honored by SubscriptionWidget.svelte (see gaps)."
idle:
render: "A BigDesign Panel holding a Form with two Radios — 'all variants' (default) and 'restricted' (eligibilityScope, lines 620-633) — plus a static info Message about Epic-26 rule scope (lines 693-704). When eligibilityScope=restricted a variant checkbox list reveals below the radios (lines 637-691)."
primary_action: "Pick 'restricted', tick at least one variant Checkbox (toggleVariant, lines 606-613), then advance the wizard Continue Button (canAdvance, lines 246-248); final activation is the Review step's primary Activate Button (submit('active'), lines 502-505)."
loading:
variants: "On first flip to 'restricted', getProductVariants(bcProductId) fetches the variant list (lines 588-604); while variants === null a 'Loading variants…' Small renders (variantsLoading, lines 647-650) — no skeleton."
submit: "On the Review step, while submitting === true the primary Activate Button shows isLoading and the Back / Cancel / Save-draft Buttons are disabled (lines 484-505)."
error:
surfaced_at: "Two inline BigDesign Message(type=error) banners, never toasts. A variant-fetch failure renders a Message with a header inside the EligibilityStep Panel (variantsError, lines 639-646). A plan-activation failure renders a wizard-level Message with a header above the nav buttons (submitError, lines 472-480; set in submit() catch line 328)."
recovery: "Variant-fetch error: the merchant can switch the radio back to 'all' to proceed without a restriction, or toggle 'all' -> 'restricted' to re-run the fetch effect (lines 588-604); there is no dedicated retry Button. Activation error: submit() sets submitting=false (line 329) re-enabling the wizard so the merchant retries Activate; the entered config is preserved in component state."
empty:
render: "When scope=restricted and the variant fetch returns zero variants (variants.length === 0) a BigDesign Message(type=info) renders one of two copies depending on variantSource: 'auth required' when source='fallback_empty' (the BC API proxy returned empty for lack of credentials) else 'no variants' (lines 651-664). The radios and the Epic-26 note stay rendered — never a blank step."
cta: "Switch the radio to 'all variants' to create the plan without a variant restriction, or resolve the BC API connection and re-enter the step to reload variants."
inputs:
- field: "eligibility_scope"
control: "select"
label: "'All variants' / 'Restricted to selected variants' — two BigDesign Radios (lines 620-633)"
allowed_values: "all | restricted"
validation: "scope=restricted requires eligibleVariantIds.length > 0 before Continue (canAdvance, lines 246-248)."
- field: "eligible_variant_ids"
control: "checkbox"
label: "per-variant BigDesign Checkbox list, one per variant (renderVariantLabel, lines 674-681)"
allowed_values: "the product's bc_variant_id set returned by getProductVariants (lines 588-604)."
validation: "at least one must be checked when scope=restricted; a 'pick at least one' warning Small shows while empty (lines 683-687)."
edge_status:
- status: "restricted scope with no variant selected"
affordance: "a warning Small 'select at least one' renders (lines 683-687) and the Continue Button is disabled (canAdvance false, lines 246-248); merchant ticks a variant or switches to 'all'."
- status: "variants_empty / variants_auth_required"
affordance: "info Message names the cause (lines 651-664); merchant switches to 'all' to proceed, or fixes the BC API connection and re-enters the step to reload."
- status: "variant_fetch_error"
affordance: "error Message with the failure reason (variantsError, lines 639-646); merchant switches to 'all', or toggles all->restricted to re-attempt the fetch."
- status: "plan_activation_failed"
affordance: "wizard-level error Message (submitError, lines 472-480); the wizard re-enables (line 329) so the merchant retries Activate with the same config."
disabled_focus:
keyboard: "Every control is a real BigDesign component over a native focusable element: the two Radios (lines 620-633), each variant Checkbox (lines 674-681), and the wizard Back / Cancel / Save-draft / Continue|Activate Buttons (lines 482-513) — no div-onClick. Tab order follows DOM source order: all-radio -> restricted-radio -> (when restricted) each variant Checkbox -> footer Buttons. Native disabled removes the Continue Button from tab order while canAdvance is false and the footer Buttons while submitting."
focus_move: "NO focus management on dynamic reveal: the variant list, the variantsError / variants-empty Messages, and the wizard-level submitError Message carry no role='alert' / aria-live and the file moves focus nowhere — a keyboard/screen-reader merchant gets no announcement when variants load, fail, or activation fails (see gaps)."
gaps: "1) CROSS-SURFACE DEFECT (the AC's storefront half): the admin can persist eligible_variant_ids, but SubscriptionWidget.svelte loads plans by product only — getPlans({ bcProductId }) (line 123) carries no variant axis, and its own comment marks bcVariantId variant-eligibility filtering as forward-compat/unimplemented (line 18). So when a shopper selects an ineligible VARIANT the widget does NOT switch to one-time-only and does NOT hide subscribe (BRD US-26.3 storefront AC unmet). The line-14 'renders nothing' comment is stale — a one-time-only fallback now exists at plans.length===0 (lines 508-545), but it is product-level, not variant-level. 2) EligibilityStep error/empty/variant-list reveals are not announced to assistive tech (no role=alert / aria-live / focus move)."
US-26.4: Custom-field-based eligibility
<!-- traceability:start:US-26.4 --><!-- traceability:end:US-26.4 -->Prototype: Custom Field Eligibility
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin
As a Merchant Admin, I want eligibility driven by BC product custom fields (e.g., subscribable=true), so that I manage eligibility in BC admin rather than our tool.
Acceptance criteria:
- Given a rule
require_custom_field: {name:subscribable, value:true}, When a shopper views a product, Then the widget only renders if the custom field matches.
UI states.
<!-- ui-states US-26.4 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan eligibility settings — custom-field rule configuration panel. Merchant creates require_custom_field rules ({name, value}) that specify which BC product custom field name and value must be present for the widget to render; the per-product value (e.g. subscribable=true) lives in BC admin, not our tool. STOREFRONT (Svelte/Tailwind): the subscription widget conditionally renders based on whether the BC product's custom fields satisfy all active rules. Persona: Merchant Admin."
idle:
render: "A 'Custom field rules' section with an H3 heading and explanatory text ('Only show the subscription widget for products where a BC product custom field matches a specified value'). An 'Add rule' form with two BigDesign Inputs — 'Field name' (placeholder 'e.g. subscribable') and 'Required value' (placeholder 'e.g. true') — and an 'Add rule' Button. Below, a table of active custom-field rules with columns: Field name / Required value / Created / Remove Button. STOREFRONT: the subscription widget renders on the PDP only when the product's custom fields satisfy all active require_custom_field rules; products that do not match render nothing (widget is absent)."
primary_action: "Enter field name and required value, then submit to add the rule. Each rule row has a 'Remove' Button to delete that rule."
loading:
trigger: "GET /api/v1/admin/eligibility/custom-field-rules on panel mount."
render: "While rules are loading, a 'Loading rules…' Text line renders in the rules section; the Add Rule form is available immediately (no fetch dependency). While a submit is in flight, the 'Add rule' Button shows isLoading ('Adding…') and both Inputs are disabled."
error:
surfaced_at: "Inline BigDesign Message(type='error') banners, never toasts. A load failure renders a Message in the rules section ('Could not load custom field rules — reload to try again'). A submit failure renders a Message below the Add Rule form ('Could not save rule') carrying the API error detail."
recovery: "On submit failure the form stays populated and re-enabled so the merchant corrects the field name or required value and resubmits. Load failure has no in-app retry — merchant reloads the page."
empty:
render: "When no rules exist, secondary Text 'No custom field rules configured — the subscription widget renders for all products' displays in place of the rules table. The Add Rule form remains visible and usable so the merchant can add the first rule immediately. Never a blank panel."
edge_status:
- status: "no rules configured (default)"
affordance: "Empty-state note clarifies the widget renders for all products; merchant adds the first rule via the always-present Add Rule form."
- status: "rule field name not found on the product at evaluation time"
affordance: "STOREFRONT: the missing custom field evaluates as non-match so the widget is suppressed. Merchant verifies the exact BC field name via BC catalog and updates the rule; the eligibility audit tool (US-26.10) surfaces the evaluated input values so support can diagnose."
- status: "duplicate rule for the same field name"
affordance: "Submit Message carries API conflict reason; merchant adjusts the required value or removes the existing rule for that field name before saving."
inputs:
- field: "custom_field_name"
control: "text"
label: "'Field name' — BigDesign Input with label and placeholder 'e.g. subscribable'"
validation: "Required; validated server-side against recognised BC custom field name patterns."
- field: "required_value"
control: "text"
label: "'Required value' — BigDesign Input with label and placeholder 'e.g. true'"
validation: "Required; string-compared against the product's custom field value at evaluation time."
disabled_focus:
keyboard: "'Field name' Input, 'Required value' Input, and 'Add rule' Button are real BigDesign components over native focusable elements — Tab moves through field name → required value → Add rule Button → each row's Remove Button in DOM order. Remove Buttons are real BigDesign Buttons (not div-onClick), activatable with Enter or Space. While a remove is in flight the affected row's Button uses isLoading to prevent double-submit. Focus does not move programmatically on rule-add success; the refreshed table is available in the natural tab sequence."
US-26.5: Customer-segment eligibility
<!-- traceability:start:US-26.5 --><!-- traceability:end:US-26.5 -->Prototype: Segment Eligibility
Phase: P1 (pulled forward 2026-05-16) · Priority: P1 · Effort: M · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want a plan to be available only to certain customer segments (group, tenure, LTV threshold), so that I gate premium subscription experiences.
Acceptance criteria:
- Given a rule
include_customer_groups: [vip], When a non-VIP browses, Then the subscribe option is hidden. - Given a guest (unauthenticated) shopper, When the rule requires an authenticated group, Then a sign-in CTA replaces the widget.
UX notes.
- For ineligible shoppers, the PDP widget either (a) hides entirely or (b) shows "Exclusive to Gold members" with sign-in/upgrade CTA — merchant-configurable
Data contract.
- BC API: subscriber's
customer_group_idresolved via storefront context or customer-profile call - Rule engine evaluates
customer_group_id ∈ allowed_groups
Success metrics.
- Product: conversion of customer-group-gated plans by target segment vs. baseline
- Product: gated-plan UX does not hurt overall site conversion
UI states.
<!-- ui-states US-26.5 -->surface: "Two surfaces. ADMIN (built, React/BigDesign): merchant restricts a plan to customer groups via CustomerGroupPicker in ScopeStep.tsx (step === 'scope'); selection persists as customer_group_ids (PlanWizard.tsx payload, CreatePlanInput.customer_group_ids). STOREFRONT (gap, Svelte): an ineligible shopper should see a hidden widget or an 'Exclusive to Gold members' CTA with sign-in/upgrade — today the storefront SubscriptionWidget has no eligibility branch and ineligibility collapses into the one-time-only fallback (see gaps + defects). Persona: Merchant Admin / Subscriber."
idle:
render: "ADMIN: 'Customer groups' FormGroup — help text, one BigDesign Checkbox per group (name + 'Default' Badge), 'No groups selected — plan will be available to all customers' note when empty. STOREFRONT north-star: an eligible shopper sees the subscribe widget; an ineligible shopper sees either nothing (merchant chose 'hide') or an 'Exclusive to Gold members' card with a sign-in (guest) / upgrade (wrong group) CTA — merchant-configurable per the UX note."
primary_action: "ADMIN: toggle group checkboxes to gate the plan; persists on submit. STOREFRONT north-star: the ineligible-state CTA (sign in / view membership) is the shopper's recovery path back to eligibility."
loading:
trigger: "ADMIN: fetchBcCustomerGroups() on CustomerGroupPicker mount. STOREFRONT: getPlans({ bcProductId }) on SubscriptionWidget mount."
render: "ADMIN: 'Loading customer groups...' Small line while groups === null. STOREFRONT: an aria-busy skeleton (animated pulse rows) while plans load."
error:
surfaced_at: "ADMIN: inline BigDesign Message(type='warning') 'Could not load customer groups', scoped to the section, never a toast. STOREFRONT: a role=alert line inside the one-time fallback fieldset when getPlans rejects."
render: "ADMIN: 'BC Customer Groups API is unavailable. You can still save — the plan will be available to all customers.' (fail-open). STOREFRONT: 'Subscribe options couldn't load — showing one-time purchase only. Refresh to try again.'"
recovery: "ADMIN: save proceeds fail-open (all customers); reload to retry. STOREFRONT: refresh to retry. NORTH-STAR (eligibility miss is NOT an error): an ineligible shopper should get the 'Exclusive to...' CTA, not a silent one-time-only collapse (see gaps)."
empty:
render: "ADMIN: when zero customer groups exist, info Message 'No customer groups found. The plan will be available to all customers.' STOREFRONT: when getPlans returns zero plans, the widget renders the one-time-only purchase fieldset — which is ALSO what an ineligible shopper sees, with no eligibility-specific copy (the gap)."
edge_status:
- status: "ADMIN: no groups selected (default)"
affordance: "'available to all customers' note — merchant checks specific groups to gate the plan."
- status: "ADMIN: BC Customer Groups API unavailable"
affordance: "Warning Message — save still works fail-open (all customers); reload to retry."
- status: "STOREFRONT: shopper is in a non-eligible customer group (AC1)"
affordance: "North-star: 'Exclusive to <group> members' card with an upgrade/learn-more CTA. Today: silently collapses to one-time-only (see gaps + defects)."
- status: "STOREFRONT: guest (unauthenticated) where the rule requires a group (AC2)"
affordance: "North-star: a sign-in CTA replaces the widget so the shopper can authenticate and re-evaluate eligibility. Today: silently collapses to one-time-only (see gaps + defects)."
inputs:
- field: "customer_group_ids"
control: "checkbox"
label: "ADMIN: 'Customer groups' — BigDesign Checkbox list, one per group (name + Default Badge)"
allowed_values: "customer groups from GET /v2/customer_groups (id -> checkbox value); empty -> null = all groups via toWireIds"
disabled_focus:
keyboard: "ADMIN: each group row is a real BigDesign Checkbox (native focusable input) in Tab order, Space toggles; wizard footer Buttons native-focusable; no div-onClick. STOREFRONT north-star: the 'Exclusive to...' sign-in/upgrade CTA is a real <a>/<button> in Tab order; today only the one-time-only fieldset controls are reachable."
guard: "ADMIN: toggling a group is non-destructive draft editing; persists on submit; no typed-confirm."
gaps: "Admin config is built (CustomerGroupPicker -> customer_group_ids). The STOREFRONT eligibility UX (AC1 hide widget / AC2 sign-in CTA / merchant-configurable 'Exclusive to Gold members') is absent: SubscriptionWidget has no customer-group eligibility branch, so an ineligible or guest shopper falls into the plans.length===0 one-time-only fallback with no eligibility copy and no conversion-recovery CTA — indistinguishable from a genuinely plan-less product."
US-26.6: Geographic & channel restrictions
<!-- traceability:start:US-26.6 --><!-- traceability:end:US-26.6 -->Prototype: Geo & Channel Restrictions
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin
As a Merchant Admin, I want to restrict a plan to specific countries, states, or BC channels, so that shipping/regulatory rules are never violated.
Acceptance criteria:
- Given a rule
allowed_countries: [US], When a shopper browses from outside the US (detected via IP/cookie or channel), Then the widget is hidden or shows a "Not available in your region" message.
UI states.
<!-- ui-states US-26.6 -->surface: "Two surfaces. ADMIN (built/partial, React/BigDesign): merchant restricts a plan by channel (ChannelPicker, persisted as channel_ids) and by country (CountryPicker — picker renders but selection is NOT persisted; see gaps) in ScopeStep.tsx (step === 'scope'). STOREFRONT (gap, Svelte): an out-of-region shopper should see a hidden widget or a 'Not available in your region' message — today SubscriptionWidget enforces neither geo nor channel; it renders the full subscribe widget when plans are returned, or the one-time-only fallback when not (see gaps + defects). Persona: Merchant Admin / Subscriber."
idle:
render: "ADMIN: 'Channels' Checkbox list (name + type Badge) + 'Countries' Checkbox list (COMMON_COUNTRIES, name + alpha-2 Badge), each with an 'all' note when nothing is selected. STOREFRONT north-star: an in-region/allowed-channel shopper sees the subscribe widget; an out-of-region shopper sees a 'Not available in your region' card (or nothing if the merchant chose 'hide')."
primary_action: "ADMIN: toggle channel/country checkboxes to restrict; channel_ids persists on submit (country does not — see gaps). STOREFRONT north-star: the 'Not available in your region' state is informational; no purchase affordance for restricted regions."
loading:
trigger: "ADMIN: fetchBcChannels() on ChannelPicker mount (countries are static, no fetch). STOREFRONT: getPlans({ bcProductId }) on SubscriptionWidget mount."
render: "ADMIN: 'Loading channels...' Small line while channels === null; the country list renders immediately (static). STOREFRONT: an aria-busy skeleton while plans load."
error:
surfaced_at: "ADMIN: inline BigDesign Message(type='warning') 'Could not load channels', scoped to the Channels section, never a toast. STOREFRONT: a role=alert line inside the one-time fallback fieldset when getPlans rejects."
render: "ADMIN: 'BC Channels API is unavailable. You can still save — the plan will be visible on all channels.' (fail-open). STOREFRONT: 'Subscribe options couldn't load — showing one-time purchase only. Refresh to try again.'"
recovery: "ADMIN: save proceeds fail-open (all channels); reload to retry. STOREFRONT: refresh to retry. NORTH-STAR (geo restriction is NOT an error): an out-of-region shopper should get the 'Not available in your region' message, not a silent fallback (see gaps)."
empty:
render: "ADMIN: zero channels -> info Message 'No active storefront channels found. The plan will be available on all channels.' STOREFRONT: getPlans returns zero -> one-time-only fieldset — also what an out-of-region shopper sees, with no region-specific copy (the gap)."
edge_status:
- status: "ADMIN: no channels / no countries selected (default)"
affordance: "'all channels' / 'available worldwide' note — merchant checks specific channels/countries to restrict."
- status: "ADMIN: BC Channels API unavailable"
affordance: "Warning Message — save still works fail-open (all channels); reload to retry."
- status: "STOREFRONT: shopper is outside the plan's allowed_countries (AC1)"
affordance: "North-star: 'Not available in your region' message replaces the widget (or it is hidden). Today: no geo check — full widget shows if plans are returned, else silent one-time-only (see gaps + defects)."
- status: "STOREFRONT: shopper is on a non-allowed channel"
affordance: "North-star: widget hidden on disallowed channels (per US-7.1 AC). Today: channel filtering is server-side only; the widget has no channel-mismatch fallback state."
inputs:
- field: "channel_ids"
control: "checkbox"
label: "ADMIN: 'Channels' — BigDesign Checkbox list (name + type Badge)"
allowed_values: "active storefront channels from GET /v3/channels; empty -> null = all channels via toWireIds"
- field: "country_codes"
control: "checkbox"
label: "ADMIN: 'Countries' — BigDesign Checkbox list (COMMON_COUNTRIES, name + alpha-2 Badge)"
allowed_values: "Static COMMON_COUNTRIES subset (US, CA, GB, AU, DE, FR, NL, IT, ES, SE, NO, DK, FI, NZ, SG, JP, MX, BR, IN, ZA); full ISO 3166-1 is Phase 3; empty -> null = all countries. NOTE: selection is not persisted today (see gaps)."
disabled_focus:
keyboard: "ADMIN: channel + country rows are real BigDesign Checkboxes (native focusable inputs) in Tab order, Space toggles; wizard footer Buttons native-focusable; no div-onClick. STOREFRONT north-star: the 'Not available in your region' card is static text (role=status); any 'notify me / change region' CTA is a real focusable control."
guard: "ADMIN: toggling channel/country is non-destructive draft editing; channel persists on submit, country does not (gap); no typed-confirm."
gaps: "Channel admin config is built+persisted (channel_ids). Country admin config renders but is NOT persisted (CreatePlanInput has no allowed_countries field; PlanWizard submit omits country) — same drop as US-7.3. STOREFRONT geo/channel enforcement is absent: SubscriptionWidget calls getPlans(bcProductId) with no region/channel context and has no 'Not available in your region' branch — an out-of-region shopper either sees the full widget (if the backend still returns plans) or the generic one-time-only fallback, never the AC-required region message. Country list is also a 20-entry static subset (Phase 3 = full ISO)."
US-26.7: Quantity min/max
<!-- traceability:start:US-26.7 --><!-- traceability:end:US-26.7 -->Prototype: Quantity Min / Max
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin
As a Merchant Admin, I want minimum and maximum quantities per subscription (e.g., min 2 units, max 10), so that I enforce MOQs or prevent abuse.
Acceptance criteria:
- Given
min_qty: 2, max_qty: 10, When a shopper attempts to subscribe with qty 1 or qty 11, Then the widget blocks the action with a clear message.
UI states.
<!-- ui-states US-26.7 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces. ADMIN (Merchant Admin, React/BigDesign): quantity constraint fields (min_qty, max_qty) on the plan create/edit form — a 'Quantity limits' FormGroup within the plan wizard or plan detail settings page. STOREFRONT (Subscriber, Svelte/Tailwind): the subscription widget's quantity selector is constrained by the plan's min_qty/max_qty; selecting a quantity outside the allowed range triggers an inline block message and disables the subscribe CTA. Persona: Merchant Admin (config) / Subscriber (enforcement)."
idle:
render: "ADMIN: 'Quantity limits' FormGroup with two BigDesign Inputs — 'Minimum quantity' (placeholder '1', default empty = no minimum) and 'Maximum quantity' (placeholder 'e.g. 10', default empty = no maximum). Help text: 'Leave blank for no limit. Shoppers cannot subscribe outside the specified range.' STOREFRONT: the widget shows a quantity selector (decrement Button, number Input, increment Button) pre-set to min_qty (or 1 when no minimum). The subscribe CTA is enabled when quantity is within [min_qty, max_qty]."
primary_action: "ADMIN: save min/max as part of the plan form submission. STOREFRONT: subscribe with the currently selected quantity — disabled at the widget level when quantity is out of range."
loading:
trigger: "ADMIN: the quantity fields have no async dependency — they render immediately. STOREFRONT: getPlans({ bcProductId }) on widget mount; plan's min_qty/max_qty arrive in the plan payload."
render: "ADMIN: no spinner — fields are available immediately. STOREFRONT: an aria-busy skeleton while the plan payload loads; once resolved the quantity control renders with enforced bounds."
error:
surfaced_at: "ADMIN: BigDesign Message(type='error') inline below the quantity fields when save fails with a validation error (e.g. min_qty > max_qty). STOREFRONT: an inline role=alert message directly below the quantity control when the selected quantity is outside the allowed range — never a toast, never a page-level banner."
recovery: "ADMIN: merchant corrects the values (ensure min ≤ max, both positive integers) and resubmits; form stays populated. STOREFRONT: the inline message states the allowed bound ('Minimum quantity is 2' or 'Maximum quantity is 10'); the shopper adjusts the quantity selector to a valid value which re-enables the subscribe CTA."
empty:
render: "Not a list surface. When no quantity constraints are set (both fields blank), the STOREFRONT widget shows a standard unconstrained quantity input (minimum 1, no maximum). The ADMIN fields display empty, which is a valid no-limit state communicated by the help text — no empty-state prompt or error is shown."
edge_status:
- status: "quantity below min_qty (STOREFRONT enforcement, AC)"
affordance: "Inline role=alert message 'Minimum quantity for this subscription is {min_qty}' below the quantity control; subscribe CTA is disabled. Decrement Button is disabled when quantity equals min_qty."
- status: "quantity above max_qty (STOREFRONT enforcement, AC)"
affordance: "Inline role=alert message 'Maximum quantity for this subscription is {max_qty}' below the quantity control; subscribe CTA is disabled. Increment Button is disabled when quantity equals max_qty."
- status: "min_qty > max_qty (ADMIN validation error on save)"
affordance: "Save blocked; BigDesign Message(type='error') 'Minimum quantity cannot exceed maximum quantity' below the fields; merchant corrects the values and resubmits."
inputs:
- field: "min_qty"
control: "text"
label: "'Minimum quantity' — BigDesign Input type=number, min=1, step=1"
validation: "Optional positive integer; when both are set must be ≤ max_qty."
- field: "max_qty"
control: "text"
label: "'Maximum quantity' — BigDesign Input type=number, min=1, step=1"
validation: "Optional positive integer; when both are set must be ≥ min_qty."
disabled_focus:
keyboard: "ADMIN: both quantity Inputs are real BigDesign Inputs (native focusable) in Tab order; no div-onClick. STOREFRONT: the decrement Button, quantity number Input, and increment Button are all native focusable elements in Tab order; the subscribe CTA is a real <button> reachable by Tab, activatable with Enter or Space. Focus stays on the quantity control area when the range error message appears — role=alert announces the constraint to screen readers without a focus move. The decrement/increment Buttons carry aria-label ('Decrease quantity' / 'Increase quantity') so their purpose is clear without adjacent visible label."
US-26.8: Mutual exclusion rules
<!-- traceability:start:US-26.8 --><!-- traceability:end:US-26.8 -->Prototype: Mutex & Dependencies
Phase: P3 · Priority: P2 · Effort: M · Persona: Merchant Admin
As a Merchant Admin, I want to express "cannot subscribe to A and B at the same time" rules, so that I prevent contradictory subscriptions (e.g., competing meal plans).
Acceptance criteria:
- Given a mutex rule between products A and B, When a shopper with an active A subscription tries to subscribe to B, Then they're blocked with "Already subscribed to a conflicting plan."
UX notes.
- Rules editor: "Subscribers to A cannot also be subscribers to B" — pairwise selector
- At checkout: if shopper attempts to add a mutex-conflicting subscription, BC cart validation returns an error with clear explanation
Data contract.
- Entity:
mutex_rules—{store_hash, product_set_a, product_set_b} - On add-to-cart validation: evaluate against subscriber's existing active subs + current cart intent
Success metrics.
- Functional: 100% mutex violations prevented at cart layer
- Product: merchant-reported reduction in conflicting-subscription support tickets
Dependencies.
- US-9.1 (cart validation hook)
UI states.
<!-- ui-states US-26.8 -->surface: "Merchant Admin (React/BigDesign) — mutual-exclusion (mutex) rule CRUD between product pairs. apps/admin/src/pages/plans/[id]/eligibility/index.tsx (MutexEligibilityPage, line 64). ROUTE-ORPHANED: the component is never imported or registered in App.tsx — the App.tsx:101-102 comment still claims 'plans/:id/eligibility ... have no component yet', and there is no SideNav entry — so the surface is unreachable today (see gaps). Persona: Merchant Admin. Backed by /api/v1/admin/eligibility/mutex-rules (GET/POST/DELETE). The AC's cart-layer block is a separate surface (US-9.1 cart validation hook), not this component."
idle:
render: "An H2 'Mutex Eligibility Rules' + an explanatory Text on rule symmetry (lines 119-124). An 'Add Rule' section (H3, line 127) with two BigDesign Inputs — 'Product A (BC product ID)' and 'Product B (BC product ID)', placeholders 'e.g. 100' / 'e.g. 200' (lines 131-147) — and an 'Add Rule' Button (lines 151-153). Below, an 'Active Rules' section (H3, line 163) with the rules Table (lines 167-195)."
primary_action: "Enter two distinct BC product IDs and submit the Form (handleAdd -> addMutexRule -> reload(), lines 85-103). Each rule row has a destructive 'Remove' Button (deleteMutexRule, lines 180-189)."
loading:
initial: "On mount reload() runs (line 83); while rules === null and no loadError a plain 'Loading…' Text renders (line 165) — no skeleton."
submit: "While submitting === true the 'Add Rule' Button shows isLoading (lines 151-153)."
remove: "While removingId === r.id that row's 'Remove' Button shows isLoading (lines 72, 180-189)."
error:
surfaced_at: "Two inline BigDesign Message(type=error) banners, never toasts. Add/validation failures render a Message below the Form (submitError, lines 156-158). Load and remove failures render a Message in the 'Active Rules' section (loadError, line 164). Client validation ('Both product IDs are required' line 90, 'Products must be different' line 91) and server failures (raw 'status: body' thrown by fetchJson, lines 40-43, surfaced line 99) appear in the submit Message."
recovery: "On a submit/validation error the Form stays populated and re-enabled (setSubmitting(false), line 101) so the merchant corrects the IDs and resubmits. Load/remove errors have NO in-app retry control — reload() runs only on mount (line 83), so the merchant must reload the page to retry."
empty:
render: "When rules.length === 0 a secondary Text 'No mutex rules configured.' renders (line 166) in place of the Table; the 'Add Rule' Form above it stays available so the merchant can add the first pair. Never a blank pane."
cta: "Add a product pair via the always-present 'Add Rule' Form (handleAdd, lines 85-103)."
inputs:
- field: "product_a_id"
control: "text"
label: "'Product A (BC product ID)' — BigDesign Input with label (lines 131-137)"
allowed_values: "n/a — an unbounded BC catalog product identifier, free-text today. NORTH-STAR: a product typeahead/selector that resolves names (see gaps)."
validation: "required + must differ from Product B (lines 90-91)."
- field: "product_b_id"
control: "text"
label: "'Product B (BC product ID)' — BigDesign Input with label (lines 141-147)"
allowed_values: "n/a — an unbounded BC catalog product identifier, free-text today. NORTH-STAR: a product typeahead/selector that resolves names (see gaps)."
validation: "required + must differ from Product A (lines 90-91)."
edge_status:
- status: "both_ids_required (client validation)"
affordance: "submit Message 'Both product IDs are required' (line 90); merchant fills both Inputs and resubmits."
- status: "products_must_differ (client validation)"
affordance: "submit Message 'Products must be different' (line 91); merchant changes one ID and resubmits."
- status: "add_rule_failed (server error / duplicate)"
affordance: "submit Message with the raw 'status: body' reason (lines 99, 40-43); merchant corrects the pair and resubmits."
- status: "rule active — pair present in the Table"
affordance: "each row shows Product A / Product B / Created and a destructive 'Remove' Button (deleteMutexRule, lines 180-189) to lift the mutex."
disabled_focus:
keyboard: "All controls are real BigDesign components over native focusable elements (Input/Button) — no div-onClick. Tab order follows DOM source order: Product A Input -> Product B Input -> Add Rule Button -> each row's Remove Button. Native disabled is not used on the Inputs; the Add Rule and Remove Buttons use BigDesign isLoading (non-activatable) to block double-submit while in flight (lines 151-153, 180-189)."
focus_move: "NO focus management on dynamic reveal: the submit/load error Messages (lines 156-158, 164) and the empty Text carry no role='alert' / aria-live and focus is never moved — a keyboard/screen-reader merchant gets no announcement when an add fails or the list reloads."
guard: "DESTRUCTIVE GAP: handleRemove (lines 105-115) deletes a mutex rule immediately with NO confirmation, unlike the sibling exclusion/inclusion lists which window.confirm before remove — a single keystroke/click can drop a rule irreversibly (see gaps)."
gaps: "1) ROUTE-ORPHAN (high): MutexEligibilityPage is fully built but never imported or routed in App.tsx (the App.tsx:101-102 comment falsely says no component exists) and has no SideNav entry — the entire surface is unreachable by merchants. 2) MISSING-CONTROL: the BRD US-26.8 UX note specifies a pairwise product selector, but the form takes raw BC product IDs as free text with no name lookup, so merchants must already know the IDs and cannot verify product names in-UI. 3) MISSING-GUARD: rule removal has no confirmation step (line 105). 4) error/empty Messages are not announced to assistive tech."
US-26.9: Conditional dependency rules
Phase: P3 · Persona: Merchant Admin
As a Merchant Admin, I want to require product X before allowing subscription to product Y (e.g., "Starter kit required before monthly refill"), so that I enforce product ladders.
Acceptance criteria:
- Given a rule
requires_prior_purchase: [starter-kit], When a shopper without prior purchase tries to subscribe to Y, Then they're redirected to the prerequisite flow.
UI states.
<!-- ui-states US-26.9 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces. ADMIN (Merchant Admin, React/BigDesign): prerequisite-purchase rule CRUD panel under the plan's Eligibility settings — merchant defines requires_prior_purchase rules that link a plan's availability to the shopper having previously purchased a specified product. STOREFRONT (Subscriber, Svelte/Tailwind): when a shopper without a qualifying prior purchase attempts to subscribe to the gated product, the widget suppresses the subscribe CTA and surfaces a prerequisite card pointing to the required product. Phase P3. Persona: Merchant Admin (config) / Subscriber (enforcement)."
idle:
render: "ADMIN: 'Prerequisite purchase rules' section with explanatory text ('Shoppers must have previously purchased the listed products before subscribing to this plan'). An 'Add rule' form with a 'Required product (BC product ID)' Input and an 'Add' Button. Below, a table of active prerequisite rules: Required product ID / Product name (resolved from BC catalog) / Created / Remove Button. STOREFRONT north-star: a shopper who meets all prerequisites sees the standard subscribe widget; a shopper missing one or more prerequisites sees a 'Get the starter kit first' prerequisite card with a direct link to the required product PDP — the subscribe CTA is replaced, not just disabled."
primary_action: "ADMIN: enter a BC product ID and click 'Add' to create the prerequisite rule. Each active rule row has a 'Remove' Button. STOREFRONT: the prerequisite CTA (link to the required product PDP) is the shopper's sole affordance until the requirement is met."
loading:
trigger: "ADMIN: GET /api/v1/admin/eligibility/prerequisite-rules on panel mount; product name resolution via the BC catalog after rules load (progressive enhancement — row shows the ID until name resolves). STOREFRONT: getPlans({ bcProductId }) + eligibility check on widget mount."
render: "ADMIN: 'Loading rules…' Text while rules === null; product names appear as the BC product ID until the BC catalog lookup completes. STOREFRONT: aria-busy skeleton while eligibility resolves."
error:
surfaced_at: "ADMIN: BigDesign Message(type='error') inline — load failure above the rules table, submit failure below the Add form — never a toast. STOREFRONT: a role=alert message inside the widget area if eligibility evaluation fails at runtime; the AC requires suppressing the subscribe CTA, so a network failure defaults to fail-closed (subscribe hidden, static error message shown) to prevent bypassing the prerequisite rule."
recovery: "ADMIN: load failure — reload page; submit failure — form stays populated and re-enabled, merchant verifies product ID in BC admin and resubmits. STOREFRONT fail-closed: static message 'Unable to verify eligibility — try refreshing the page'; shopper refreshes and the check re-runs."
empty:
render: "ADMIN: when no prerequisite rules exist, secondary Text 'No prerequisite rules — the plan is available to all shoppers regardless of purchase history' renders in place of the rules table; the Add form stays visible. STOREFRONT: no prerequisites configured means the standard subscribe widget renders with no eligibility gate."
edge_status:
- status: "shopper lacks a prior purchase of the required product (STOREFRONT enforcement, AC)"
affordance: "Prerequisite card with direct link to the required product's PDP replaces the subscribe CTA; shopper purchases the prerequisite, returns, and the widget re-evaluates on next page load."
- status: "multiple prerequisites — one or more missing (STOREFRONT compound rule)"
affordance: "Prerequisite card lists ALL missing required products with individual PDP links; shopper must purchase each before the subscribe CTA becomes available."
- status: "shopper has purchased all required products (STOREFRONT — eligible)"
affordance: "Standard subscribe widget renders; no prerequisite messaging shown."
- status: "BC product ID not found in catalog (ADMIN submit validation)"
affordance: "Submit Message 'Product not found in BC catalog (ID {n})' — merchant verifies the ID via BC admin and resubmits."
inputs:
- field: "required_product_id"
control: "text"
label: "'Required product (BC product ID)' — BigDesign Input with placeholder 'e.g. 100'"
validation: "Required positive integer; validated against BC catalog server-side on submit."
disabled_focus:
keyboard: "ADMIN: the product ID Input and 'Add' Button are real BigDesign components over native focusable elements in Tab order; each row's Remove Button is a real <button> — no div-onClick. STOREFRONT: the prerequisite CTA link/button is a real focusable element in Tab order (not a styled div); the subscribe CTA is absent (not merely disabled) when prerequisites are unmet so focus does not land on a non-actionable control. If the widget contains both a prerequisite card and other visible controls (e.g. one-time purchase option), Tab moves through them in natural DOM order."
US-26.10: Rule evaluation audit
<!-- traceability:start:US-26.10 --><!-- traceability:end:US-26.10 -->Prototype: Audit Tool
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin / Support
As Support / Ops, I want to see why a subscriber was allowed or denied eligibility for a plan, so that I resolve "why can't I subscribe?" tickets in seconds.
Acceptance criteria:
- Given a shopper was denied, When Support opens the audit tool and enters customer + plan, Then the tool shows: which rule evaluated which way, with input values visible.
UI states.
<!-- ui-states US-26.10 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) support diagnostic tool — eligibility rule evaluation audit panel. Support / Ops enters a customer email and selects a plan; the tool posts to the eligibility audit endpoint and renders a rule-by-rule results table showing which rule evaluated which way and the actual input values used for each. Persona: Merchant Admin / Support."
idle:
render: "A panel or page titled 'Eligibility audit' with subtitle 'Diagnose why a shopper was allowed or denied a subscription plan.' A two-field lookup form: 'Customer email' (BigDesign Input type=email, placeholder 'shopper@example.com') and 'Plan' (BigDesign Select, options loaded from active plans). A 'Run audit' primary Button (disabled until a valid email shape is entered and a plan is selected). Below the form, the results pane is blank on initial load — no pre-run state, no placeholder text."
primary_action: "'Run audit' — POSTs to /api/v1/admin/eligibility/audit with { customer_email, plan_id } and renders the rule-by-rule results table."
loading:
trigger: "Two async operations: GET /api/v1/admin/plans on mount (populates the Plan Select); POST /api/v1/admin/eligibility/audit on 'Run audit' submit."
render: "While plans load, the Plan Select is disabled showing a 'Loading plans…' placeholder option. While the audit runs, the 'Run audit' Button shows isLoading ('Auditing…') and both fields are disabled; the results pane shows an aria-busy spinner — no stale result from a prior run is visible."
error:
surfaced_at: "BigDesign Message(type='error') inline with role=alert, never a toast. Plan-load failure renders above the form ('Could not load plans — reload to try again'). Audit-run failure renders above the results pane ('Audit failed: {reason}') with the API error detail."
recovery: "Plan-load failure: reload page. Audit-run failure: form stays populated and re-enabled (customer email preserved); rep corrects the email or selects a different plan and resubmits. For customer-not-found, the Message text directs the rep to verify the email in BC admin."
empty:
render: "When the audit returns zero rules evaluated (no eligibility rules configured for the plan), the results pane shows a BigDesign Message(type='info') 'No eligibility rules are configured for this plan — all shoppers are eligible.' This is a valid, non-error result."
edge_status:
- status: "customer not found (POST 404)"
affordance: "Message 'Customer not found for email {email}' above results; rep corrects or verifies the email and re-runs the audit."
- status: "plan not found or inactive (POST 412)"
affordance: "Message 'Plan is not active and cannot be audited' above results; rep selects a different plan from the Select."
- status: "rule evaluated as DENY"
affordance: "Results table row for that rule shows a 'Denied' status badge (danger color) with the rule type (e.g. custom_field / customer_group / prerequisite) and the actual input value evaluated (e.g. customer_group_id=42, required=[vip]) — rep relays the specific reason to the shopper and guides them to resolve it."
- status: "rule evaluated as ALLOW"
affordance: "Results table row shows an 'Allowed' status badge (success color) with the evaluated input value; overall ALLOW is shown in a summary banner only when ALL rules evaluate as ALLOW."
inputs:
- field: "customer_email"
control: "email"
label: "'Customer email' — BigDesign Input type=email, placeholder 'shopper@example.com'"
validation: "Required; basic email shape client-side; resolved server-side by case-insensitive match scoped to store_hash."
- field: "plan_id"
control: "select"
label: "'Plan' — BigDesign Select pre-populated from GET /api/v1/admin/plans"
allowed_values: "All plans (active and inactive, since audit may be for a plan that was recently deactivated); option.value = plan.id, option.content = plan.name."
disabled_focus:
keyboard: "Customer email Input and Plan Select are real BigDesign components over native focusable elements — Tab moves through email → plan → 'Run audit' Button in DOM order. Plan Select is keyboard-navigable with arrow keys. While the audit runs, both fields are disabled (removed from tab order). Results table rows render as readable content; any per-row action links (e.g. 'View rule config') are real <a> or <button> elements in Tab order after the form."