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) andplan_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, withplansitself trimmed to the columns this domain's invariant and mechanism concern; every other column and table is omitted). This source carries nosign_offfield; its own staleness marker isas_of_commit: 80fc35f4,staleness_threshold_days: infinite(mechanically regenerated fromschema.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
handleStorefrontPlansListgains a variant parameter andcreateSubscription's call sites are updated to stampbc_variant_idfrom the BC order line item. - Wiring variant enforcement, if you pick this up: the two pieces
needed are (a)
handleStorefrontPlansListaccepting and filtering on a variant query param, joined againstplan_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 tocreateSubscription'sNewSubscription.bc_variant_idfield (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 onplan.pricing_strategy; thelock_price_at_creationandfixed_priceshort-circuits take precedence overprice_list/discount_percentre-reads. - Add a new scoping dimension (beyond channel/group/country/price-list):
follow the ADR-0047 pattern — a nullable JSON-array
TEXTcolumn onplans, a partial index on the non-null subset, and a new filter step inapplyScopeFilters(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 explicitfailOpen/failClosedmarker, registered as a step incheckSubscriptionEligibility'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 athandleStorefrontPlansList'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
handleStorefrontPlansListandcheckSubscriptionEligibilityexhaustively for variant call sites as of this commit — grep-verified acrossapps/, 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 toplan_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.