Skip to content

Catalog and plans

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/catalog-and-plans.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What catalog-and-plans is for

A plan's variant-eligibility restriction and its Price-List pricing strategy must never be trusted to enforce or re-price against a subscriber's actual chosen variant on any production request path — because today, neither does. handleStorefrontPlansList, the endpoint ADR-0028 §1 designates as the single storefront enforcement chokepoint, never reads plan_variant_eligibility or filters on bc_variant_id. And subscriptions.bc_variant_id is never written by any subscription-creation path, so the price_list pricing strategy's renewal-time repricing guard is permanently false for every real subscriber. A merchant who configures a variant-restricted, Price-List-priced plan believing both mechanisms are live gets neither: any variant can subscribe regardless of the restriction, and every renewal silently carries forward the cycle-0 charge amount instead of re-pricing against the Price List. That is the invariant this page exists to make visible.

This domain owns the plan model itself, the admin plan wizard, plan lifecycle/deactivation, and the storefront entry point — everything a merchant configures to turn a BC product into a subscribable offer. It does not own trial mechanics or checkout-time discount snapshots (see trials-and-intro-offers.md) or campaign promotions and charge-time combination (see promotions-and-discounts.md).

Seven features, each anchored to its build story for traceability:

  • "Subscriptions" panel on any BC product — a merchant sees a plan-status panel (none/draft/active/paused) directly on the BC product edit page via an App Extension PANEL, with no context switch out of BC admin (US-4.1)
  • Six-step plan-config wizard — Intervals → Pricing → Options → Eligibility → Scope → Review, so a merchant configures a subscription offer without learning a complex form (US-4.2)
  • Choose which variants are subscribable — a merchant restricts a plan to a subset of a product's variants, defaulting to "all eligible" (US-4.3)
  • Deactivate a plan without disrupting current subscribers — stop new signups, or (destructively) end all subscriptions at next cycle / cancel immediately, with an EU/EEA notice-period gate on the immediate-cancel path (US-4.4)
  • Copy a plan's configuration to other products — avoid repeating the wizard across a large catalog (US-4.5)
  • Three pricing strategies per plan — a fixed discount off catalog price, a BC Price List binding that re-reads at each renewal, or a flat fixed amount (US-4.2 / US-5.2 / US-15.2)
  • Plan scoping by channel, customer group, country, and Price List — a merchant restricts a plan's storefront visibility to a specific multichannel/B2B/geo/negotiated-pricing segment without duplicating plans (Epic 7, US-7.1–7.5)

The decisions that carry the most weight

1. Nullable JSON-array scoping columns, not join tables. channel_ids/customer_group_ids/country_codes/price_list_ids are each a single nullable TEXT column on plans (null = all), filtered in-memory by applyScopeFilters after one D1 fetch, rather than four join tables requiring four LEFT JOINs on every storefront request (ADR-0047 §"Why not a separate join table?"). The tradeoff is explicit: a join-table model would need a sentinel row per dimension to express "no restriction," which collapses once a store mixes restricted and unrestricted plans in the same query.

2. Single-chokepoint storefront enforcement — by design, not yet in practice for variants. Every storefront-facing eligibility and scoping check is designed to live at one endpoint, handleStorefrontPlansList, so every storefront widget inherits the same enforcement without per-client duplication (ADR-0028 §1). The invariant above is exactly where that design intent and current reality diverge — see "Where intent and reality diverge" below.

3. Renewal repricing branches on pricing_strategy, not on a flag. scheduler.ts:1621-1720 re-reads pricing at every renewal for non-locked price_list and discount_percent plans; fixed_price and locked plans never do. price_list re-fetches the BC Price List entry for the subscriber's variant, discount_percent re-reads the current catalog price and reapplies the discount, and neither branch fires when lock_price_at_creation=1 (BRD §US-5.6 takes precedence) or the strategy is fixed_price (the persisted amount carries forward unchanged by design).

4. Checker-module extension pattern for new eligibility rule types. Each rule type — custom-field, qty-bounds, geo, customer-group — is a self-contained module under services/eligibility/ with an explicit failOpen/failClosed type-level marker, registered as a chain step; BC-dependent lookups fail open, D1-dependent blocklists fail closed (ADR-0048 §1, §4; ADR-0028 §3).

How it actually works

