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 7 — Scoping (channels, customer groups, Price Lists) (derived view)
Read-only per-epic slice of
BRD.md§9, lines 3179–3456. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-7.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (5): US-7.1, US-7.2, US-7.3, US-7.4, US-7.5
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 7 — Scoping (channels, customer groups, Price Lists)
<!-- traceability:start:BRD:Epic-7 --><!-- traceability:end:BRD:Epic-7 -->Prototype: Channels · Customer Groups · Regions · Coverage Matrix · Plan scoped to Price List
Value: A plan can be active only for specific channels, customer groups, or Price Lists — essential for multi-storefront and B2B merchants.
US-7.1: Plan scoped to specific channels
<!-- traceability:start:US-7.1 --><!-- traceability:end:US-7.1 -->Prototype: Channels
Phase: P2 · Priority: P0 · Effort: M · Persona: Merchant Admin
As a Merchant Admin running a Multi-Storefront setup, I want to enable a plan only on specific channels, so that my EU storefront doesn't show a US-only subscription.
Acceptance criteria:
- Given a plan has
channels: [1, 2], When a subscriber browses channel 3, Then the subscription widget does not render on that product's PDP. - Given a subscriber checks out through an allowed channel, When the subscription is created, Then the channel_id is stamped on the subscription.
UX notes.
- Plan wizard step "Scope": channel multi-select (default: all channels)
- Plan list: column "Channels" showing tags
- Coverage view: matrix of channels × plans for quick audit
Data contract.
- Our DB:
plans.channel_ids(int[] — BC channel IDs) - BC API:
GET /v3/channelsto populate the picker - Storefront widget: receives
channel_idon render (via BC storefront API context); filters plans accordingly
Success metrics.
- Functional: plan widget renders only on allowed channels (tested on multi-storefront stores)
- Product: Multi-Storefront merchant activation rate (benchmark during P2)
Dependencies.
- US-8.1 (widget must know channel_id at render time)
- BC Multi-Storefront feature availability
Non-functional.
- Channel list cached 1 hour; invalidate on
store/channel/*webhook
UI states.
<!-- ui-states US-7.1 -->surface: "Admin (React/BigDesign) plan wizard 'Scope' step — Channels section. Rendered by ChannelPicker inside ScopeStep.tsx, the 5th step ('scope') of PlanWizard.tsx (step === 'scope'). On mount ChannelPicker calls fetchBcChannels() -> GET /api/admin/bc/channels?type=storefront&status=active (proxy of BC GET /v3/channels). Multi-select checkbox list; empty selection = null = all channels. Persona: Merchant Admin (Multi-Storefront)."
idle:
render: "A 'Channels' FormGroup with help text ('Select which storefronts this plan appears on. Leave all unselected to enable on every channel'), then one BigDesign Checkbox per active storefront channel showing the channel name + a secondary Badge of the channel type. Checked state reflects value.channelIds; toggling a box calls toggleId -> onChange(toWireIds(...)). When nothing is checked, a 'No channels selected — plan will appear on all channels' note renders beneath the list."
primary_action: "Toggle channel checkboxes to restrict the plan; the wizard 'Continue' button (canAdvance is unconditionally true on the scope step — scope is optional) advances to Review. channel_ids persists at wizard submit (PlanWizard.tsx payload)."
loading:
trigger: "fetchBcChannels() on ChannelPicker mount (useEffect)."
render: "While channels === null and no error, a Small 'Loading channels...' line renders in place of the checkbox list. No checkboxes and no all-channels note are shown yet. No aria-live region (see gaps)."
error:
surfaced_at: "Inline BigDesign Message(type='warning') in place of the channel list, headed 'Could not load channels' — never a toast, scoped to the Channels section."
render: "Body text 'BC Channels API is unavailable. You can still save — the plan will be visible on all channels.' (fail-open per AC-9)."
recovery: "Save proceeds fail-open (the plan is unrestricted = all channels); to retry the channel fetch the merchant reloads the wizard — there is no in-section retry button. No role=alert/aria-live announce (see gaps)."
empty:
render: "When fetchBcChannels() returns zero active storefront channels, a BigDesign Message(type='info') renders: 'No active storefront channels found. The plan will be available on all channels.' The plan stays unrestricted."
edge_status:
- status: "BC Channels API unavailable (fetch rejected)"
affordance: "Warning Message explains save still works fail-open (all channels); reload the wizard to retry the fetch."
- status: "no storefront channels exist for the store"
affordance: "Info Message — plan defaults to all channels; merchant creates a channel in BC, then reopens the wizard."
- status: "no channels selected (default)"
affordance: "'plan will appear on all channels' note — merchant checks specific channels to restrict, or leaves as-is for store-wide availability."
inputs:
- field: "channel_ids"
control: "checkbox"
label: "'Channels' — BigDesign Checkbox list, one per active storefront channel (name + type Badge)"
allowed_values: "active storefront channels from GET /v3/channels (id -> checkbox value); empty selection normalizes to null = all channels via toWireIds"
disabled_focus:
keyboard: "Each channel row is a real BigDesign Checkbox wrapping a native focusable input, reachable in Tab order; Space toggles. The wizard Back/Cancel/Save-draft/Continue Buttons are native focusable elements. Tab order follows DOM source: channel checkboxes -> wizard footer buttons. No div-onClick dead-ends."
guard: "Toggling a channel is non-destructive (edits draft scope only); no typed-confirm. Selection persists only on wizard submit."
gaps: "The async loading->loaded transition and the warning/empty Messages are inserted with no role=alert / aria-live region and no programmatic focus move (ScopeStep.tsx imports no useRef, calls no .focus()), so a screen-reader merchant gets no announcement when channels finish loading or the fetch fails. Reachability is fine; the announce is the gap (same class as the US-22.1 documented gap)."
US-7.2: Plan scoped to customer groups
<!-- traceability:start:US-7.2 --><!-- traceability:end:US-7.2 -->Prototype: Customer Groups
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to offer a plan only to specific customer groups (e.g., VIPs), so that I gate exclusive pricing.
Acceptance criteria:
- Given a plan has
customer_groups: [vip], When a non-VIP subscriber reaches the PDP, Then the subscription widget does not render. - Given a VIP subscriber signs up and later loses VIP status, When the next renewal runs, Then the merchant setting determines behavior: grandfather / cancel / pause.
UI states.
<!-- ui-states US-7.2 -->surface: "Admin (React/BigDesign) plan wizard 'Scope' step — Customer groups section. Rendered by CustomerGroupPicker inside ScopeStep.tsx (step === 'scope' of PlanWizard.tsx). On mount it calls fetchBcCustomerGroups() -> GET /api/admin/bc/customer-groups (proxy of BC GET /v2/customer_groups). Multi-select checkbox list; empty selection = null = all customers. Persona: Merchant Admin."
idle:
render: "A 'Customer groups' FormGroup with help text ('Restrict this plan to specific customer groups. Leave all unselected to allow any customer to subscribe'), then one BigDesign Checkbox per group showing the group name + a primary 'Default' Badge on the store default group. Checked state reflects value.customerGroupIds; toggling calls toggleId -> onChange(toWireIds(...)). When nothing is checked, a 'No groups selected — plan will be available to all customers' note renders."
primary_action: "Toggle group checkboxes to gate the plan; 'Continue' advances (scope step is optional, canAdvance true). customer_group_ids persists at wizard submit (PlanWizard.tsx payload)."
loading:
trigger: "fetchBcCustomerGroups() on CustomerGroupPicker mount (useEffect)."
render: "While groups === null and no error, a Small 'Loading customer groups...' line renders in place of the checkbox list. No aria-live region (see gaps)."
error:
surfaced_at: "Inline BigDesign Message(type='warning') in place of the group list, headed 'Could not load customer groups' — never a toast, scoped to the Customer groups section."
render: "Body text 'BC Customer Groups API is unavailable. You can still save — the plan will be available to all customers.' (fail-open per AC-9)."
recovery: "Save proceeds fail-open (unrestricted = all customers); to retry the fetch the merchant reloads the wizard — no in-section retry button. No role=alert/aria-live announce (see gaps)."
empty:
render: "When fetchBcCustomerGroups() returns zero groups, a BigDesign Message(type='info') renders: 'No customer groups found. The plan will be available to all customers.' The plan stays unrestricted."
edge_status:
- status: "BC Customer Groups API unavailable (fetch rejected)"
affordance: "Warning Message explains save still works fail-open (all customers); reload the wizard to retry the fetch."
- status: "no customer groups exist for the store"
affordance: "Info Message — plan defaults to all customers; merchant creates a group in BC, then reopens the wizard."
- status: "no groups selected (default)"
affordance: "'available to all customers' note — merchant checks specific groups (e.g. VIP) to gate exclusive pricing, or leaves as-is."
inputs:
- field: "customer_group_ids"
control: "checkbox"
label: "'Customer groups' — BigDesign Checkbox list, one per group (name + Default Badge)"
allowed_values: "customer groups from GET /v2/customer_groups (id -> checkbox value); empty selection normalizes to null = all groups via toWireIds"
disabled_focus:
keyboard: "Each group row is a real BigDesign Checkbox wrapping a native focusable input, reachable in Tab order; Space toggles. Wizard footer Buttons (Back/Cancel/Save-draft/Continue) are native focusable. Tab order: group checkboxes -> footer buttons. No div-onClick dead-ends."
guard: "Toggling a group is non-destructive draft editing; no typed-confirm. Persists only on wizard submit. NOTE: the AC2 grandfather/cancel/pause-on-VIP-loss merchant setting is a renewal-engine behavior, not a control in this picker."
gaps: "Same a11y-announce gap as US-7.1: the loading/warning/empty Messages have no role=alert / aria-live and no focus move (no useRef/.focus() in ScopeStep.tsx). The 'subscriber loses VIP mid-subscription' AC2 (grandfather/cancel/pause) is governed by a separate merchant setting + renewal engine — it has no representation in this scope picker."
US-7.3: Plan scoped to geographic region
<!-- traceability:start:US-7.3 --><!-- traceability:end:US-7.3 -->Prototype: Regions
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to restrict a plan to specific shipping countries, so that I don't accept subscribers I can't fulfill.
Acceptance criteria:
- Given a plan has
allowed_countries: [US, CA], When a subscriber enters a shipping address outside the allowed list, Then checkout blocks with a clear message. - Given a subscriber moves internationally mid-subscription, When they update their address to a disallowed country, Then the subscription is paused and flagged for merchant review.
UI states.
<!-- ui-states US-7.3 -->surface: "Admin (React/BigDesign) plan wizard 'Scope' step — Countries section. Rendered by CountryPicker inside ScopeStep.tsx (step === 'scope' of PlanWizard.tsx). Restricts the plan to subscribers in specific billing-address countries via a checkbox list. PARTIAL: the picker renders and edits wizard state (value.countryCodes), but the selection is never persisted on save — see gaps."
idle:
render: "A 'Countries' FormGroup with help text ('Restrict this plan to subscribers in specific countries (based on billing address). Leave all unselected to allow any country') and a phase note ('Common countries shown. Full ISO 3166-1 list is a Phase 3 deepening'), then a wrapped Flex of BigDesign Checkboxes — one per entry in the static COMMON_COUNTRIES list (country name + alpha-2 code Badge). When nothing is checked, a 'No countries selected — plan will be available worldwide' note renders."
primary_action: "Toggle country checkboxes (toggleCode -> onChange(toWireCodes(...))) to restrict by country; 'Continue' advances. NORTH-STAR: on wizard submit the selected country codes persist to the plan as allowed_countries — today they do not (see gaps)."
loading:
trigger: "None — CountryPicker reads the synchronous static getCommonCountries() list."
render: "No loading state: the checkbox list renders immediately on step entry because the data is bundled, not fetched."
error:
surfaced_at: "No fetch today, so no load error. NORTH-STAR (when wired to a live ISO/locale source): an inline BigDesign Message scoped to the Countries section, never a toast (mirroring the channels/groups fail-open pattern)."
render: "North-star: 'Could not load the country list' with the offending detail. Until then the static list cannot fail to load."
recovery: "North-star: retry the country-list fetch in-section, or fall back to the static common list. Today there is no error path because the data is static and bundled."
empty:
render: "The static COMMON_COUNTRIES list is non-empty by construction, so the picker is never empty. NORTH-STAR (live source returning zero): an info Message 'No countries available — plan defaults to worldwide', mirroring the channels/groups empty pattern."
edge_status:
- status: "no countries selected (default)"
affordance: "'plan will be available worldwide' note — merchant checks specific countries to restrict, or leaves as-is."
- status: "needed country not in the 20-entry common list (Phase 3 scope limit)"
affordance: "North-star: full ISO 3166-1 picker covers it; today the merchant sets allowed_countries via direct API for countries outside the common subset."
- status: "country restriction not persisted on save (current defect)"
affordance: "North-star: selection saves as plans.allowed_countries and gates checkout (AC1) + pauses mid-sub address moves (AC2). Today the wizard selection is silently dropped — the merchant must set country scope via direct API (see gaps)."
inputs:
- field: "country_codes"
control: "checkbox"
label: "'Countries' — BigDesign Checkbox list, one per COMMON_COUNTRIES entry (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 (ISO 3166-1 alpha-2). Full ISO list is Phase 3. Empty selection -> null = all countries via toWireCodes."
disabled_focus:
keyboard: "Each country row is a real BigDesign Checkbox (native focusable input) in Tab order; Space toggles. The wrapped Flex layout does not change tab semantics. Wizard footer Buttons are native focusable. No div-onClick dead-ends."
guard: "Toggling a country is non-destructive draft-state editing; no typed-confirm."
gaps: "TWO gaps. (1) PERSISTENCE: CountryPicker edits value.countryCodes, but the PlanWizard submit payload sends only channel_ids + customer_group_ids and CreatePlanInput has no allowed_countries/country_codes field — so the merchant's country selection is silently dropped on save (the 'available worldwide' note still shows, masking the drop). (2) SCOPE: the country list is a curated 20-entry static subset, not full ISO 3166-1 (Phase 3). The checkout-block (AC1) and mid-subscription address-move pause (AC2) are backend behaviors with no subscriber-facing UI in this story."
US-7.4: Plan scoped to Price List
<!-- traceability:start:US-7.4 --><!-- traceability:end:US-7.4 -->Prototype: Plan scoped to Price List
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want a plan to respect which Price List applies to a customer, so that pricing is consistent with the rest of their catalog experience.
Acceptance criteria:
- Given a plan pricing strategy is "Price List" and a customer belongs to multiple Price Lists, When a charge computes, Then BC's native Price List resolution order is respected.
UI states.
<!-- ui-states US-7.4 -->surface: "Admin (React/BigDesign) plan wizard 'Scope' step — Price lists section. Rendered by PriceListPicker inside ScopeStep.tsx (step === 'scope' of PlanWizard.tsx). Intended to restrict the plan to customers on specific BC Price Lists. PARTIAL: today this is a static info-message placeholder — no picker, no GET /v3/pricelists call, onChange ignored. (Distinct from the pricing-strategy single price_list_id set in the wizard 'Pricing' step, which IS built and persisted via CreatePlanInput.price_list_id.)"
idle:
render: "A 'Price lists' FormGroup containing a single BigDesign Message(type='info'): 'Price list scoping is available — enter price list IDs directly via the API (price_list_ids)... A picker UI backed by GET /v3/pricelists will be added in Phase 3. For now, the plan is unrestricted by price list (null = all).' plus a Small 'TBC' note. No checkboxes, no selectable controls."
primary_action: "NORTH-STAR: a multi-select of the store's BC Price Lists (GET /v3/pricelists via an /api/admin/bc/pricelists proxy) whose selection persists as price_list_ids on submit. Today there is no actionable control — the merchant sets price_list_ids via direct API (see gaps)."
loading:
trigger: "NORTH-STAR: fetchBcPriceLists() on PriceListPicker mount. Today no fetch runs — the placeholder Message renders synchronously."
render: "North-star: a 'Loading price lists...' Small line in place of the picker (mirroring ChannelPicker). Today: no loading state — the static info Message renders immediately."
error:
surfaced_at: "North-star: inline BigDesign Message(type='warning') in the Price lists section, scoped to the section, never a toast (mirroring the channels/groups fail-open pattern)."
render: "North-star: 'Could not load price lists. You can still save — the plan will be unrestricted by price list.' Today there is no fetch, so no error path exists."
recovery: "North-star: save proceeds fail-open (all price lists); reload to retry. Today the only path is direct-API price_list_ids."
empty:
render: "North-star: when GET /v3/pricelists returns zero price lists, an info Message 'No price lists found. The plan will be available regardless of price list.' Today the placeholder info Message stands in for every state."
edge_status:
- status: "price-list picker not yet built (current state)"
affordance: "Info Message directs the merchant to set price_list_ids via the API directly; plan stays unrestricted by price list until then."
- status: "no price lists exist for the store (north-star)"
affordance: "Info Message — plan defaults to all price lists; merchant creates a Price List in BC, then reopens the wizard."
- status: "BC Price Lists API unavailable (north-star)"
affordance: "Warning Message — save still works fail-open (all price lists); reload to retry."
inputs:
- field: "price_list_ids"
control: "select"
label: "NORTH-STAR: 'Price lists' multi-select backed by GET /v3/pricelists; today rendered as a non-interactive info Message placeholder"
allowed_values: "North-star: the store's BC Price Lists from GET /v3/pricelists (id -> option). Empty selection -> null = all price lists. Not yet fetched (see gaps)."
disabled_focus:
keyboard: "Today the section contains only a static BigDesign Message + Small text — no interactive control to focus, so nothing in this section enters Tab order (the wizard footer Buttons remain reachable). NORTH-STAR: the price-list multi-select is a real focusable BigDesign control in Tab order, Space/arrow operable."
guard: "North-star: toggling a price list is non-destructive draft editing; no typed-confirm. Today there is no control to guard."
gaps: "PriceListPicker (ScopeStep.tsx) is a static info-message placeholder: it receives selected/_selected and onChange/_onChange but ignores both (underscore-prefixed, never called), makes no /api/admin/bc/pricelists call, and CreatePlanInput has no price_list_ids[] field — so scope-by-price-list cannot be set from the admin UI at all; the placeholder's 'use the API directly' instruction is the only path. NOTE: the pricing-strategy single price_list_id (US-7.4 AC1, BC native Price List resolution order) IS built and persisted via the wizard Pricing step (CreatePlanInput.price_list_id) — a different field from this scope restriction."
US-7.5: Scope audit view
<!-- traceability:start:US-7.5 --><!-- traceability:end:US-7.5 -->Prototype: Coverage Matrix
Phase: P2 · Persona: Merchant Admin
As a Merchant Admin, I want to see which plans apply to which channels / customer groups / regions, so that I catch gaps (e.g., no plan active in Canada).
Acceptance criteria:
- Given I open "Plan coverage" in the admin, When it loads, Then I see a matrix of channels × customer groups × countries with count of applicable plans per cell.
- Given a cell has zero plans, When I hover, Then I see a "Why?" explainer.
UI states.
<!-- ui-states US-7.5 -->surface: "Admin (React/BigDesign) per-plan coverage panel — apps/admin/src/pages/plans/Coverage.tsx (export const Coverage), routed by :id, fetching GET /api/v1/admin/plans/{id}/coverage via fetchCoverage (lines 56-69). Persona: Merchant Admin. Read-only view of which channels, customer groups, countries, and price lists a SINGLE plan is scoped to — NOT the aggregate cross-plan matrix the BRD AC describes (see gaps)."
idle:
render: "A page headed 'Plan Coverage' with a subtitle explaining 'null / all means no restriction' (Coverage.tsx:143-154), then a <Panel> of four DimensionRow rows (lines 174-192): Channels, Customer Groups, Countries, Price Lists. Each DimensionRow (lines 75-106) renders an 'all' secondary Badge when the dimension is null/empty, or one primary Badge per scoped value otherwise."
primary_action: "None — this is a read-only audit surface with no buttons, links, or inputs."
loading:
fetch: "While loading === true a 'Loading coverage…' <Small> renders (Coverage.tsx:156-163); fetchCoverage sets loading true on mount and false on resolve/reject (lines 121-135)."
error:
surfaced_at: "An inline BigDesign Message(type='error', header 'Could not load coverage') renders in place of the panel when the fetch fails (Coverage.tsx:165-172), carrying the HTTP status/body string from fetchCoverage (lines 64-66)."
recovery: "NORTH-STAR: the error state offers a 'Retry' Button that re-runs fetchCoverage plus a 'Back to plan' link. TODAY (gap): the error Message has NO retry control and the effect only re-fires on planId change (line 139), so a transient failure dead-ends the Merchant to a manual page reload."
empty:
render: "There is no separate page-level empty state — a plan always resolves to a coverage object, and an UNRESTRICTED dimension renders as an 'all' secondary Badge inside its DimensionRow (Coverage.tsx:80,94-95). So the at-rest 'empty' (a plan scoped to nothing specific) shows four rows of 'all' Badges rather than a blank pane."
cta: "n/a — to add scoping the Merchant configures the plan's Scope step in the plan wizard / plan edit (not actionable from this read-only view)."
edge_status:
- status: "scoped — a dimension is restricted to specific channels / customer groups / countries / price lists"
affordance: "DimensionRow renders the specific values as primary Badges (Coverage.tsx:96-101); to change the scoping the Merchant edits the plan's Scope step in the wizard / plan-edit (this view is read-only)"
- status: "unrestricted (all) — a dimension has no scoping (null/empty)"
affordance: "DimensionRow renders a single 'all' Badge (lines 94-95); to restrict it the Merchant configures scope in the wizard"
inputs: []
disabled_focus:
keyboard: "This surface renders NO interactive controls — only H2/Small/Panel/Text/Badge and (on failure) a non-dismissible Message (no onClose) — so there is nothing in the tab order to reach or trap; a keyboard user can read but cannot act, which is correct for a read-only audit view."
focus_move: "No dynamic focusable controls appear; the loading/error/panel swaps carry no aria-live, so a screen-reader Merchant is not announced when coverage loads or fails. NORTH-STAR: add a Tab-reachable 'Retry' <button> to the error state and a 'Back to plan' link, and wrap the result in an aria-live region."
gaps: "(1) PARTIAL vs BRD AC1: this shows a single plan's scoping, not the required 'matrix of channels x customer groups x countries with count of applicable plans per cell' — the cross-plan aggregate is acknowledged as a Phase 3 follow-up in the file header (Coverage.tsx:12-14) and is UNBUILT. (2) PARTIAL vs BRD AC2: the 'zero-plans cell with hover Why? explainer' is UNBUILT (no cell/tooltip concept exists in the single-plan view). (3) The error state is a dead-end — no retry control. (4) No in-page back/navigation affordance at all."