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 6 — Advanced subscription types (derived view)
Read-only per-epic slice of
BRD.md§9, lines 2491–3177. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-6.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (11): US-6.1, US-6.2, US-6.3, US-6.4, US-6.5, US-6.6, US-6.7, US-6.8, US-6.9, US-6.10, US-6.11
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 6 — Advanced subscription types
<!-- traceability:start:BRD:Epic-6 --><!-- traceability:end:BRD:Epic-6 -->Prototype: Gift · Prepaid · Build-a-Box · Membership · Bundle · Usage-Based · Curation · Referral
Value: Merchants can model gifts, bundles, build-a-box, prepaid, and usage-based subscriptions.
US-6.1: Gift subscriptions
<!-- traceability:start:US-6.1 --><!-- traceability:end:US-6.1 -->Prototype: Gift
Phase: P3 · Priority: P1 · Effort: XL · Persona: Merchant Admin / Subscriber (Gifter)
As a gift-giver, I want to purchase a subscription as a gift for someone else, so that they receive recurring deliveries on my dime.
Acceptance criteria:
- Given a subscribable product and a "Gift this subscription" entry, When I (a logged-in gifter) pick it and enter recipient info, Then I pay upfront for N cycles and the recipient receives a gift-reveal email.
- Given the gift recipient clicks the reveal link, When they claim the gift, Then a subscription is created in their BC customer account with no payment method required and cycles counting down from N.
- AC3 (amended — consent prompt): Given the recipient's claimed gift subscription, When it nears the end of its N prepaid cycles (configurable lead, default 1 cycle), Then the recipient is prompted (portal + email) to add a payment method to continue, with clear consent and the post-gift price disclosed.
- AC4 (convert): Given the recipient added a payment method during the gift window, When the Nth prepaid cycle completes, Then the subscription transitions to normal paid billing on their card with no interruption, and a
subscription.gift_convertedevent is emitted. - AC5 (lapse fallback): Given the recipient did NOT add a payment method, When the Nth prepaid cycle completes, Then the subscription ends (
status='cancelled',cancel_reason='gift_exhausted'), asubscription.gift_endedevent is emitted, and a final "your gift ended — reactivate anytime" invite is sent. No dunning/payment-retry emails fire for an exhausted gift.
UI states.
<!-- ui-states US-6.1 -->surface: '/gift/[token] — storefront recipient gift-claim landing (apps/storefront-svelte/src/routes/gift/[token]/+page.svelte mounts GiftClaimView.svelte)'
idle: 'Ready view (default): a 🎁 hero, heading "You’ve received a gift", body telling the recipient the deliveries are prepaid so no card is required, and a primary "Activate your gift" button. The claim fires only on explicit button click, never on page load (GiftClaimView.svelte:129-152).'
loading: 'Claiming view: the gift hero stays mounted while the same primary button switches its label to "Activating…", becomes disabled, and carries aria-busy=true for the duration of the POST to /api/v1/portal/gifts/[token]/claim (GiftClaimView.svelte:145-150).'
error:
surfaced_at: 'Generic retryable failure only (network / JSON-parse / non-mapped HTTP → GiftClaimReason "error"): the gift hero is replaced in place by a centered role="alert" block carrying a "!" glyph, the heading "Something went wrong", and the body "We couldn’t activate your gift just now. Please try again in a moment." (GiftClaimView.svelte:106-117; gift-claim.ts:36-59).'
render: 'Full-panel in-place swap (not a toast, not an inline field error) that replaces the hero; assertive aria-live via role="alert" announces it, and focus is moved onto the alert heading (GiftClaimView.svelte:77-79, 114).'
recovery: 'A "Try again" button — rendered only for the retryable "error" reason (ERROR_COPY.error.retry is true) — re-invokes activate() to re-POST the claim (GiftClaimView.svelte:118-127).'
empty:
render: 'Not a list surface — this is a single-action claim page, so there is no empty-collection state. The nearest "nothing to activate" case is an unknown or withdrawn token, surfaced through edge_status (invalid), not an empty state.'
edge_status:
- status: 'activated — claim succeeded (HTTP 200, GiftClaimResult.ok is true); an ACTIVE subscription is created in the recipient account with no payment method required'
badge: 'Activated'
affordance: 'A "Sign in to manage it" link to signInHref (default /subscriptions) sends the recipient to the magic-link portal to manage the new subscription (GiftClaimView.svelte:83-104).'
- status: 'claimed — the gift was already redeemed (HTTP 409 conflict → reason "claimed"); the single-use token is spent (#1754)'
badge: 'Already claimed'
affordance: 'Copy directs the recipient to contact the sender if the earlier claim was not them; no retry button (ERROR_COPY.claimed.retry is false), because re-POSTing cannot un-spend a single-use token (GiftClaimView.svelte:50-54).'
- status: 'expired — the claim window has closed (HTTP 412 precondition-failed → reason "expired")'
badge: 'Expired'
affordance: 'Copy directs the recipient to ask the sender to resend the gift; no retry button (ERROR_COPY.expired.retry is false) (GiftClaimView.svelte:45-49).'
- status: 'invalid — a mistyped, unknown, or withdrawn token (HTTP 404 notFound("gift") → reason "invalid")'
badge: 'Invalid link'
affordance: 'Copy directs the recipient to re-check the link in their email and reopen the correct URL; no retry button (ERROR_COPY.invalid.retry is false), since re-POSTing a bad token cannot succeed (GiftClaimView.svelte:40-44).'
disabled_focus:
keyboard: 'The single primary "Activate your gift" button is reachable by Tab from initial render (it is the only interactive control on the ready view). While claiming it is disabled and aria-busy=true, so it leaves the tab order for the brief in-flight window; the busy state is announced via aria-busy rather than silently dropping focus (GiftClaimView.svelte:142-150).'
focus_move: 'On reaching success or any terminal error state, focus is programmatically moved to the new outcome heading (tabindex="-1") via $effect, so a keyboard or screen-reader user is not stranded on <body> when the trigger button is removed (GiftClaimView.svelte:74-79, 91, 114).'
guard: 'The claim POST fires only on explicit button click, never on page load — GET renders, POST claims — so link-prefetchers and security scanners cannot silently burn the single-use gift (GiftClaimView.svelte:10-13, 62-66).'
Conversion is opt-in, never coercion (synthesis #1798). A gift is the highest-intent acquisition moment a merchant gets; converting the recipient at peak engagement turns a one-time order into a retained subscriber. The lever is the timing and framing of the consent ask (mid-gift, "keep it going") — never a silent auto-charge. Forced gift→paid trips negative-option-billing law (FTC Negative Option Rule; EU consumer-rights directives). Merchants MAY offer a configurable conversion incentive (e.g. "add a card now, get 15% off your first paid cycle") as the GMV dial. Build status: AC3 + AC5 (lapse-only delivery sweep) are near-term; AC4 (convert) is ratified as direction and gated on the shared payment-vaulting / add-a-card slice (which also unblocks the gift purchase form's new-card path).
UX notes.
- Entry: login-gated portal "Gift this subscription" flow for a logged-in gifter — recipient-info form (name, email, delivery date for reveal email, gift message). (The implemented purchase route is portal-authenticated; a PDP "Buy as gift" toggle pointing at a portal-auth endpoint would mis-promise, so the honest entry is the portal flow — synthesis #1798. A true unauthenticated PDP toggle is a separate future slice.)
- Checkout: special line-item treatment — gift lines pay upfront for all N cycles at the discounted rate
- Reveal email to recipient: branded email with product hero image, sender's gift message, "Activate your gift" CTA
- Recipient portal: dedicated "Claim gift" landing page that authenticates (magic link if new), creates BC customer, creates subscription
- Mid-gift consent prompt (AC3): near exhaustion (configurable lead, default 1 cycle), portal banner + email — "add a card to keep your deliveries going", post-gift price disclosed, opt-in only
Data contract.
- Our DB:
subscriptions.gift_from_customer_id(nullable). Countdown reuses the prepaid extension mechanism (cycles_remainingdriven by the US-6.2skip_advance+cycles_remaining--machinery), not gift-only columns — on claim, attach a prepaid-style extension (cycles_remaining=N) + setnext_charge_atso the recipient sub skip-delivers N cycles then hits the end-state (synthesis #1798). End-state isstatus='cancelled'+cancel_reason='gift_exhausted'(sibling of the existinggift_unclaimed_expired) — no new status value (thesubscriptionsstatus CHECK cannot be widened on D1 given its incoming FKs). - Payment: single charge at checkout for N × cycle_price; the giver pays once. Recipient delivery cycles are $0 — no charge row (the recipient has no payment method and
charges.payment_method_idis NOT NULL); the $0 delivery/order is materialized directly. - Events:
subscription.gift_purchased,subscription.gift_reveal_sent,subscription.gift_claimed,gift.expired_unclaimed,subscription.gift_ended(lapse, AC5),subscription.gift_converted(convert, AC4 — downstream)
Success metrics.
- Functional (target): 100% of gift purchases result in reveal emails sent within 15 min (or scheduled at the merchant-configured reveal time)
- Product (target): gift claim rate ≥ 80% within 14 days of reveal
- Operational: unclaimed-gift refund workflow runs correctly on merchant policy
Dependencies.
- US-6.2 (prepaid semantics are shared)
- US-17.1 (magic-link auth for recipients)
Non-functional.
- If the recipient never claims, merchant policy determines outcome: refund gifter at T+90 days, or retain as store credit
- Gift subscriptions never silently auto-renew: at prepaid-cycle exhaustion the sub continues to paid billing ONLY if the recipient gave explicit consent by adding a PM during the gift window (AC4); otherwise it ends (AC5). Silent gift→paid is an abuse vector and a negative-option-billing violation — prevented by the opt-in consent gate.
- An exhausted, non-converted gift produces ZERO dunning / payment-retry emails — the recipient never owed money, so the dunning path must not fire (enforced by the lapse-only sweep using a distinct gift type, not the prepaid renew-or-lapse path).
US-6.2: Prepaid fixed-term subscriptions
<!-- traceability:start:US-6.2 --><!-- traceability:end:US-6.2 -->Prototype: Prepaid
Phase: P1 (pulled forward 2026-05-16) · Priority: P0 · Effort: L · Persona: Subscriber
As a Subscriber, I want to pay upfront for 3, 6, or 12 months of deliveries at a discount, so that I commit once and don't think about billing again.
Acceptance criteria:
- Given a plan with prepaid options, When I check out with "6 months prepaid," Then a single charge collects the full period amount.
- Given the prepaid subscription is active, When a shipment cycle arrives, Then a BC order generates with
payment_status: capturedbacked by the already-collected prepaid charge. - Given the prepaid period ends, When the next cycle would occur, Then I am prompted to renew or let it lapse.
UI states.
<!-- ui-states US-6.2 -->surface: "Subscriber portal — read-only prepaid status panel (apps/storefront-svelte/src/lib/subscriptions/PrepaidPanel.svelte), rendered inside the subscription list (/subscriptions and /account/subscriptions via SubscriberPortalApp) and the detail view (/portal/subscriptions/[id] via SubscriptionDetailView). Not a form — the subscriber cannot edit prepaid state; it is managed by the merchant/engine."
idle: "Panel renders a header 'Prepaid subscription' plus a pill badge '{remaining} of {total} cycles left', body copy ('{consumed} cycles used — {remaining} remaining before any future billing. No charge until your prepaid balance runs out.'), and a progress bar with role=progressbar, aria-valuenow={remaining}, aria-valuemin=0, aria-valuemax={total} (PrepaidPanel.svelte:33-57). Data is embedded inline from the portal list response (prepaid_cycles_total + prepaid_cycles_remaining via the list.ts LEFT JOIN) — no panel-level fetch."
loading: "No panel-level loading state — the prepaid fields arrive inline on the subscription row, so the panel does not mount until the parent portal fetch resolves. The parent SubscriberPortalApp owns the spinner (loadingSubs && subscriptions.length === 0, SubscriberPortalApp.svelte:58,356); the panel shows nothing until its row exists."
error:
surfaced_at: "Parent portal fetch boundary — a failed getSubscriptions() sets SubscriberPortalApp error and the panel never mounts (SubscriberPortalApp.svelte:110-111,586). The panel itself has no error branch: partial or inconsistent prepaid data (one of prepaid_cycles_total / prepaid_cycles_remaining present, the other null) is silently dropped by the total != null && remaining != null guard (PrepaidPanel.svelte:27) instead of surfacing a notice."
render: "Parent portal renders the message in a role=alert error paragraph (SubscriberPortalApp.svelte:586-589). North-star for the panel: when prepaid fields are present but inconsistent, render an inline 'Prepaid status unavailable — contact support' notice rather than self-gating to nothing."
recovery: "Expired or unauthorized session routes to the magic-link re-auth prompt (SubscriberPortalApp.svelte:588-589); a transient fetch failure recovers on reload. Prepaid state is merchant/engine-managed and read-only, so a persistent data inconsistency routes the subscriber to support, never to an edit control."
empty:
render: "Not a list surface — a single status panel. The genuinely empty case is a subscription with no prepaid extension (prepaid_cycles_total / prepaid_cycles_remaining null or absent): the panel self-gates and renders NOTHING via {#if total != null && remaining != null} (PrepaidPanel.svelte:27). Intended — non-prepaid subscriptions show no prepaid panel."
edge_status:
- status: "active — prepaid cycles remaining (prepaid_cycles_remaining > 0)"
badge: "{remaining} of {total} cycles left"
affordance: "Read-only status; no per-cycle charge during the prepaid window. The subscriber acts on the underlying subscription through the parent portal Manage controls (pause / skip / cancel — ManagePanel.svelte, CancelSubscriptionButton.svelte), not from this panel."
- status: "depleted — final prepaid cycle consumed (prepaid_cycles_remaining === 0)"
badge: "Prepaid complete"
affordance: "NORTH-STAR (AC3): prompt to renew or let it lapse — an explicit 'Renew prepaid term' and 'Let it lapse' choice driven by subscriptions.auto_renew_behavior (renew_prepaid | switch_to_monthly | expire). GAP: the panel currently renders dead-end copy 'All prepaid cycles consumed. Your next renewal will be charged normally.' with no renew/lapse control (PrepaidPanel.svelte:42-43), and auto_renew_behavior is not yet on PortalSubscriptionRow (api-client.ts:34-36)."
- status: "cancelled during prepaid window (subscription.status === 'cancelled' with cycles still remaining)"
badge: "Cancelled — refund pending"
affordance: "NORTH-STAR: surface the merchant refund outcome (default prorated refund per BRD Non-functional) plus a re-subscribe path. GAP: the panel does not render a cancelled-during-prepaid state; cancellation is handled only by the parent portal cancel flow."
disabled_focus:
keyboard: "Today the panel exposes NO focusable controls — it is read-only, and the progress bar (role=progressbar, PrepaidPanel.svelte:51) is announced to AT via aria-label but is not in the tab order. NORTH-STAR: the depleted-state 'Renew prepaid term' and 'Let it lapse' buttons must be reachable via Tab in DOM order and operable with Enter and Space."
focus_move: "On actioning renew or lapse, move focus to the resulting confirmation/status text so AT announces the outcome. Currently N/A — the panel has no actionable controls."
guard: "Read-only states expose nothing to disable. NORTH-STAR: while a renew or lapse action is in flight, disable both buttons (mirror the parent's disabled={submitting} pattern, SubscriptionWidget.svelte) to prevent double-submit."
UX notes.
- PDP: "Pay upfront" toggle within the subscribe widget — shows options (3/6/12 months) with savings vs. month-to-month
- Checkout: shows "Today: $X (N months prepaid)" and "Then: auto-renew or expire"
- Portal: banner "N of M cycles remaining; X months left"
Data contract.
- Our DB:
subscriptions.prepaid_cycles_remaining,subscriptions.auto_renew_behavior(renew_prepaid|switch_to_monthly|expire) - Billing logic: initial charge at sign-up for total; each cycle generates a BC order with
payment_status: capturedbacked by the pre-collected payment; no processor call per cycle - Events:
subscription.prepaid_purchased,subscription.prepaid_cycle_consumed,prepaid.depleted
Success metrics.
- Functional: prepaid subscribers never experience dunning during the prepaid window
- Product: prepaid LTV ≥ 1.3× month-to-month LTV for merchants offering it (target; calibrate per category)
- Operational: BC order creation succeeds even though no fresh charge is made
Dependencies.
- US-14.1 (order creation must support "captured by prior prepaid charge" semantics)
- Accounting nuances: prepaid revenue recognition (P3 NetSuite integration)
Non-functional.
- Refund-on-cancel-during-prepaid is a merchant policy choice (full refund prorated / no refund); default = prorated refund
US-6.3: Build-a-box subscriptions
<!-- traceability:start:US-6.3 --><!-- traceability:end:US-6.3 -->Prototype: Build-a-Box
Phase: P3 · Priority: P1 · Effort: XL · Persona: Subscriber
As a Subscriber to a build-a-box plan, I want to pick N items from a curated set each cycle, so that I customize what I receive without exiting the subscription.
Acceptance criteria:
- Given a build-a-box plan with
box_size: 4,eligible_products: [set],cycle_customization: true, When I sign up, Then I pick my initial 4 items from the set. - Given my next shipment date is X days away, When a customization window opens (merchant-configured), Then I can modify my selection; after the window closes, selection locks.
- Given I don't customize, When the window closes, Then my previous cycle's selection rolls forward.
UI states.
<!-- ui-states US-6.3 -->surface: "/portal/subscriptions/[id] (BuildABoxPanel.svelte, mounted by SubscriptionDetailView.svelte)"
idle:
render: "Once the box-composition read resolves to a real build-a-box subscription within an open window, a grid of eligible products renders, each with a per-product minus/plus quantity stepper, and a running-total bar reading 'X of N selected'. The heading is 'Build your box' on first run, 'Your box' once a composition exists."
primary_action: "Save box (labelled 'Update box' once a composition exists) - enabled only when the selection sums to exactly box_size and differs from the saved box."
loading:
trigger: "GET /api/v1/portal/subscriptions/{id}/box-composition on mount (the self-gating read); PUT the same path on save."
render: "The mount read renders nothing until it resolves - the panel is fail-closed so non-box subscriptions never flash a card. While a save is in flight the Save button shows 'Saving...' and is disabled, and every minus/plus stepper is disabled (no double-submit)."
error:
surfaced_at: "Inline within the box panel, directly beneath the running-total bar and above the Save button (text-error), scoped to this subscription's box - never a page-level banner or a toast that vanishes."
render: "'We couldn't save your box. Please try again.' on a transient / network / 5xx failure; 'The customization window has closed for this cycle.' when a 409 fires and the re-read cannot confirm an open window."
recovery: "Retry - the Save button stays live and the steppers remain editable. On a 409 (the window closed between the read and the save) the panel re-reads the composition; window_open flips false and the view switches to the read-only locked box, so the user is never left retrying against a closed window."
empty:
render: "When the window is closed and no items were selected for the cycle, the read-only list renders the explicit line 'No items were selected for this cycle.' rather than a blank surface. The panel as a whole is fail-closed: a subscription with no box config renders nothing at all."
cta: "n/a - not a list-management surface; the eligible-products grid drives selection and the locked view is read-only."
inputs: []
edge_status:
- status: "window_closed - this cycle's customization window is closed (window_open is false)"
badge: "Customization closed"
affordance: "View the locked box read-only ('Customization for this cycle closed on {date}. Your saved box ships as-is.'); the saved box ships and rolls forward - customize again when the next window opens."
- status: "incomplete - the selection does not yet sum to box_size (window open)"
badge: "X of N selected"
affordance: "Use the minus/plus steppers to reach exactly N items ('{remaining} more to go' / 'Remove N to fit the box'); the Save button unlocks at the target, with 'Pick exactly N to save' shown until then."
- status: "no_changes - the selection is complete but identical to the saved box"
badge: "Box is complete"
affordance: "Save is disabled with 'No changes to save'; adjust an item via the steppers to re-enable Save, or leave it - the saved box already ships every cycle."
- status: "item_out_of_stock - an eligible item goes out of stock between save and shipment (BRD non-functional; NOT yet rendered by this component)"
badge: "Item unavailable"
affordance: "Swap the out-of-stock item for an eligible alternate, or accept the merchant-configured auto-substitution (north-star contract - see gapNotes)."
disabled_focus:
keyboard: "Every minus/plus stepper and the Save button is a real <button> reachable in tab order (never a div-onClick); disabled steppers and a disabled Save use the native `disabled` attribute so assistive tech skips them, and each stepper carries an aria-label ('Add one {name}' / 'Remove one {name}')."
focus_move: "No overlay traps focus; the running-total region is aria-live=polite so the count and completion ('Box is complete') are announced as quantities change, and the save-success line renders in place beneath the total."
guard: "Saving is non-destructive (a single intentional click, no typed-confirm); the steppers hard-stop at box_size (inc returns once the total hits the limit) and floor at zero, so an invalid composition can't be submitted."
UX notes.
- Plan config: merchant selects eligible products, sets
box_size, customization window (days before ship date) - Storefront/portal: grid of eligible products with quantity steppers; must sum to
box_size - Customization window: email sent T-N days with link to customize; countdown displayed in portal
Data contract.
- Our DB:
plans.box_size,plans.eligible_product_ids,plans.customization_window_days;subscriptions.current_box_composition(JSONB) - On each cycle: if composition is empty at lock time, use previous cycle's composition; lock composition at T-0
- Events:
box.customization_opened,box.customization_saved,box.locked,box.shipped
Success metrics.
- Functional: box composition always sums to exactly
box_size - Product (target): ≥ 60% of subscribers customize (indicating the feature is valued, not ignored)
- Operational (target): customization-email → save conversion ≥ 40%
Dependencies.
- US-15.1 (inventory check per box-item)
- Epic 19 preferences (allergen-aware curation)
Non-functional.
- Eligible-products list can be 100+ items; grid must virtualize for performance
- If an item goes OOS between composition save and shipment, fallback behavior: notify + suggest swap, or auto-substitute per merchant config
US-6.4: Bundle subscriptions
<!-- traceability:start:US-6.4 --><!-- traceability:end:US-6.4 -->Prototype: Bundle
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want to offer a fixed-bundle subscription (e.g., "Coffee + Creamer + Filters" at 15% off), so that I cross-sell related products in one recurring purchase.
Acceptance criteria:
- Given I configure a bundle plan with fixed composition, When a subscriber signs up, Then each cycle generates a single BC order with all bundle SKUs at the bundle discount.
- Given a bundle SKU becomes unavailable, When the renewal runs, Then the subscription is flagged into the exception queue with options: swap / skip / pause.
UI states.
<!-- ui-states US-6.4 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan wizard — fixed-composition bundle configuration step. Configures the set of SKUs, per-item quantities, and bundle discount that compose one recurring bundle order per billing cycle. Persona: Merchant Admin."
idle:
render: "A 'Bundle composition' wizard step showing: an 'Add product' typeahead search Input with an 'Add' Button to append a product row; a composition Table with columns Product (name + SKU), Quantity (number Input per row), and Remove (Button per row); a 'Bundle discount (%)' number Input applied uniformly to all line items; and a read-only 'Estimated cycle total' line recomputing from catalog prices minus the discount after each addition or discount change."
primary_action: "'Next' Button advances to the following wizard step; it is disabled until at least one SKU with quantity ≥ 1 is present. Per-row 'Remove' Button deletes the SKU from the composition."
loading:
trigger: "Product typeahead: GET /api/v1/admin/products?q={query} on each debounced keystroke. Plan save: PATCH /api/v1/admin/plans/{planId} on 'Next'."
render: "Typeahead: a loading indicator within the results dropdown while the search resolves. Save: 'Next' Button shows isLoading / 'Saving…' and is disabled; all composition Inputs and Remove Buttons are disabled."
error:
surfaced_at: "Save error: inline BigDesign Message(type='error') below the composition Table. Product search error: inline error text below the search Input."
render: "Save failure: API error string (e.g. 'At least one product is required for a bundle plan'). Search failure: 'Product search is unavailable — check your connection and try again.'"
recovery: "Save failure: the Table retains the current composition; the Merchant corrects (adds a SKU or adjusts quantity) and clicks 'Next' again. Search failure: the Merchant retypes the query to retry the search."
empty:
render: "When no SKUs have been added, the composition Table shows an empty-state row: 'No products in this bundle yet — search above to add the first item.' The 'Next' Button is disabled until at least one SKU with quantity ≥ 1 is present."
inputs:
- field: "product_search"
control: "text"
label: "'Add product' — typeahead search Input; results show product name, SKU, and base price; selecting a result appends a row to the composition Table"
- field: "item_quantity"
control: "number"
label: "'Quantity' — per-row number Input in the composition Table; minimum 1; required for each row"
- field: "bundle_discount_pct"
control: "number"
label: "'Bundle discount (%)' — applied uniformly to all line items; range 0–100; 0 = no discount; validated before save"
edge_status:
- status: "a bundle SKU becomes unavailable at renewal time (product deleted or deactivated)"
affordance: "The subscription is flagged into the exception queue; the exception queue UI presents per-subscription options for the admin: swap the unavailable SKU for an alternate, skip this cycle, or pause the subscription — per BRD AC2."
- status: "bundle discount set to 100% — free bundle"
affordance: "The 'Estimated cycle total' shows $0.00 and an advisory Message(type='warning'): 'A 100% discount makes this bundle free. Subscribers will not be charged for this cycle.' Save proceeds — 100% is a valid configuration."
- status: "only one SKU in the bundle"
affordance: "An advisory Message(type='warning') below the Table: 'Bundles typically include 2 or more products. You can continue with one SKU, but subscribers will receive only one item per cycle.' The Merchant can proceed."
- status: "composition changes on a plan with active subscribers"
affordance: "A warning Message(type='warning'): 'SKU changes apply from the next renewal cycle. In-flight orders for the current cycle are unaffected.' Merchant confirms and clicks 'Next' to proceed."
disabled_focus:
keyboard: "The product search Input, per-row quantity Inputs, bundle discount Input, per-row Remove Buttons, and the 'Next' Button are real BigDesign components inside FormGroups with label props — all reachable in DOM tab order, Enter/Space activatable. The product typeahead results panel traps focus while open and returns focus to the search Input on selection or Escape dismiss. Remove Buttons are native buttons, not div-onClick."
US-6.5: Membership subscriptions (access-based)
<!-- traceability:start:US-6.5 --><!-- traceability:end:US-6.5 -->Prototype: Membership
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want to offer a subscription that grants entitlements (discount access, content, community) without shipping physical goods, so that I capture recurring revenue on digital/intangible value.
Domain model (PRD-COMPANION D1, DECIDED 2026-04-23). Membership is implemented on top of the first-class Entitlement entity defined in PRD.md Section 8. Each membership plan configures one or more entitlement keys; the engine owns the PENDING → ACTIVE → SUSPENDED → REVOKED state machine; a pluggable provider adapter applies the external effect (v1: BC customer-group assignment).
Acceptance criteria:
- Given a "Membership" plan type with
entitlements: [...], When a subscriber signs up, Then oneEntitlementrow is created per configured key withstatus = pendingand no shipping address is required; no BC order generates at renewal (or generates a zero-line-item fulfillment order if merchant prefers). - Given the initial charge succeeds, When the
payment.succeededevent fires, Then each pending entitlement transitions toactive, the configured provider adapter runs (v1: customer-group assignment applied in BC), and anentitlement.grantedevent is emitted per entitlement within 5 minutes. - Given a renewal charge fails and the grace window exhausts, When the
dunning.grace_exhaustedevent fires, Then all active entitlements transition tosuspended, the provider adapter removes the external effect (customer-group removed), and anentitlement.suspendedevent is emitted. - Given a suspended entitlement's subscription recovers (payment succeeds after dunning), When
payment.succeededfires, Then entitlements transitionsuspended → activeand the provider adapter re-applies without re-granting a new row. - Given the subscription is cancelled and the cancel-grace window expires, When the
subscription.cancel_grace_expiredevent fires, Then entitlements transition torevoked, the provider adapter removes the external effect, and anentitlement.revokedevent is emitted. Revoked is terminal. - Given the entitlement-provider adapter interface, When a merchant or partner registers a non-BC adapter (SSO, feature-flag, license-server), Then entitlement state transitions invoke the adapter with the same contract as the v1 BC adapter — state machine and events are provider-agnostic.
Events emitted. entitlement.granted, entitlement.suspended, entitlement.reinstated, entitlement.revoked — each carrying { entitlement_id, subscription_id, key, status, provider, provider_external_ref }.
Non-goals. Granting an entitlement without a backing subscription (out of scope for v1 — handled by post-v1 "manual grant" tool). Cross-subscription entitlement aggregation (e.g., bundle granting N entitlements with conflicting grants) — rules of precedence deferred to v2.
<!-- normative-requirements US-6.5 - artifact: entitlement.reinstated kind: event fit: emitted when a suspended membership entitlement returns to active closes: grep:apps/api/src -->UI states.
<!-- ui-states US-6.5 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) plan edit page — 'Membership entitlements' configuration section (entitlement keys, provider adapter, BC customer-group binding). (2) Subscriber portal (Svelte/Tailwind) subscription detail — per-entitlement status chip display (active / suspended / revoked). Persona: Merchant Admin (configuration) / Subscriber (status view)."
idle:
render: "Admin — A 'Membership entitlements' Panel section in the plan edit form. An 'Add entitlement' form row has a free-text key Input (e.g. 'premium-access') and a Provider Select (v1: 'BC Customer Group'; future adapters listed as 'Coming soon' and non-selectable). When provider = 'BC Customer Group' a third Select appears: 'Customer group', populated from the store's BC customer groups. A Table below lists configured entitlements with columns Key, Provider, and Remove Button. Subscriber portal — An 'Entitlements' row within the subscription detail card showing a status Chip per entitlement key (green 'Active', amber 'Suspended', red 'Revoked') alongside the entitlement key label."
primary_action: "Admin: 'Add entitlement' Button appends the row to the Table; 'Save plan' commits all entitlement config. Portal: read-only display — no subscriber action on the entitlement row."
loading:
trigger: "Admin: BC customer groups: GET /api/v1/admin/bc/customer-groups when provider Select changes to 'BC Customer Group'. Plan save: PATCH /api/v1/admin/plans/{planId}. Portal: entitlement status included in the subscription detail GET payload on page load."
render: "Admin: the customer-group Select shows a 'Loading groups…' placeholder option while groups are fetched; 'Save plan' shows isLoading / 'Saving…' with all entitlement controls disabled. Portal: the entitlement row shows skeleton placeholder Chips while the detail payload loads."
error:
surfaced_at: "Admin: inline BigDesign Message(type='error') below the 'Membership entitlements' Panel header. Portal: if the subscription detail fails to load, an inline role='alert' block replaces the detail card."
render: "Admin: API error string (e.g. 'Entitlement key is required', 'Customer group not found'). Portal: 'Unable to load subscription details — please refresh.'"
recovery: "Admin: the entitlement Table stays populated; the Merchant corrects the key or group and resubmits 'Save plan'. Portal: a 'Refresh' button in the alert triggers a page reload."
empty:
render: "Admin: when no entitlements are configured the Table shows 'No entitlements configured yet.' and the 'Add entitlement' form is still visible above. Portal: when the subscription has no entitlements (non-membership plan) the 'Entitlements' row is omitted from the detail card entirely — no blank row renders."
inputs:
- field: "entitlement_key"
control: "text"
label: "'Entitlement key' — free-text, lowercase alphanumeric plus hyphens (e.g. 'premium-access', 'member-discount')"
- field: "entitlement_provider"
control: "select"
allowed_values: "bc_customer_group | sso | feature_flag | license_server"
label: "'Provider' — BigDesign Select; v1 only 'bc_customer_group' is active; others listed as 'Coming soon' and disabled"
- field: "bc_customer_group_id"
control: "select"
label: "'Customer group' — BigDesign Select of BC customer groups fetched live from the store; visible only when provider = 'bc_customer_group'; allowed_values are the store's actual BC customer group IDs and names"
edge_status:
- status: "entitlement status = active (payment current)"
affordance: "Subscriber portal shows a green 'Active' Chip; no action required from the subscriber."
- status: "entitlement status = suspended (dunning grace exhausted)"
affordance: "Subscriber portal shows an amber 'Suspended' Chip with helper text 'Your membership benefits are paused — update your payment method to restore access.' A 'Update payment' link routes to the portal payment-update flow."
- status: "entitlement status = revoked (subscription cancelled and cancel-grace window expired — terminal)"
affordance: "Subscriber portal shows a red 'Revoked' Chip with helper text 'Your membership has ended.' A 'Resubscribe' link routes to the product/plan purchase page. Revoked is terminal; no reinstatement action is available."
- status: "admin removes an entitlement key from a plan with active subscribers"
affordance: "A warning Message(type='warning') in the admin plan editor: 'Removing this entitlement key will revoke it for all active subscribers at their next renewal. This cannot be undone.' Merchant must check an 'I understand' Checkbox before 'Save plan' is enabled."
disabled_focus:
keyboard: "Admin: the entitlement key Input, provider Select (arrow-key navigable through the provider enum), customer-group Select (arrow-key navigable through store groups), 'Add entitlement' Button, per-row Remove Buttons, and 'Save plan' Button are real BigDesign components in FormGroups with label props — all in DOM tab order, Enter/Space activatable. The customer-group Select is conditionally rendered when provider = 'bc_customer_group', so it enters and leaves tab order with its visibility. Portal: the 'Update payment' and 'Resubscribe' links are native anchor elements reachable in tab order — never div-onClick."
US-6.6: Usage-based subscriptions
<!-- traceability:start:US-6.6 --><!-- traceability:end:US-6.6 -->Prototype: Usage-Based
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin
As a Merchant Admin, I want to bill subscribers based on measured usage (e.g., X data requests, Y minutes, Z print jobs), so that subscription pricing matches consumption.
Acceptance criteria:
- Given a usage-based plan with
meter_nameandprice_per_unit, When subscribers or their systems report usage via our API, Then usage accumulates per cycle. - Given the cycle closes, When the charge computes, Then the amount is
sum(usage) * price_per_unitplus any fixed base fee.
UI states.
<!-- ui-states US-6.6 -->surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) plan edit page — 'Usage-based billing' configuration section (meter_name, price_per_unit, optional fixed base fee). No subscriber-facing usage accumulation display exists in Phase 1. Persona: Merchant Admin."
idle:
render: "A 'Usage-based billing' Panel section within the plan edit form. Three fields: 'Meter name' text Input (e.g. 'api_requests', 'print_jobs'); 'Price per unit' currency number Input (e.g. 0.01); and 'Fixed base fee' currency number Input (optional; 0 = metered-only billing). A read-only formula note below the fields reads: 'Cycle charge = (units reported × price per unit) + base fee.' A 'How usage reporting works' link opens the relevant help docs. An introductory line reads: 'Configure metered billing for this plan. Leave all fields empty to use flat-rate billing.'"
primary_action: "'Save plan' Button commits the usage config alongside all other plan fields."
loading:
trigger: "PATCH /api/v1/admin/plans/{planId} on 'Save plan' click."
render: "'Save plan' shows isLoading / 'Saving…' and is disabled; all three usage Inputs are disabled during the request."
error:
surfaced_at: "Inline BigDesign Message(type='error') below the 'Usage-based billing' Panel header — not a page-level toast."
render: "API error string, e.g. 'Meter name is required when price per unit is set', 'Price per unit must be greater than 0', 'Meter name must be lowercase alphanumeric with underscores'."
recovery: "The form retains the Merchant's last input; they correct the offending field and click 'Save plan' again."
empty:
render: "When no usage config is set (all three fields null/empty), the Panel section renders with all Inputs empty and the formula note present — the section is never absent from the form. This is the default for all non-metered plans."
inputs:
- field: "meter_name"
control: "text"
label: "'Meter name' — lowercase alphanumeric plus underscores (e.g. 'api_requests'); uniquely identifies the meter reported via the usage-reporting API; required when price_per_unit is set"
- field: "price_per_unit"
control: "number"
label: "'Price per unit' — currency decimal (e.g. 0.01); must be > 0 when meter_name is set"
- field: "fixed_base_fee"
control: "number"
label: "'Fixed base fee' — optional currency decimal; added to the metered charge each cycle; 0 means metered-only"
edge_status:
- status: "meter_name set but price_per_unit is 0 or empty"
affordance: "The API returns a validation error; Message(type='error') shows 'Price per unit must be greater than 0 when a meter name is configured.' Merchant sets a valid price and retries."
- status: "plan type changed from flat-rate to usage-based on an existing plan with subscribers"
affordance: "A warning Message(type='warning'): 'Switching to usage-based billing applies from the next cycle. In-flight cycle charges already computed at the flat rate are unaffected.' Merchant must acknowledge with an 'I understand' Checkbox before 'Save plan' is enabled."
- status: "usage-based plan with no fixed_base_fee — subscriber reports zero usage in a cycle"
affordance: "The cycle charge is $0.00; the subscription remains active and renews normally. The admin plan detail shows the formula confirming a zero-usage cycle produces a $0.00 charge — no intervention is needed and no exception is raised."
disabled_focus:
keyboard: "The 'Meter name', 'Price per unit', and 'Fixed base fee' Inputs are real BigDesign components inside FormGroups with label props — reachable in DOM tab order; 'Save plan' Button is a real button activatable with Enter/Space. The 'How usage reporting works' link is a native anchor element. No div-onClick dead-ends."
US-6.7: Surprise-me / curation subscriptions
<!-- traceability:start:US-6.7 --><!-- traceability:end:US-6.7 -->Prototype: Curation
Phase: P1 (pulled forward 2026-05-16) · Persona: Subscriber
As a Subscriber, I want to receive a curated surprise product each cycle (chosen by the merchant), so that I get novelty without choosing.
Acceptance criteria:
- Given a "Curated" plan with merchant-configured rotation rules, When each cycle runs, Then the merchant-picked (or rule-selected) SKU is pulled for that cycle and included in the BC order.
- Given the subscriber has preferences (allergens, dislikes), When the curator picks, Then those preferences are honored (see US-19.5).
UI states.
<!-- ui-states US-6.7 -->surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) subscription detail — curated-subscription cycle view. Shows the merchant-picked SKU for the upcoming cycle and any active subscriber preference filters (allergens, dislikes) that shaped the curation. Persona: Subscriber on a 'Curated' plan."
idle:
render: "A 'This cycle's pick' card within the subscription detail panel. Shows the merchant-curated product image, name, and a short description. A 'Your preferences' section below lists active preference filters as read-only Chip tags (e.g. 'Nut-free', 'No cilantro'). A read-only note states the next shipment date. The merchant controls the SKU selection; no subscriber action changes the curated pick."
primary_action: "'Edit preferences' link routes to the subscriber preference editing page (US-19.5) where allergens and dislikes are managed."
loading:
trigger: "Subscription detail GET on page load; curated cycle SKU is included in the detail payload."
render: "While the detail payload is loading, the 'This cycle's pick' card shows skeleton placeholder blocks for the product image, name, and preference Chips."
error:
surfaced_at: "If the subscription detail fails to load, an inline role='alert' block replaces the detail card. If the cycle SKU is absent from a successfully-loaded detail (not yet assigned for the upcoming cycle), the product slot renders the empty state copy rather than an error."
render: "Load error: 'Unable to load subscription details — please refresh.' Missing SKU: see empty state."
recovery: "Load error: a 'Refresh' button in the role='alert' block triggers a page reload. Missing SKU: no subscriber action needed — the merchant will assign a SKU before the cycle closes; the card updates on next page load."
empty:
render: "When no SKU has been assigned for the upcoming cycle yet, the product image/name slot shows: 'The merchant is still curating this cycle's pick — check back soon.' The preferences section and 'Edit preferences' link remain visible."
inputs: []
edge_status:
- status: "upcoming cycle SKU matches subscriber preferences — curation honored"
affordance: "A Chip tag 'Preference honored' appears on the product card confirming the merchant respected the subscriber's filters. No subscriber action required."
- status: "upcoming cycle SKU conflicts with a subscriber preference — preference could not be honored this cycle"
affordance: "A Chip tag 'Note: your preference for [X] could not be honored this cycle' appears on the product card with a muted explanatory note. A 'Contact support' link routes to the merchant support path for the subscriber to escalate."
- status: "subscription is paused — no cycle pick available"
affordance: "The 'This cycle's pick' card renders a paused-state note: 'Your subscription is paused — resume to receive cycle picks.' A 'Resume subscription' button triggers the portal resume flow."
- status: "subscription is cancelled — no future cycle picks"
affordance: "The 'This cycle's pick' card is not rendered for cancelled subscriptions; the subscription detail shows the cancellation summary and a 'Resubscribe' link routing to the product/plan page."
disabled_focus:
keyboard: "The 'Edit preferences' link is a native anchor element reachable in tab order. Preference Chips are read-only <span> elements — non-interactive, not in tab order. The 'Resume subscription' button is a real <button> activatable with Enter/Space. The 'Contact support' and 'Resubscribe' links are native anchors. The 'Refresh' button in the error state is a real <button>. No div-onClick dead-ends; visible focus rings apply to all interactive elements per the portal's global Tailwind focus-visible styles."
US-6.8: Referral subscriptions
<!-- traceability:start:US-6.8 --><!-- traceability:end:US-6.8 -->Prototype: Referral
Phase: P1 (pulled forward 2026-05-16) · Persona: Subscriber
As an existing Subscriber, I want to refer friends and earn credit on my subscription, so that I'm incentivized to grow the merchant's business.
Acceptance criteria:
- Given I have an active subscription, When I access my portal, Then I see a referral link with my unique code.
- Given a referred user signs up through my link, When their first charge succeeds, Then I earn merchant-configured credit applied to my next charge.
- Given the referral is fraudulent (self-referral, chargebacks), When detection triggers, Then the credit is reversed and logged.
UI states.
<!-- ui-states US-6.8 -->surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) — referral panel within the account subscriptions page, visible only to subscribers with at least one active subscription. Shows the unique referral link, referral code, earned credit balance, and fraud-reversal status. Persona: Subscriber."
idle:
render: "A 'Refer a friend' card showing: (1) a unique referral URL in a read-only text Input with a 'Copy link' Button that copies it to the clipboard and briefly replaces its label with 'Copied!' as inline feedback; (2) the referral code displayed inline (e.g. REF-ABC123); (3) 'Earned credit' — a currency amount with a note 'Applied automatically to your next charge'; (4) a 'How it works' collapsible section (Disclosure pattern) explaining mechanics: friend signs up via the link → first charge succeeds → credit is applied to the referrer's next charge."
primary_action: "'Copy link' Button — copies the unique referral URL to the clipboard."
loading:
trigger: "Referral data is included in the subscription detail GET on page load."
render: "While the detail payload loads, the referral panel shows skeleton placeholder blocks for the referral URL, referral code, and credit amount."
error:
surfaced_at: "If the referral data is absent from the detail payload, an inline role='alert' note replaces the referral panel content."
render: "'Referral information is temporarily unavailable — please refresh.'"
recovery: "A 'Refresh' button in the role='alert' block triggers a page reload to re-fetch the detail payload."
empty:
render: "When the subscriber has no earned credit yet (credit_balance = 0.00), the 'Earned credit' line shows '$0.00 earned so far' with a motivational note: 'Share your link — you earn [merchant-configured amount] when a referred friend's first charge succeeds.' The referral link and code are still shown; the card is never hidden for an active subscriber with a generated code."
inputs: []
edge_status:
- status: "credit earned — at least one successful referral charge has completed"
affordance: "The 'Earned credit' amount updates to reflect the balance; a note confirms 'Applied automatically to your next charge.' No subscriber action is required — credit application is automatic."
- status: "credit reversal — fraud detected (self-referral or chargeback)"
affordance: "The 'Earned credit' line shows $0.00 with a 'Credit reversed' warning Chip and helper text: 'This referral was flagged and the credit has been removed. Contact support if you believe this is an error.' A 'Contact support' link routes to the merchant support path."
- status: "referral pending — referred user signed up but first charge has not yet succeeded"
affordance: "A 'Pending' Chip appears in a referral history row: 'Waiting for your friend's first payment to confirm your credit.' Credit is not added to the balance until the referred charge succeeds."
- status: "subscription is not active — referral panel is suppressed"
affordance: "The 'Refer a friend' card is not rendered for paused or cancelled subscriptions. The subscriber sees the standard paused/cancelled detail with resume or resubscribe affordances per their current status."
disabled_focus:
keyboard: "The 'Copy link' Button is a real <button> reachable by Tab and activatable with Enter/Space — never a div-onClick. The referral URL text Input is read-only (selectable but not editable). The 'How it works' collapsible toggle is a real <button> or <summary> element so the section is keyboard-openable and closeable. The 'Contact support' link is a native anchor. The 'Refresh' button in the error state is a real <button>. All interactive elements have visible focus rings per the portal's global Tailwind focus-visible styles."
US-6.9: AllotmentGrant — admin-granted recurring quota / wallet (PRD-COMPANION D18)
Phase: P3 · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want to grant a customer (or buyer-org) a recurring quota that refreshes on cadence (e.g., $50/month wellness credit, 4 shred-pickups/quarter), so that I can model loyalty / B2B-contract / employee-benefit flows that are independent of any paid subscription.
Acceptance criteria:
- Given I create an
AllotmentGrantwithunit_type,amount_per_period,refresh_cadence,rollover_policy, When I issue it to a customer or buyer-org, Then a grant row is created withcurrent_balance = amount_per_periodandnext_refresh_atset per cadence. - Given the grant is
active, When the subscriber places an order or the system applies a charge offset, Then anAllotmentDebitis recorded against the grant andcurrent_balancedecrements; debits link optionally to BC orders or our Charges. - Given
next_refresh_atarrives, When the refresh job runs, Thencurrent_balanceis reset perrollover_policy(none: replace withamount_per_period;cap_to_one_period: max of current + new vsamount_per_period;accumulate: add). - Given a grant is revoked, When the admin confirms, Then
status='revoked', no further debits accepted, an audit Event recordsgranted_by+reason.
Data contract. allotment_grants + allotment_debits per PRD §8.1. Distinct from Entitlement (D1) — Entitlements are derived from paid subscription state; AllotmentGrants are independent admin-issued quota.
Dependencies. US-22.7 (CS-tools admin grant/revoke UI). Optional integration with subscription-charge offset workflow (Epic 14).
UI states.
<!-- ui-states US-6.9 -->surface: "Admin (React/BigDesign) SubscriptionAdminDetail (apps/admin/src/pages/subscriptions/SubscriptionAdminDetail.tsx) — deep-linked AllotmentGrant detail. allotmentGrantId is read from the ?allotment_grant_id= query param (searchParams.get, line 229) and threaded into the embedded CsActionsPanel (lines 779-784), which renders the grant card (CsActionsPanel.tsx lines 548-625). Persona: Merchant Admin / Subscriber."
idle:
render: "The subscription detail renders the standard summary; the 'Allotment grant' Panel appears ONLY when an allotment_grant_id is present, showing a status Badge, current_balance + unit_type, amount_per_period, next_refresh date, and refresh_cadence (CsActionsPanel.tsx lines 558-585) plus a debit-history Table (lines 594-608)."
primary_action: "'Revoke grant' is the only state-changing affordance (CsActionsPanel.tsx lines 610-618), shown when status is not 'revoked'."
loading:
trigger: "Detail: fetchDetail(id) on mount. Grant card: GET /api/v1/admin/cs/allotments/{allotment_grant_id} on mount (CsActionsPanel.tsx useEffect lines 215-223)."
render: "While the detail data is null a plain loading Text renders (SubscriptionAdminDetail.tsx lines 366-372); within the grant card, grantLoading renders a 'Loading grant detail…' Text (CsActionsPanel.tsx lines 550-552). No skeletons."
error:
surfaced_at: "Detail-level failure renders a BigDesign Message(type=warning) with a back-to-list Button (SubscriptionAdminDetail.tsx lines 351-364). Grant-load failure renders a Message(type=error) inside the 'Allotment grant' Panel (CsActionsPanel.tsx lines 553-555)."
recovery: "The detail error offers a 'back to subscriptions' link to re-navigate; the grant Message has no in-card retry control — the rep reloads the ?allotment_grant_id= deep-link URL."
empty:
render: "With no ?allotment_grant_id= present the 'Allotment grant' Panel does not render at all — there is no per-customer grant listing and no create/issue-grant entry point, so an admin without a grant id sees only the standard subscription detail. BRD AC1 (create a grant with current_balance = amount_per_period) has no UI surface (gap)."
edge_status:
- status: "grant active"
affordance: "the 'Revoke grant' destructive Button is shown (CsActionsPanel.tsx lines 610-618)."
- status: "grant revoked"
affordance: "the Revoke Button is hidden and the revocation reason is shown (lines 587-592); north-star is to issue a NEW grant to restore quota, but the issue flow is not built (gap)."
- status: "grant suspended"
affordance: "north-star is a Resume action returning the grant to active; suspend/resume is not built (gap) — only Revoke exists."
- status: "no debit history"
affordance: "the debit-history Table is omitted (guard line 594); balance + status still render so the surface is not a dead-end."
inputs: []
disabled_focus:
keyboard: "All controls are real BigDesign components reachable in DOM tab order and activatable with Enter/Space — the back-link Button, the 'Revoke grant' Button, and the revoke modal's reason Textarea / Confirm / Cancel — no div-onClick. The BigDesign Modal traps focus while open and restores it on close."
gaps: "BRD AC1 (create/issue AllotmentGrant) is entirely unbuilt — no grant form, no per-customer grant listing; the panel only renders an already-issued grant via ?allotment_grant_id=. The subscriber-side allotment balance/wallet display is also absent (the revoke modal copy at line 651 only states the portal balance 'will be hidden'). Suspend / manual-refresh / override-balance actions are missing — only Revoke exists. The status Badge renders the raw enum grantDetail.status (line 564). Grant-load failure has no in-card retry and no role=alert."
US-6.10: Multi-actor subscription — payer / beneficiary / manager distinct from owner (PRD-COMPANION D19)
<!-- traceability:start:US-6.10 --><!-- traceability:end:US-6.10 -->Prototype: Multi-Actor Subscription Detail · Delivery Schedule (cadence ≠ billing)
Phase: P1 (pulled forward 2026-05-16) · Persona: Merchant Admin / Subscriber
As a Merchant Admin, I want a subscription to track distinct actors (owner, payer, beneficiary, manager, org_admin), so that gift / managed / B2B / marketplace flows have first-class semantics for who pays, who consumes, who can manage.
Acceptance criteria:
- Given a subscription is created in any flow (self-serve, gift, CS-rep enrollment, B2B), When the subscription is persisted, Then an
actorsrow is written for at least theownerrole;payer.roleis unique-per-subscription (partial-index enforced). - Given a subscriber is the
payerbut not thebeneficiary, When the renewal MIT charges, Then the payer'spayment_method_refis used and thebeneficiaryreceives the delivery / entitlement notification (notification routing reads role enum). - Given a
manageractor exists (CS-rep on B2B subscription, parent on family plan), When dunning escalates, Then the manager is cc'd on the dunning email pernotification_prefs. - Given a gift subscription is converted to an actor model (US-6.1), When the gift is claimed, Then the recipient becomes
payer + beneficiary; the gifter becomesowner(historical attribution); reveal-claim flow remains unchanged.
Data contract. actors table per PRD §8.1. Role enum: owner | payer | beneficiary | manager | org_admin. Reserved column processor_connection_ref is null in Phase 1, populated at v2 marketplace MoR (ADR-0022).
Dependencies. US-17 (portal role-based view). US-19 (PM-update permission keyed off payer). ADR-0023 (B2B manager role semantics).
UI states.
<!-- ui-states US-6.10 -->surface: "NOT YET BUILT — forward-looking contract. Two surfaces: (1) Merchant Admin (React/BigDesign) subscription detail page — 'Subscription actors' Panel listing the distinct role holders (owner, payer, beneficiary, manager, org_admin) with per-role notification routing. (2) Subscriber portal (Svelte/Tailwind) subscription detail header — simplified role-aware 'Paid by / Delivered to' display when payer and beneficiary differ from the logged-in owner. Persona: Merchant Admin / Subscriber."
idle:
render: "Admin — A 'Subscription actors' Panel on the subscription detail page. A Table lists each role defined on the subscription with columns: Role (Badge, e.g. 'owner', 'payer', 'beneficiary', 'manager', 'org_admin'), Customer (name + email linked to BC customer record), and Notification routing (dunning / delivery routing indicator: 'primary', 'cc', or 'off'). The Panel is read-only in Phase 1 — roles are written at subscription creation; no admin mutation affordance is shown. Portal — The subscription detail header shows 'Paid by: [payer name]' and 'Delivered to: [beneficiary name]' only when payer and beneficiary differ from the logged-in subscriber; for standard single-actor subscriptions these lines are omitted."
primary_action: "Admin: no primary action in Phase 1 — the actors Panel is read-only. Portal: no actor-management action for subscribers in Phase 1."
loading:
trigger: "Actor data is included in the subscription detail GET payload on page load for both admin and portal."
render: "Admin: while the detail payload loads, the actors Panel shows skeleton placeholder rows for each expected role. Portal: while the detail loads, the 'Paid by / Delivered to' header lines show placeholder text."
error:
surfaced_at: "Admin: if the subscription detail fails to load, a BigDesign Message(type='warning') with a 'Back to subscriptions' Button replaces the page. If the actors data is absent from a successfully-loaded detail, the actors Panel renders an inline Message(type='error'): 'Actor information could not be loaded.' Portal: if the subscription detail fails to load, an inline role='alert' block replaces the detail card."
render: "Admin detail error: the API error string. Admin actor-load error: 'Actor information could not be loaded — reload the page to try again.' Portal: 'Unable to load subscription details — please refresh.'"
recovery: "Admin detail error: the 'Back to subscriptions' Button navigates away. Admin actor-load error: no in-panel retry — the rep reloads the page. Portal: a 'Refresh' button in the role='alert' block triggers a page reload."
empty:
render: "When only the 'owner' role is present (standard self-serve subscription with no gift, B2B, or manager actors), the actors Panel renders a single-row Table showing owner = subscriber with dunning and delivery notifications routed to them. A note reads: 'This subscription has one actor. Gift, B2B, and managed subscriptions add additional roles at creation.' The Panel is never hidden when only one actor is present."
inputs: []
edge_status:
- status: "payer and beneficiary are distinct actors (gift or B2B scenario)"
affordance: "Admin Panel shows two separate Table rows for payer and beneficiary with their respective customer emails. The notification routing column shows 'primary' on the payer row for dunning emails and 'primary' on the beneficiary row for delivery/entitlement notifications. Admin can view routing but cannot reassign roles in Phase 1."
- status: "manager actor present (B2B or family plan)"
affordance: "Admin Panel shows the manager row with 'cc' in the notification routing column; a tooltip on the 'cc' Badge reads 'Manager is cc'd on dunning escalations per notification_prefs.' No action is available to change cc routing in Phase 1 — the Panel is read-only."
- status: "org_admin actor present (buyer-org B2B subscription)"
affordance: "Admin Panel shows the org_admin row with an 'Org admin' Badge; a muted read-only field notes 'processor_connection_ref: reserved for Phase 2 marketplace MoR' with a tooltip explaining it will be populated when the org payment rail is configured."
- status: "gift subscription — recipient claimed; roles assigned post-claim per US-6.1 AC4"
affordance: "Admin Panel shows gifter as 'owner' (historical attribution) and recipient as 'payer + beneficiary' (dual-role Badge). A note below the Table reads: 'This gift was claimed on [claimed_at date]. The recipient is now the payer and beneficiary.' Admin can view this attribution but cannot alter it in Phase 1."
disabled_focus:
keyboard: "Admin: the actors Table is read-only in Phase 1 — no editable inputs; all interactive elements are the 'Back to subscriptions' Button (on error), customer-name anchor links in the Table, and any tooltip trigger buttons — all real focusable elements in tab order activatable with Enter/Space. Portal: the 'Paid by / Delivered to' lines are read-only text with no interactive elements; the 'Refresh' button in the error state is a real <button>. No div-onClick dead-ends."
US-6.11: Custom-field configuration on a plan (PRD-COMPANION D20)
Phase: P3 · Persona: Merchant Admin
As a Merchant Admin, I want to define typed custom fields per plan / per subscription / per allotment grant (po_number text required, delivery_window enum, fund_code text), so that prospect-specific data needs (PO numbers, accounting attribution, delivery preferences) are captured and validated without schema changes.
Acceptance criteria:
- Given I configure a
CustomFieldDefinition(scope, key, type, required, validation regex, subscriber visibility), When I save, Then the field appears in the corresponding admin / storefront UI immediately. - Given a
requiredfield is unset on a subscription, When the subscription is created or edited, Then the API returns 422 with the field key in the error body. - Given a field is
editable_by_subscriber=true, When the subscriber views the portal, Then the field renders as editable; otherwise read-only or hidden pervisible_to_subscriber. - Given a field is archived, When future admin / storefront UI renders, Then the field is hidden but existing data on subscriptions is preserved.
Data contract. custom_field_definitions table per PRD §8.1. Field data lives on parent entity's metadata.custom_fields JSONB key.
Dependencies. US-22.8 (admin-edit of custom-field data on subscriptions). Plan Wizard surface integration for field configuration.
UI states.
<!-- ui-states US-6.11 -->surface: "Admin (React/BigDesign) plan custom-field definition editor — routed at /plans/:planId/custom-fields by App.tsx:105 → PlanCustomFieldsPage from apps/admin/src/pages/plans/PlanCustomFields.tsx (App.tsx import line 48). Persona: Merchant Admin. Add / list / archive typed CustomFieldDefinitions (key, type, required, subscriber-visibility) per plan via /api/v1/admin/custom-fields."
idle:
render: "A page headed 'Custom Fields — Plan {planId}' with a '< Back to Plan' subtle Button (PlanCustomFields.tsx:147-150). An 'Add Field' <Form> (lines 156-206) with a Key <Input> (HTML required, description 'Lowercase letters, numbers, underscores'), a Label <Input> (HTML required), a Type <Select> and a Subscriber Visibility <Select>, plus an 'Add Field' submit Button. Below, a 'Defined Fields' <Table> (lines 212-245) with columns Key (code), Label, Type (Badge), Required (Yes/No), Visibility, and a 'Remove' Button per row."
primary_action: "'Add Field' submit → handleCreate POSTs the definition (lines 99-132) then reloads the table; 'Remove' → handleDelete DELETEs by id (lines 134-143) then reloads."
loading:
list: "While definitions === null and there is no loadError, a 'Loading…' <Text> renders under 'Defined Fields' (PlanCustomFields.tsx:210)."
submit: "While saving === true the 'Add Field' submit Button shows isLoading (line 203); handleCreate sets saving true/false around the POST (lines 102/130)."
error:
surfaced_at: "Two inline BigDesign Message(type='error') banners at the top of the page (PlanCustomFields.tsx:152-153): loadError (list fetch failure) and actionError (create/delete failure). These are page-level banners, not scoped to the failing row."
recovery: "actionError: the add Form stays populated after a failed create (only success resets the fields, lines 121-125) so the Merchant corrects (e.g. a duplicate key) and resubmits; a failed delete can be retried via the same 'Remove' Button. loadError NORTH-STAR: a 'Retry' control re-runs reload(). TODAY (gap): no retry button — recovery is '< Back to Plan' or a page reload (reload() only re-fires on planId change, line 97)."
empty:
render: "When the plan has no definitions (definitions?.length === 0) a 'No custom fields defined yet.' <Text color='secondary50'> renders and the Table is suppressed (PlanCustomFields.tsx:211) — the 'Add Field' form is still shown above."
cta: "The always-present 'Add Field' form is the empty-state CTA."
edge_status:
- status: "create failed — e.g. duplicate field_key or backend validation 4xx"
affordance: "actionError Message shows the API error string (lines 117-119, 153); the populated form is retained so the Merchant edits the key/label/type and resubmits"
- status: "delete (archive) failed"
affordance: "actionError Message shows the failure (lines 138-141, 153); the row stays and the Merchant retries via 'Remove'"
- status: "archived definition — a field is removed/archived"
affordance: "the DELETE archives it and reload() drops it from the table while existing subscription data is preserved server-side (handleDelete lines 134-143; BRD AC4)"
inputs:
- field: "field_key"
control: "text"
allowed_values: "n/a free text; description constrains to lowercase letters/numbers/underscores (PlanCustomFields.tsx:161-168) but only HTML `required` is enforced client-side — no pattern/regex validation"
- field: "field_label"
control: "text"
allowed_values: "n/a free text (lines 173-179); HTML required"
- field: "field_type"
control: "select"
allowed_values: "text | number | boolean | select (FIELD_TYPE_OPTIONS, PlanCustomFields.tsx:56-61)"
- field: "subscriber_visibility"
control: "select"
allowed_values: "hidden | read | write (VISIBILITY_OPTIONS, PlanCustomFields.tsx:63-67)"
- field: "required"
control: "checkbox"
allowed_values: "true | false — NORTH-STAR control. TODAY (gap): the routed form renders NO control for this; `required` is initialised false and only reset, never settable from the UI (see gaps), even though the model + the 'Required' table column expect it."
disabled_focus:
keyboard: "The Key/Label <Input>s, Type/Visibility <Select>s, the 'Add Field' submit Button, the per-row 'Remove' Buttons, and the '< Back to Plan' Button are all real BigDesign components wrapping native focusable elements inside FormGroups with label props — Tab-reachable in DOM order, Enter to submit the form, Enter/Space to activate buttons; no div-onClick dead-ends."
focus_move: "After a create/delete the table reloads and the error/success state changes, but focus is not moved and the Message banners carry no aria-live/role=alert, so a screen-reader Merchant is not announced the outcome."
gaps: "(1) MISSING required control: the routed PlanCustomFields.tsx 'Add Field' form has no input bound to setRequired (it imports no Checkbox; setRequired is only the initial useState(false) at line 82 and the post-success reset at line 125), so a Merchant CANNOT create a required field even though `required` is sent in the POST body (line 114) and shown in the table (lines 222-225). (2) ROUTE-ORPHAN divergence: a more complete second implementation also named PlanCustomFieldsPage lives at apps/admin/src/pages/plans/[id]/custom-fields.tsx (a Required <Select>, select-type options input, and a live regex validator) but it is NOT imported/routed — App.tsx wires the less-complete PlanCustomFields.tsx. (3) UNBUILT portal side (BRD AC3): subscriber_visibility='write' is meant to render the field as subscriber-editable in the storefront portal, but no consumer of that visibility exists in apps/storefront-svelte — forward-looking, not yet built. (4) Error banners are page-level, not scoped to the failing row."