Read the deciding record before the code, not after. The canonical-framing attestation on this domain (_input-b/catalog-and-plans.md) traced two independent variant-scope mechanisms in this codebase — plans.bc_variant_id (a scalar) and plan_variant_eligibility (a multi-row allow-list) — and found that ADR-0028's single-chokepoint design does not, today, carry a variant dimension at all. If you read the wizard UI or the eligibility-engine module first, you will conclude variant restriction is enforced at checkout. It is not, on any production storefront request.

The storefront entry point. A merchant activates a plan on a BC product edit page via an App Extension PANEL registered on ORDERS, PRODUCTS, and CUSTOMERS at install time (routes/install.ts:15-35). From there the six-step PlanWizard (apps/admin/src/pages/products/PlanWizard.tsx) walks Intervals → Pricing → Options → Eligibility → Scope → Review. The Eligibility step (EligibilityStep, PlanWizard.tsx:580+) fetches the product's BC variants and lets the merchant pick a subset when eligibilityScope='restricted'; the wizard's own canAdvance guard (PlanWizard.tsx:243-247) requires at least one variant selected in that case, with a comment stating "the order-create webhook's variant-eligibility check treats [an empty allow-list] as 'no variants eligible' and would fail every signup." No such check exists in the order-create webhook — see the first typed delta below; the comment describes an enforcement path that was never wired.

What the wizard persists, and where. submit() (PlanWizard.tsx:262+) sends eligible_variant_ids when the scope is restricted. The backend persists it to the plan_variant_eligibility join table via upsertPlanVariantEligibility — a DELETE then per-variant INSERT (db.ts:1617-1627) — and reads it back with listPlanEligibleVariants (db.ts:1637-1645). Grepping every call site of listPlanEligibleVariants across apps/ turns up exactly three non-definition hits: the write path itself, epic-04-failure-injection.test.ts, and routes/admin/plans/copy.ts:199 — the copy-plan feature (US-4.5), carrying the eligibility subset forward to a new product, not enforcing it against a shopper.

The storefront chokepoint itself. handleStorefrontPlansList (lines 980-1141) is the endpoint every storefront widget calls to discover subscribable plans for a product. Its full query-param list — store_hash, bc_product_id, channel_id, customer_group_id, country_code, price_list_id (plans.ts:991-997) — carries no variant identifier. It fetches plans with listPlans(repo, store_hash, { bc_product_id }) (plans.ts:1085), then narrows with applyScopeFilters (plans.ts:876-975) on channel, customer-group, country, and price-list — never on variant. The scope filter runs in-memory after one D1 fetch, an explicit ADR-0047-acknowledged tradeoff bounded by the assumption that a product typically carries 1–3 plans.

The variant-aware check that does exist, and where it actually runs. checkSubscriptionEligibility (services/eligibility-engine.ts:50) filters on plan.bc_variant_id — the scalar column, not the plan_variant_eligibility join table — as part of a rule chain (mutex → dependency → custom-field → qty-bounds). Its own module header states plainly that "product-scoped rules... are enforced upstream in handleStorefrontPlansList" — which, as traced above, has no variant handling to enforce them with. Grepping production call sites of checkSubscriptionEligibility (excluding tests) finds exactly two: the admin diagnostic GET /api/v1/admin/eligibility/audit (ADR-0048 §5) and qty-bounds-checker.ts's internal use of a different check. Zero call sites in routes/storefront-plans.ts, routes/webhooks.ts, or any apps/storefront-svelte source.

Renewal-time repricing. For a pricing_strategy='price_list' plan without a locked price, scheduler.ts:1627 gates the repricing branch on subscription.bc_variant_id !== null. That column exists on subscriptions (schema.sql:1126) but NewSubscription (db.ts:614-639) has no bc_variant_id field, and createSubscription's INSERT column list (db.ts:1105-1120) omits it — structurally impossible to set through the only insertion point for subscription rows. The order-webhook path that creates subscriptions from a real BC order (routes/webhooks.ts::createSubscriptionsFromOrder) never extracts a variant id from the order line item to pass through. The one place bc_variant_id appears near a subscription-scoped action is the per-delivery-instance swap_product override (routes/subscriptions.ts:1021,1057,1126), which writes a delivery instance's JSON override_payload — a different table entirely.

erDiagram
    plans {
        TEXT id PK
        INTEGER bc_product_id
        INTEGER bc_variant_id
        TEXT pricing_strategy
        INTEGER price_list_id
        TEXT price_list_ids
        TEXT channel_ids
        TEXT customer_group_ids
        TEXT country_codes
    }
    plan_intervals {
        TEXT id PK
        TEXT plan_id FK
        TEXT interval
        INTEGER interval_count
    }
    plan_variant_eligibility {
        TEXT plan_id PK
        INTEGER bc_variant_id PK
    }
    plans ||--o{ plan_intervals : "plan_id"
    plans ||--o{ plan_variant_eligibility : "plan_id"

Diagram provenance. Excerpt of the canonical, code-sourced docs/architecture/data-model-erd.md (3 of ~85 tables — plans, plan_intervals, plan_variant_eligibility, with plans itself trimmed to the columns this domain's invariant and mechanism concern; every other column and table is omitted). This source carries no sign_off field; its own staleness marker is as_of_commit: 80fc35f4, staleness_threshold_days: infinite (mechanically regenerated from schema.sql, CI-drift-gated). This is one transcluded source, not a hand-drawn fork.

No sequence diagram exists for the plan-activation → App-Extension-panel → wizard-submit flow, or for the price_list renewal-repricing branch this domain's invariant concerns. docs/architecture/sequence-diagrams.md has no plan-lifecycle-specific excerpt as of this trace — a documentation gap in its own right, the same pattern the build-a-box and promotions-and-discounts domains independently found in theirs.

Where intent and reality diverge

The derived coverage matrix (_coverage-matrix.json) reports US-4.1, US-4.2, US-4.3, and US-4.4 all at terminal_gate: "G4", g4_status: "pass"; US-7.1–7.5 and US-26.3 likewise pass. That is true, and — as with every domain in this corpus — it is not the whole truth. Six honest deltas, each typed:

1. Superseded-framing residue — two independent variant-scope mechanisms, neither wired to the enforcement chokepoint. plan_variant_eligibility (the wizard's multi-select variant picker, US-4.3) and plans.bc_variant_id (a single-scalar column) are structurally different, non-interoperating representations of "which variants can subscribe." Only the scalar is read by checkSubscriptionEligibility, and neither is read by handleStorefrontPlansList. apps/api/test/scenarios/epic-26-eligibility.scenario.ts:30-33's own header comment claims "the storefront handler filters on bc_variant_id in the plan row" — traced directly against plans.ts and found false; the scenario tests checkSubscriptionEligibility (imported at line 91), not handleStorefrontPlansList's actual variant handling, because the latter has none. PlanWizard.tsx:244-245's own comment about an "order-create webhook variant-eligibility check" is the same trap from the frontend side — no such check exists anywhere in routes/webhooks.ts. See the canonical-framing attestation on this domain for the full trace.

2. Named-deferred — variant-level storefront enforcement is recorded as unbuilt, not merely un-noticed. ADR-0048 §"Explicitly out of scope" states directly: "US-26.3 variant widget hide — substrate shipped (migration 0005), browser verification pending." SubscriptionWidget.svelte:16-21's own header comment independently confirms: "bcVariantId variant-eligibility filtering (forward-compat — same status as the React version)."

3. Contract-verified, not live-verified — the variant-aware check is proven, but not at the storefront. checkSubscriptionEligibility is G4-proven by epic-26-eligibility.scenario.ts Scenario 8 and reachable in production only via the admin diagnostic GET /api/v1/admin/eligibility/audit — never by a real storefront request or checkout. No scenario exercises handleStorefrontPlansList itself rejecting or filtering a request by variant, because that code path has no variant parameter to test against.

4. Built-but-untrodden — Price-List renewal repricing is real, reachable code that no real subscriber ever exercises. scheduler.ts:1627's price_list repricing branch, with its full BC Price List API integration (resolveVariantPriceFromList), is genuine and correctly wired — but the gating column it checks, subscriptions.bc_variant_id, is never populated by any subscription-creation path. The branch is dead in production for every subscriber created since the app existed; the renewal silently falls through to the carry-forward branch (renewalAmountCents = charge.amount_cents) instead. A merchant relying on price_list re-pricing at renewal is not getting it.

5. Superseded-framing residue — the deactivation UI still warns against backend paths that have shipped. The admin plan-deactivation modal (US-4.4) labels "End all at next cycle" and "Cancel all immediately" with a "destructive" + "stub" Badge and a dissuading "stubWarning" message (ProductDetail.tsx, badge introduced 2026-05-07, commit 95a22634). The backend for both, handlePlanEndAtNextCycle and handlePlanCancelImmediately (plans.ts:1572-1648), has been fully implemented since commit 20e979b4 (2026-06-26, closing Hive #1227) — real UPDATE subscriptions mutations (next-cycle: null out next_charge_at on active subs; cancel-immediately: cascade cancellation, gated by an EU/EEA notice-period conflict check), with no setTimeout placeholder anywhere in the file. The epic-04 DoD audit (docs/audits/epic-dod/epic-04-dod-status.md, dated 2026-05-19) correctly recorded these as "backend stubbed" at the time it was written — the audit predates the fix by five weeks and was never re-attested. Two later commits touched the same admin file (db170948, 36e2e3c8, both PR #1851) without removing the stub markers.

6. Verified-but-incomplete — copy-plan's in-flight UX and unbounded fan-out. US-4.5 (copy a plan between products) has a real backend (routes/admin/plans/copy.ts, correctly carrying the eligibility subset forward via listPlanEligibleVariants) and a real admin modal (CopyPlanModal.tsx), but the modal's "working" phase renders no loading affordance — it re-renders the unchanged product-select list with a premature "Done" button, per the epic-04 derived UI-states view — and has no enforced cap on how many target products a merchant can select in one request.

How to operate & extend

  • The invariant you must not break: don't build a feature — merchant copy, admin UI, a new storefront client — that implies variant-eligibility or Price-List repricing is enforced at checkout. Neither is, on any production request path, until handleStorefrontPlansList gains a variant parameter and createSubscription's call sites are updated to stamp bc_variant_id from the BC order line item.
  • Wiring variant enforcement, if you pick this up: the two pieces needed are (a) handleStorefrontPlansList accepting and filtering on a variant query param, joined against plan_variant_eligibility, and (b) the webhook order-create path (webhooks.ts::createSubscriptionsFromOrder) extracting the BC order line item's variant id and passing it through to createSubscription's NewSubscription.bc_variant_id field (which doesn't exist yet — this is a schema-touching change, add it explicitly rather than defaulting).
  • Change a plan's pricing strategy behavior at renewal: the branch lives in scheduler.ts:1621-1720, keyed on plan.pricing_strategy; the lock_price_at_creation and fixed_price short-circuits take precedence over price_list/discount_percent re-reads.
  • Add a new scoping dimension (beyond channel/group/country/price-list): follow the ADR-0047 pattern — a nullable JSON-array TEXT column on plans, a partial index on the non-null subset, and a new filter step in applyScopeFilters (plans.ts:876-975). Don't reach for a join table unless the product-to-plan ratio assumption (1-3 plans) changes.
  • Add a new eligibility rule type (mutex, dependency, custom-field, qty-bounds shape): a self-contained module under services/eligibility/ with an explicit failOpen/failClosed marker, registered as a step in checkSubscriptionEligibility's chain (ADR-0028 §3, ADR-0048 §1, §4). Remember this chain runs at subscription-creation time via the admin diagnostic and future checkout hooks — not at handleStorefrontPlansList's list-time chokepoint.
  • Deactivation not working as the UI implies? Ignore the "stub" badge on ProductDetail.tsx — the backend is real (delta #5 above). If you're the one fixing the UI, remove the stub markers in the same PR rather than leaving a third stale signal for the next reader.

Confidence notes

  • No deciding record exists for why the wizard's variant-eligibility UI shipped ahead of the enforcement chokepoint. ADR-0048 names the gap ("browser verification pending") but does not explain the sequencing decision. If closing this gap, consider whether a decision record should capture the wiring plan alongside the fix.
  • This page traces handleStorefrontPlansList and checkSubscriptionEligibility exhaustively for variant call sites as of this commit — grep-verified across apps/, not sampled. A future PR that adds a variant parameter to either function invalidates the invariant as stated here; re-attest before relying on this page after such a change.
  • The G4-pass status on US-4.3 and US-26.3 in the coverage matrix is real but narrow. It proves checkSubscriptionEligibility's variant-scalar filter and the wizard's write-side persistence to plan_variant_eligibility — not that either is reachable from a storefront request. Read "pass" here as "the built piece works," not "the feature is enforced."
  • No live-sandbox (G5) proof was checked for this domain as part of this trace — the deltas above are all G3/G4-tier findings (code presence and scenario-test scope), not a live-state claim beyond what the Input-B live-state attestation already ratified.