Decide by one rule whether a member can find and buy a product

#178 and #282. The operator's product page and the member pages decided
by different rules whether a member could find and buy a product, so
they disagreed about the same product.

#178: the readiness panel decided findability from "a plan tier, or
display_category addon", while the member Products page lists any
listed product on no ladder whose default price passes the price rule,
whatever its category, and Checkout refuses by the same rule. The panel
also called a tier Purchasable when its default price was one-time,
which Checkout refuses. The price rule is now exported
(plans.PriceRule; Offer and OffLadder keep their answers through
priceMove), and the panel builds the price facts with the member pages'
own function (offeredPrice) and asks it. Purchasable requires the rule
to pass: a one-time default price reads "Incomplete; missing: recurring
default price", the Active price row reads One-time with the line "Make
a recurring price the default on the Prices view; a one-time price is
not sold.", and the products list notes "Missing: recurring default
price". The Member catalog visibility row reads Shown exactly when the
member pages draw the product; the label decides nothing. Anything
Purchasable is now shown, so the qualifier "Purchasable; not shown in
the member catalog" and its list note are removed, with
CatalogQualifier and the row's three long details.

#282: with no Stripe key, the member pages read price mappings an
earlier key made and offered Add and Subscribe buttons that posted to a
route nothing served. "Stripe is configured" now has one source,
stripeConfigured(keyMode), the mode boot derives from the key, which it
accepts only with its webhook secret. The position loader takes the key
mode and reads no mapping without one; the panel's flag, the Checkout
route's registration and the checkout return use the same function, and
server.Config drops StripeAPIKey and StripeWebhookSecret.

Ride-alongs, listed in the proposal:
- member-product-discovery said a non-plan product is "not a tier on
  any active plan ladder" while its plan requirement and the code take
  any ladder; it now says any plan ladder.
- product-catalog cross-referenced a storefront add-on filter by
  display_category that does not exist; it says the label renders as a
  badge and decides nothing.
- MappingReachable's comment says an empty mode means no key.
- The readiness functions' parameter stripeConfigured is renamed
  configured, so the new function of that name is not shadowed.
- TestSwitchRefusesWhenAReadFails builds its failing handlers with the
  control's key mode, so each differs from its control only by the
  failing read.
- docs/plan-management.md drops the "product type" remedy and the
  claim that a one-time typed product is purchasable off-ladder.
- An existing fixture, seedActivePrice, inserts a monthly price, since
  the verdict test means a recurring one.

Strings (approved): the four above; no member-visible string changes.

Tests: TestPriceRule; TestTheReadinessPanelAgreesWithTheMemberPages
(every product shape of the design's failure table, a tier of two
ladders included, with and without a key);
TestMemberCatalogGateFollowsTheMappingEnvironment with its keyless
half; TestTheStripeKeyDecidesWhatThePagesSell (the Products page, the
product page and Checkout through the router).

Full suite on reset databases, on this commit's tree: 5473 passed, 16
skipped, none failed. make screens: the six product detail captures
(detail, open and invalid, at desktop and mobile width) each lose one
line, the unlisted product's Member catalog visibility detail; the
FedWiki sites page differs from the baseline by the maintenance panel
of the previous FedWiki commit, taken after the baseline; no other
capture changed. The states make screens cannot reach (each product
shape of the panel, the member pages with no key) are held by the tests
above.

Breaks, all CAUGHT (37):
- one-time price reads met
- catalog row by the addon label (unlabelled, recurring)
- catalog row by the addon label (addon, one-time)
- catalog row without the gate
- purchasable without the price rule (one-time)
- purchasable without the price rule (other environment)
- payment processing met when mapped
- offered price always recurring (panel)
- offered price always recurring (router)
- loader reads mappings without a key (database test)
- loader reads mappings without a key (router)
- default price judged under no key mode
- keyless facts drop the price
- stripe always configured
- stripe never configured (Products page)
- stripe never configured (product page)
- checkout route always registered
- panel always configured
- return reconciles without a key
- catalog row counted as missing
- qualifier restored
- one-time state unmet
- one-time detail reworded
- one-time price unnamed
- price rule sells one-time
- not-default offered disabled
- switch around the rule
- checkout around the rule
- off-ladder around the rule
- price rule order: gate before price
- price rule order: default before recurrence
- price rule order: recurrence before reach
- a purchasable product carries a note
- one-time price reads met (router)
- unlisted catalog row hidden
- catalog row carries a detail
- one-time annotation drawn as a badge

One edit has no break: the removal of CatalogQualifier leaves nothing
to break.
This commit is contained in:
2026-10-30 16:28:11 -05:00
parent af8317b045
commit 5c454adc91
38 changed files with 1902 additions and 350 deletions
-2
View File
@@ -307,8 +307,6 @@ func serveConsole(
StripeQ: stripemod.New(database),
DomainsQ: domains.New(database),
Temporal: temporalHolder,
StripeWebhookSecret: viper.GetString("stripe-webhook-secret"),
StripeAPIKey: viper.GetString("stripe-api-key"),
StripeDashboardURL: stripeintegration.DashboardURL(boot.stripeMode),
StripeMode: boot.stripeMode,
StripeKeyFingerprint: boot.stripeKeyFingerprint,
+4 -4
View File
@@ -49,7 +49,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
5. **[app]** Visibility is two independent switches with two different jobs: an inactive product (`is_active` false) drops out of the public catalog and out of the operator pickers that attach products to new things, while `is_public` false hides the product from members only. Neither switch implies the other.
6. **[db]** A product has at most one default price; a partial unique index rejects a second one. The default price is the price members are offered.
7. **[db]** A usage-typed price must be recurring: one CHECK constraint limits `usage_type` to `NULL`, `licensed`, or `metered`, and another rejects any price whose `usage_type` is set while `recurring_interval` is `NULL`. A one-time price therefore never carries a usage type.
8. **[app]** A member can only be offered the default price, and only when that price is active, recurring, and has a row in `stripe.price_mappings` whose Stripe id the current key can reach; without that, a plan tier's buy control renders disabled and a product on no ladder is not listed. Checkout, the plan switch and its preview refuse every other price (`plans.Offer`, `plans.OffLadder`).
8. **[app]** A member can only be offered the default price, and only when that price is active, recurring, and has a row in `stripe.price_mappings` whose Stripe id the current key can reach; without that, a plan tier's buy control renders disabled and a product on no ladder is not listed. A deployment with no key reads no mapping, so it offers no price. Checkout, the plan switch and its preview refuse every other price (`plans.Offer`, `plans.OffLadder`, both through `plans.PriceRule`).
9. **[db]** Entitlement sets do not nest — the schema has no way for a set to include another set. To sell a combination, create a new set holding the combined rules. Many products may point at the same set.
10. **[db-code]** What a provision delivers is fixed at conferral time: `core.confer` writes the product and the entitlement set onto the provision row as they are at purchase or grant, and nothing later rewrites live provisions when a product is edited.
11. **[app]** Member surfaces offer only published products. The public-catalog queries return only rows with `lifecycle_status = 'published'`, and checkout independently rejects any price whose product is not published, active, and public before contacting Stripe — so a draft or retired product cannot be bought even through a crafted or stale request. One exception: an organization already enrolled on a ladder tier keeps seeing that rung, marked current with no move control, after its product leaves publication.
@@ -67,8 +67,8 @@ These axes are independent, and holding them separate is most of understanding t
- **Consumption shape** — derived from the active prices' `usage_type`: `none`, `licensed` (pay for access), `metered` (pay for use), or `mixed`.
- **Entitlement linkage** — which set the product confers.
- **Stripe sync** — whether the default price has its `stripe.price_mappings` row; without one the product cannot be bought.
- **Presentation** — `display_category`. Deliberately not a behavioral axis (invariant 2); the storefront's "add-ons" page is this grouping at work, not a structural category.
- **Presentation** — `display_category`. Deliberately not a behavioral axis (invariant 2): a member sees it as a badge on a product's row, and it decides nothing.
Two composite verdicts assemble these axes, and they must not be conflated. **Purchasability** answers "may a member buy this now?" — the complete verdict requires published, active, public, an entitlement set linked, and an active default price with its Stripe mapping, and is computed in `product_readiness.go` for the operator readiness panel. The member storefront and checkout enforce every leg of that verdict a member could trip (invariant 11): the catalog queries return only published, active, public products, and checkout re-checks those three switches and asks the offer decision, which requires the default, recurring price with a Stripe mapping the key can reach and reads the organization's position on every ladder the product is a tier of, before contacting Stripe. The one leg reported only on the operator panel is the linked entitlement set, so a member can buy a product on no ladder that has none: Stripe bills the subscription, and the subscription reconcile skips the product with a warning in the server log, so the subscription delivers nothing (#272). **Conferral shape** answers "what does holding this product deliver?" — assembled by `product_conferral_shapes` from the entitlement set, lifecycle, member visibility, and ladder position.
Two composite verdicts assemble these axes, and they must not be conflated. **Purchasability** answers "may a member buy this now?" — the complete verdict requires published, active, public, an entitlement set linked, and an active, recurring default price whose Stripe mapping the current key can reach, and is computed in `product_readiness.go` for the operator readiness panel, which asks the price rule (`plans.PriceRule`) the member pages and Checkout ask. The member storefront and checkout enforce every leg of that verdict a member could trip (invariant 11): the catalog queries return only published, active, public products, and checkout re-checks those three switches and asks the offer decision, which requires the default, recurring price with a Stripe mapping the key can reach and reads the organization's position on every ladder the product is a tier of, before contacting Stripe. The one leg reported only on the operator panel is the linked entitlement set, so a member can buy a product on no ladder that has none: Stripe bills the subscription, and the subscription reconcile skips the product with a warning in the server log, so the subscription delivers nothing (#272). **Conferral shape** answers "what does holding this product deliver?" — assembled by `product_conferral_shapes` from the entitlement set, lifecycle, member visibility, and ladder position.
Purchasability is not the same question as **findability**: whether the member catalog actually renders a control for the product at all. The member catalog draws ladder tiers in their plan sections and lists, in one more section, every published public product outside the tiers whose default price is active, recurring, mapped to Stripe and reachable under the current key; `display_category` only labels a row (invariant 2). A product whose only price is one-time, or not yet mapped, is real but has no catalog path. The readiness panel reports this as a diagnostic "member catalog visibility" line, `ladder_count > 0 OR display_category = 'addon'`, which disagrees with the catalog in both directions (#178), and attaches a qualifier to an otherwise-purchasable verdict rather than failing it — off-ladder purchasability is an ordinary, supported shape, not an error, but the panel must never let "Purchasable" be misread as "a member can find this."
Purchasability is not the same question as **findability**: whether the member catalog actually renders a control for the product at all. The member catalog draws ladder tiers in their plan sections and lists, in one more section, every published public product outside the tiers whose default price is active, recurring, mapped to Stripe and reachable under the current key; `display_category` only labels a row (invariant 2). A product on no ladder whose default price is one-time, or not yet mapped, is real but has no catalog path. The readiness panel reports findability as a diagnostic "member catalog visibility" row from the same facts: shown for a published, active, listed tier, and for a listed product on no ladder exactly when the price rule passes. A Purchasable product is therefore always shown, and the row never changes the verdict.
+3 -5
View File
@@ -40,8 +40,8 @@ Creating and publishing a plan product is **not** enough for a member to buy it.
1. **Draft → entitlement rules.** Create the product and attach an entitlement set whose rules define the quantitative limits the tier grants (e.g. `sites`, `storage_mb`).
2. **On a ladder.** Put the product on a plan ladder as a tier. **This is what makes it a plan** — a product's plan-status *is* its ladder membership (Doc 31 Amendment #3 / Decision 121). A plan that is on no ladder is not a plan at all, and is invisible in the member catalog. To sell a single standalone plan, give it its own **single-tier ladder** — that is the idiom, not an off-ladder product.
3. **Published, public, active.** Set `lifecycle_status = published`, and mark the product Active and Public so it appears in the member catalog.
4. **Priced.** Add an active price on the product's Prices view.
3. **Published, listed, active.** Set `lifecycle_status = published`, and mark the product Active and Listed so it appears in the member catalog.
4. **Priced.** Add an active, recurring price on the product's Prices view and make it the default; a one-time default price reads "Incomplete; missing: recurring default price".
5. **Stripe-synced.** The price must be mapped to a live Stripe price. Creating a price does **not** sync it automatically — click **Sync to Stripe** on the purchasability panel (or per-row on the prices table); see [stripe.md](stripe.md). Once enqueued, the product shows **sync pending** until the mapping lands, and is not yet purchasable.
When all five hold, the member's plan card renders an enabled buy/upgrade control. Until then the card renders disabled ("Not available for purchase yet").
@@ -50,9 +50,7 @@ When all five hold, the member's plan card renders an enabled buy/upgrade contro
The operator product edit page shows a **Purchasability** panel that evaluates each step above against the live state of the product and renders a single verdict — **✓ Purchasable** or **⚠ Incomplete**, naming exactly which preconditions are unmet, with inline guidance for each. The verdict is computed from the same gate the member catalog uses, so the panel never claims a product is purchasable while a member sees it disabled.
The panel also flags the **invisible-limbo** case: a published product that is neither on a ladder (so it is not a plan) nor assigned a product type. Such a product appears nowhere in the member catalog. Remediate it by putting it on a ladder (to sell it as a plan) or by setting a product type (add-on / usage / one-time) — typed products are purchasable off-ladder.
> A NULL `product_type` is correct for two kinds of product: a **plan** (whose kind is derived structurally from its ladder membership) and a **draft** (not yet published). A *published* NULL-type product on no ladder is neither — it is the limbo the panel warns about.
The panel's **Member catalog visibility** row says whether members see the product: a published, active, listed tier is shown in its ladders' plan sections, and a listed product on no ladder is shown in the Products page's products section exactly when its default price is recurring and has a Stripe price the current key can reach. The display category only labels a row; it decides nothing.
## Ladder Configuration
+3 -3
View File
@@ -226,14 +226,14 @@ A restricted key needs permission for the calls these moves make, beyond those t
A member can only buy a product once **every** purchasability precondition is met. The operator product edit page (`/operator/products/{id}`) shows a **Purchasability** panel that evaluates each one and renders a single verdict — **Purchasable** or **Incomplete** — naming exactly what is missing. The preconditions:
1. **Published** — set the product's lifecycle to `published`.
2. **Public & active** — mark it Active and Public so it appears in the member catalog.
3. **Structural kind** — put it on a plan ladder to sell it as a plan (a single-tier ladder is the idiom for a standalone plan), **or** give it a product type (`addon` / `usage` / `one_time`). A published, untyped product on no ladder is invisible "limbo", which the panel flags.
2. **Listed & active** — mark it Active and Listed so it appears in the member catalog.
3. **Where members find it** — put it on a plan ladder to sell it as a plan (a single-tier ladder is the idiom for a standalone plan); a product on no ladder is listed in the Products page's products section once its default price can be bought. The panel's Member catalog visibility row says whether members see it; the display category only labels a row.
4. **Active default price** — the product's default price is the one members are offered; a product on no ladder needs a recurring one, and a plan tier's default price must be recurring to be bought.
5. **Stripe-mapped price** — the default price is mapped to a Stripe price in the current key's environment.
The last step is the one to do **explicitly**: on the Purchasability panel, click **Sync to Stripe**. That enqueues the product and price sync; the precondition shows **Sync pending** until the outbox worker drains, then flips to **Met** and the verdict becomes **Purchasable**. Adding a price does **not** auto-sync — click Sync to Stripe whenever a product or its active price is not yet mapped. When the mapping records another environment, or the environment check marked it stale, the same control reads **Create in live** (or **Create in test**) and creates the product and price again in the current one ("Moving between environments" above).
If Stripe is **not configured** for the deployment (no API key), the panel says so on the Stripe-mapped precondition instead of a dead "missing" marker — pricing simply cannot be Stripe-mapped, though grant-based access still works without Stripe.
If Stripe is **not configured** for the deployment (no API key), the panel says so on the Stripe-mapped precondition instead of a dead "missing" marker — pricing simply cannot be Stripe-mapped, though grant-based access still works without Stripe. The member pages then offer nothing for sale, whatever prices an earlier key synced.
## Operator guidelines
@@ -11,8 +11,8 @@
CSP-safe. Built from the page-anatomy parts: sectionHeader outside
the card; check states (met, missing, sync pending, sync failed)
render through statusBadge; annotations (Purchasable, Incomplete,
Shown, Not shown, Not applicable, Unlisted, No rules) render as text
per design D3. */}}
Shown, Not shown, Not applicable, Unlisted, No rules, One-time) render as
text per design D3. */}}
<div id="product-readiness"
{{ if .Readiness.SyncPending }}hx-get="{{ routeURL "/partials/operator/products/{productID}/readiness" .Product.ProductID }}"
hx-trigger="every 3s" hx-swap="outerHTML"{{ end }}>
@@ -23,11 +23,10 @@
renders as an alert strip, not a row-sized badge (maintainer,
2026-08-31). product-management "The readiness panel prints its
verdict": the template always prints .Readiness.Verdict, the
string the evaluation computes ("Purchasable", "Purchasable; not
shown in the member catalog", "Ready to grant" for an unlisted
product, "Inactive", "Incomplete; missing: ..."), and never a literal (such
as "Purchasable.") derived from the Purchasable boolean in its
place. */}}
string the evaluation computes ("Purchasable", "Ready to grant" for
an unlisted product, "Inactive", "Incomplete; missing: ..."), and
never a literal (such as "Purchasable.") derived from the
Purchasable boolean in its place. */}}
{{ if .Readiness.Purchasable }}
<div class="alert alert-success py-2 mb-3"><strong>{{ .Readiness.Verdict }}</strong></div>
{{ else }}
@@ -45,6 +44,8 @@
<span class="text-body-secondary small">Unlisted; issued as grants</span>
{{ else if eq .State "no_rules" }}
<span class="text-warning-emphasis small">No rules</span>
{{ else if eq .State "one_time" }}
<span class="text-warning-emphasis small">One-time</span>
{{ else if eq .State "shown" }}
<span class="text-body-secondary small">Shown</span>
{{ else if eq .State "hidden" }}
@@ -84,10 +84,8 @@ func MappingAgrees(livemode sql.NullBool, keyMode string) bool {
// asked Stripe for the id under this key and was answered
// resource_missing.
//
// An empty keyMode answers true. A deployment whose key the console could
// not classify knows nothing about which environment it is in, and
// refusing every id on that would take a working deployment down over a
// question it cannot answer.
// An empty keyMode means the deployment has no key, since boot refuses a key
// whose mode it cannot classify. It answers true.
func MappingReachable(livemode sql.NullBool, syncStatus, keyMode string) bool {
if keyMode == "" {
return true
+38 -16
View File
@@ -299,10 +299,10 @@ func (p Position) rankOf(productID string) (int32, bool) {
// charges nothing, while a switch would invoice with the failed invoice still
// open.
//
// The price rule decides Checkout and Switch: a product outside the member
// gate is offered none; a tier with no price is offered the move disabled; a
// price that is not the default is offered none; a one-time price, or one with
// no reachable Stripe price, is offered the move disabled.
// The price rule (PriceRule) decides Checkout and Switch: a product outside
// the member gate is offered none; a tier with no price is offered the move
// disabled; a price that is not the default is offered none; a one-time price,
// or one with no reachable Stripe price, is offered the move disabled.
func Offer(pos Position, t Target) Move {
rank, ok := pos.rankOf(t.ProductID)
if !ok {
@@ -370,7 +370,7 @@ func (a ask) resume() Move {
// to Downgrade (the granted tier is the current one), so the rule sets Upgrade
// itself.
func (a ask) paidSwitch() Move {
m := priceRule(Switch, a.Target)
m := priceMove(Switch, a.Target)
m.Relation = Upgrade
m.ReplacesGrant = m.Kind == Switch
m.OwnTier = m.ReplacesGrant && a.ProductID == a.Current
@@ -434,35 +434,57 @@ func (a ask) route() Move {
var m Move
switch {
case a.rel == Available || (a.rel == Upgrade && !a.Subscribed):
m = priceRule(Checkout, a.Target)
m = priceMove(Checkout, a.Target)
case a.freeRung():
m = Move{Kind: Cancel, Enabled: true}
case a.Suspended:
m = Move{Reason: Suspended}
default:
m = priceRule(Switch, a.Target)
m = priceMove(Switch, a.Target)
m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval
}
m.Relation = a.rel
return m
}
// priceRule decides a Checkout or a Switch toward the target.
func priceRule(kind Kind, t Target) Move {
// PriceRule is the price rule: NoReason when a member may buy the target's
// price, and otherwise the first refusal of NotListed (the product is
// outside the member gate), NoPrice, NotDefault, OneTime and Unpurchasable,
// in that order. Every Checkout and switch Offer answers, and OffLadder's
// Checkout, pass through it. It reads no position, so a surface that
// answers for a product rather than for an organization's position, such
// as the operator's readiness panel, asks it and agrees with them.
func PriceRule(t Target) Reason {
p := t.Price
switch {
case !t.Listed:
return Move{Reason: NotListed}
return NotListed
case p.ID == "":
return Move{Kind: kind, Reason: NoPrice}
return NoPrice
case !p.Default:
return Move{Reason: NotDefault}
return NotDefault
case !p.Recurring:
return Move{Kind: kind, Reason: OneTime}
return OneTime
case !p.Purchasable:
return Move{Kind: kind, Reason: Unpurchasable}
return Unpurchasable
}
return NoReason
}
// priceMove is the price rule's answer as a Checkout or a Switch toward the
// target: the move enabled when the rule passes; no move for a product
// outside the member gate or a price that is not the default; the move
// disabled for a tier with no price, a one-time price, or a price with no
// Stripe price the key can reach.
func priceMove(kind Kind, t Target) Move {
switch r := PriceRule(t); r {
case NoReason:
return Move{Kind: kind, Enabled: true}
case NotListed, NotDefault:
return Move{Reason: r}
default:
return Move{Kind: kind, Reason: r}
}
return Move{Kind: kind, Enabled: true}
}
// Across combines one product's moves on the ladders it is a tier of. A
@@ -495,5 +517,5 @@ func Across(moves []Move) Move {
// OffLadder decides the offer for a product that is a tier of no ladder: the
// price rule, with Checkout the only move.
func OffLadder(t Target) Move {
return priceRule(Checkout, t)
return priceMove(Checkout, t)
}
+37
View File
@@ -465,6 +465,43 @@ func TestOffLadder(t *testing.T) {
}
}
func TestPriceRule(t *testing.T) {
tests := []struct {
name string
edit func(*Target)
want Reason
}{
{"offered", func(*Target) {}, NoReason},
{"outside the member gate", func(t *Target) { t.Listed = false }, NotListed},
{"outside the member gate with no price", func(t *Target) { t.Listed = false; t.Price = Price{} }, NotListed},
{"no price", func(t *Target) { t.Price = Price{} }, NoPrice},
{"another price", func(t *Target) { t.Price.Default = false }, NotDefault},
{"another price that is one-time", func(t *Target) { t.Price.Default = false; t.Price.Recurring = false }, NotDefault},
{"a one-time price", func(t *Target) { t.Price.Recurring = false }, OneTime},
{"a one-time price out of reach", func(t *Target) { t.Price.Recurring = false; t.Price.Purchasable = false }, OneTime},
{"not purchasable", func(t *Target) { t.Price.Purchasable = false }, Unpurchasable},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tg := target("plus")
tc.edit(&tg)
if got := PriceRule(tg); got != tc.want {
t.Errorf("%s: PriceRule = %q, want %q", tc.name, got, tc.want)
}
off := OffLadder(tg)
if off.Reason != tc.want || off.Allows(Checkout) != (tc.want == NoReason) {
t.Errorf("%s: OffLadder answers %+v, the price rule %q", tc.name, off, tc.want)
}
if got := Offer(Position{Tiers: ladderTiers}, tg).Reason; got != tc.want {
t.Errorf("%s: Offer's Checkout answers %q, the price rule %q", tc.name, got, tc.want)
}
if got := Offer(holds("basic", true), tg).Reason; got != tc.want {
t.Errorf("%s: Offer's switch answers %q, the price rule %q", tc.name, got, tc.want)
}
})
}
}
func TestMoveAllows(t *testing.T) {
kinds := []Kind{None, Checkout, Switch, Cancel}
for _, k := range []Kind{Checkout, Switch, Cancel} {
+2 -2
View File
@@ -261,8 +261,8 @@ func (h *BillingCheckoutHandler) resolveCheckoutTarget(w http.ResponseWriter, r
// (plans.Across): a subscription to the product supersedes the incumbent on
// each of them. Any read that fails returns its error, which refuses.
func (h *BillingCheckoutHandler) checkoutOffer(ctx context.Context, orgID string, target checkoutTarget, keyMode string) (plans.Move, error) {
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
price, err := loader.postedPrice(ctx, target.price, keyMode)
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, keyMode, time.Now)
price, err := loader.postedPrice(ctx, target.price)
if err != nil {
return plans.Move{}, err
}
+2 -2
View File
@@ -97,12 +97,12 @@ func (h *MemberProductsHandler) buildAddonsData(ctx context.Context) AddonsData
// public tier products exist.
data.HasPlans = len(tierIDs) > 0
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)
for _, p := range products {
if tierIDs[p.ProductID] {
continue // renders in its plan ladder section instead
}
price, err := loader.defaultPrice(ctx, p.ProductID, h.StripeMode)
price, err := loader.defaultPrice(ctx, p.ProductID)
if err != nil {
h.Logger.Error("failed to read the price facts of a product", slog.String("product_id", p.ProductID), slog.Any("error", err))
return AddonsData{Error: "Failed to load extras"}
@@ -13,9 +13,9 @@ package server
// DB-backed via TEST_DATABASE_URL, and the rows are committed rather than
// written inside a rolled-back transaction: the handler reads price
// mappings through its own *sql.DB (the position loader builds the Stripe
// querier from h.Database), which cannot see another connection's open
// transaction, so the rollback shape member_extras_test.go uses cannot
// supply the mapping this gate turns on.
// querier from h.Database when the key mode is set), which cannot see another
// connection's open transaction, so the rollback shape member_extras_test.go
// uses cannot supply the mapping this gate turns on.
// Each fixture deletes its own rows.
import (
@@ -31,6 +31,14 @@ import (
"github.com/google/uuid"
)
// mappingCase is a product whose price mapping sits in one state, and whether
// the member pages sell it under a key in the environment the case names.
type mappingCase struct {
name string
productID string
wantPurchasable bool
}
func TestMemberCatalogGateFollowsTheMappingEnvironment(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
@@ -99,24 +107,20 @@ func TestMemberCatalogGateFollowsTheMappingEnvironment(t *testing.T) {
listed[a.ProductID] = true
}
cases := []struct {
name string
productID string
wantPurchasable bool
}{
cases := []mappingCase{
{"recorded in the key's own environment", reachable, true},
{"unverified, which blocks nothing", unverified, true},
{"recorded in test under a live key", otherEnv, false},
{"found missing by the environment check", stale, false},
}
loader := newPositionLoader(h.BillingQ, nil, database, time.Now)
loader := newPositionLoader(h.BillingQ, nil, database, h.StripeMode, time.Now)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if listed[tc.productID] != tc.wantPurchasable {
t.Errorf("listed in Extras = %v, want %v", listed[tc.productID], tc.wantPurchasable)
}
facts, err := loader.defaultPrice(ctx, tc.productID, h.StripeMode)
facts, err := loader.defaultPrice(ctx, tc.productID)
if err != nil {
t.Fatalf("defaultPrice: %v", err)
}
@@ -139,4 +143,42 @@ func TestMemberCatalogGateFollowsTheMappingEnvironment(t *testing.T) {
}
})
}
checkCatalogWithoutAKey(t, ctx, h, database, cases)
}
// checkCatalogWithoutAKey runs each case's mapping through the member pages of
// a deployment with no Stripe key: the products section lists none of them and
// the position loader reads no mapping, so no price is purchasable, whatever an
// earlier key recorded. The price is still named: it exists, it is only not for
// sale.
func checkCatalogWithoutAKey(t *testing.T, ctx context.Context, h *MemberProductsHandler, database *sql.DB, cases []mappingCase) {
t.Helper()
keyless := &MemberProductsHandler{BillingQ: h.BillingQ, Logger: h.Logger, Database: database}
data := keyless.buildAddonsData(ctx)
if data.Error != "" {
t.Fatalf("buildAddonsData with no Stripe key: %s", data.Error)
}
listed := make(map[string]bool, len(data.Addons))
for _, a := range data.Addons {
listed[a.ProductID] = true
}
keylessLoader := newPositionLoader(h.BillingQ, nil, database, "", time.Now)
for _, tc := range cases {
t.Run("without a key "+tc.name, func(t *testing.T) {
if listed[tc.productID] {
t.Errorf("%s: listed in Extras with no Stripe key", tc.name)
}
facts, err := keylessLoader.defaultPrice(ctx, tc.productID)
if err != nil {
t.Fatalf("defaultPrice: %v", err)
}
if facts.Price.Purchasable {
t.Errorf("%s: defaultPrice purchasable with no Stripe key", tc.name)
}
if facts.Price.ID == "" {
t.Errorf("%s: defaultPrice names no price with no Stripe key", tc.name)
}
})
}
}
+1 -1
View File
@@ -225,7 +225,7 @@ func (h *MemberProductsHandler) renderApprovalPreview(w http.ResponseWriter, ctx
// confirmation's "You keep Pro until" and the decline's "you're still on
// Plus". Empty when it holds none or a read fails (#197).
func (h *MemberProductsHandler) currentTierName(ctx context.Context, orgID, ladderID string) string {
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)
held, err := loader.positions(ctx, orgID)
if err != nil {
return ""
+5 -5
View File
@@ -33,9 +33,9 @@ type MemberProductsHandler struct {
// plan move checks the ids it sends against it and hands it to the
// reconcile that follows.
StripeMode string
// Database is used to resolve Stripe price mappings when determining
// whether a plan is purchasable. May be nil (e.g. in tests or when Stripe
// is not configured), in which case plans are treated as not purchasable.
// Database resolves Stripe price mappings for the price facts; the loader
// reads them only when StripeMode is set (stripeConfigured), and reads none
// when Database is nil.
Database *sql.DB
// StripeKeyFingerprint names the API key in force, so the billing facts
// under each section read only a public-details record read under that
@@ -308,7 +308,7 @@ func (h *MemberProductsHandler) GetPlans(w http.ResponseWriter, r *http.Request)
func (h *MemberProductsHandler) buildPlansData(ctx context.Context, orgID string) PlansData {
b := plansBuild{
orgID: orgID,
loader: newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now),
loader: newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now),
// Resolved once for every tier's feature lines: the catalog must agree
// with the entitlement view on resource-key display names (raw keys like
// "fedwiki_sites" never render when a display name exists).
@@ -488,7 +488,7 @@ func (h *MemberProductsHandler) buildTier(ctx context.Context, b *plansBuild, la
h.setCurrent(ctx, &tier, ladderID, pos, facts)
return tier, true, nil
}
price, err := b.loader.defaultPrice(ctx, t.ProductID, h.StripeMode)
price, err := b.loader.defaultPrice(ctx, t.ProductID)
if err != nil {
return tier, false, fmt.Errorf("read the price facts of product %s: %w", t.ProductID, err)
}
@@ -360,6 +360,7 @@ func TestSwitchRefusesWhenAReadFails(t *testing.T) {
handler := func(entsQ entitlements.Querier, billingQ billing.Querier) *server.MemberProductsHandler {
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: entsQ, BillingQ: billingQ, AuthConfig: m.h.AuthConfig, Logger: discardLogger(), Database: m.db,
StripeMode: m.h.StripeMode,
})
if err != nil {
t.Fatalf("new handler: %v", err)
+168 -1
View File
@@ -183,6 +183,173 @@ func TestCheckoutThroughTheRouter(t *testing.T) {
}
}
// stripeKeyEnv is the Stripe key test's fixture: three listed products with a
// rule and a mapped price, the router with a key and the router with none, and
// a member and an operator signed in on each.
type stripeKeyEnv struct {
w *world.World
keyed, keyless *routeEnv
keyedMember, keylessMember *http.Cookie
keyedOperator, keylessOperator *http.Cookie
// ladder holds tier alone; monthly and oneTime are on no ladder.
ladder world.Ladder
tier, monthly, oneTime world.Product
tierPrice, monthlyPrice world.Price
oneTimePrice world.Price
}
// checkoutControl is the selector of the controls that post to Checkout on the
// Products page: a tier's buy control and a product's Add.
const checkoutControl = `[hx-post="/billing/checkout"]`
// newStripeKeyEnv seeds the products, each with a boolean rule and a price
// whose mapping records no environment, and signs in on both routers.
func newStripeKeyEnv(t *testing.T, db *sql.DB) *stripeKeyEnv {
t.Helper()
w := world.New(t, db)
registerGoldenKeys(t, w)
f := &stripeKeyEnv{w: w}
rule := entitlements.RuleFields{RuleType: "boolean", ResourceKey: sql.NullString{String: goldenForumKey, Valid: true}}
seed := func(name string, price func(world.Product) world.Price) (world.Product, world.Price) {
p := w.Product(name)
w.Rule(p, rule)
pr := price(p)
w.MapPrice(pr)
return p, pr
}
monthly := func(p world.Product) world.Price { return w.Price(p, 1500) }
f.tier, f.tierPrice = seed("Keyed tier", monthly)
f.ladder = w.Ladder("Keyed", f.tier)
f.monthly, f.monthlyPrice = seed("Keyed monthly", monthly)
f.oneTime, f.oneTimePrice = seed("Keyed one-time", func(p world.Product) world.Price { return w.OneTimePrice(p, 900) })
f.keyed = newRouteEnv(t, onDatabase(db))
f.keyless = newRouteEnv(t, func(c *Config) { onDatabase(db)(c); c.StripeMode = "" })
orgType := w.OrgType("keyed")
_, _, f.keyedMember = member(t, w, f.keyed, orgType, "Keyed")
_, _, f.keylessMember = member(t, w, f.keyless, orgType, "Keyless")
f.keyedOperator = f.operator(t, f.keyed, "Keyed operator")
f.keylessOperator = f.operator(t, f.keyless, "Keyless operator")
return f
}
// operator signs in a new person with the operator role on e.
func (f *stripeKeyEnv) operator(t *testing.T, e *routeEnv, name string) *http.Cookie {
t.Helper()
op := f.w.Person(name).SignIn()
op.Roles = []string{OperatorRole}
return e.signIn(t, op)
}
// products is the Products page as the cookie's member reads it.
func (f *stripeKeyEnv) products(t *testing.T, e *routeEnv, cookie *http.Cookie) *htmltest.Node {
t.Helper()
rec := e.get(cookie, "/products", false)
if rec.Code != http.StatusOK {
t.Fatalf("GET /products = %d", rec.Code)
}
return htmltest.Parse(t, rec.Body.String())
}
// offers reports whether a control on the page posts the price to Checkout.
func offers(page *htmltest.Node, price world.Price) bool {
for _, c := range page.All(checkoutControl) {
if vals, _ := c.Attr("hx-vals"); strings.Contains(vals, price.ID) {
return true
}
}
return false
}
// productPage is the verdict text and the parsed operator page of the product.
func (f *stripeKeyEnv) productPage(t *testing.T, e *routeEnv, cookie *http.Cookie, p world.Product) (string, *htmltest.Node) {
t.Helper()
rec := e.get(cookie, "/operator/products/"+p.ID, false)
if rec.Code != http.StatusOK {
t.Fatalf("GET /operator/products/%s = %d", p.ID, rec.Code)
}
doc := htmltest.Parse(t, rec.Body.String())
return doc.One(t, "#product-readiness .alert strong").Text(), doc
}
func (f *stripeKeyEnv) keyedOffersBoth(t *testing.T) {
page := f.products(t, f.keyed, f.keyedMember)
for _, c := range []struct {
p world.Product
price world.Price
}{{f.tier, f.tierPrice}, {f.monthly, f.monthlyPrice}} {
if !offers(page, c.price) {
t.Errorf("with a key the Products page offers no Checkout of %s", c.p.Name)
}
}
}
func (f *stripeKeyEnv) keyedOneTime(t *testing.T) {
if offers(f.products(t, f.keyed, f.keyedMember), f.oneTimePrice) {
t.Error("with a key the Products page lists the one-time product")
}
verdict, doc := f.productPage(t, f.keyed, f.keyedOperator, f.oneTime)
if verdict != "Incomplete; missing: recurring default price" {
t.Errorf("with a key the one-time product's page reads %q, want \"Incomplete; missing: recurring default price\"", verdict)
}
wantCountOfText(t, doc, "#product-readiness span.text-warning-emphasis", "One-time", 1)
wantCountOfText(t, doc, "#product-readiness small.text-muted", oneTimeDetail, 1)
}
func (f *stripeKeyEnv) keylessOffersNeither(t *testing.T) {
page := f.products(t, f.keyless, f.keylessMember)
// Unfiltered on purpose: with no key no product of any test's rows can be sold.
if n := page.Count(checkoutControl); n != 0 {
t.Errorf("with no Stripe key the Products page posts %d controls to Checkout, want none", n)
}
card := page.One(t, "#move-disabled-"+f.ladder.ID+"-"+f.tier.ID)
if !strings.Contains(card.Text(), "Not available for purchase yet") {
t.Errorf("with no Stripe key the tier's card does not read %q", "Not available for purchase yet")
}
}
func (f *stripeKeyEnv) keyedProductPage(t *testing.T) {
if verdict, _ := f.productPage(t, f.keyed, f.keyedOperator, f.monthly); verdict != "Purchasable" {
t.Errorf("with a key the product page reads %q, want \"Purchasable\"", verdict)
}
}
func (f *stripeKeyEnv) keylessProductPage(t *testing.T) {
verdict, doc := f.productPage(t, f.keyless, f.keylessOperator, f.monthly)
if verdict != "Incomplete; missing: payment processing" {
t.Errorf("with no Stripe key the product page reads %q, want \"Incomplete; missing: payment processing\"", verdict)
}
if !strings.Contains(doc.Text(), "Stripe is not configured for this deployment") {
t.Error("with no Stripe key the product page does not say Stripe is not configured")
}
}
func (f *stripeKeyEnv) keylessCheckout(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/billing/checkout", strings.NewReader(url.Values{"price_id": {f.tierPrice.ID}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(f.keylessMember)
if rec := f.keyless.serve(req); rec.Code != http.StatusNotFound {
t.Errorf("with no Stripe key POST /billing/checkout answered %d, want 404", rec.Code)
}
}
// TestTheStripeKeyDecidesWhatThePagesSell holds, through the router, that the
// Products page, the operator's product page and Checkout's route answer from
// the one fact of whether the deployment has a key, over mappings an earlier
// key left. A Stripe mock that answers every call with an error fails any page
// that calls Stripe.
func TestTheStripeKeyDecidesWhatThePagesSell(t *testing.T) {
db := dbtest.Open(t)
t.Cleanup(stripetest.Install(&stripetest.MockBackend{Err: errors.New("stripetest: the key test answers no Stripe call")}))
f := newStripeKeyEnv(t, db)
t.Run("with a key the Products page offers both", f.keyedOffersBoth)
t.Run("with a key a one-time product is not listed and reads Incomplete", f.keyedOneTime)
t.Run("with no key the Products page offers neither", f.keylessOffersNeither)
t.Run("with a key the product page reads Purchasable", f.keyedProductPage)
t.Run("with no key the product page says Stripe is not configured", f.keylessProductPage)
t.Run("with no key Checkout is not served", f.keylessCheckout)
}
// memberPagesEnv is the member pages' fixture: a router on the test database
// and a Stripe mock that records every call.
type memberPagesEnv struct {
@@ -379,7 +546,7 @@ func (f *memberPagesEnv) subscribe(t *testing.T, customer string, price world.Pr
}
func (f *memberPagesEnv) noKeyNoReconcile(t *testing.T) {
bare := newRouteEnv(t, func(c *Config) { onDatabase(f.db)(c); c.StripeAPIKey = "" })
bare := newRouteEnv(t, func(c *Config) { onDatabase(f.db)(c); c.StripeMode = "" })
o, _, cookie := member(t, f.w, bare, f.orgType, "Keyless")
cus := f.customer(t, o)
f.dashboard(t, bare, cookie, "?checkout=success")
+2 -4
View File
@@ -101,7 +101,7 @@ func setViper(t *testing.T, key string, value any) {
t.Cleanup(func() { viper.Set(key, prev) })
}
// routeConfig is the configured profile: a Stripe key and webhook secret,
// routeConfig is the configured profile: a Stripe key's mode (test),
// one dashboard card, the fake mount, a mount with static files and one
// without, and one integration configuration.
func routeConfig(kept *Deps) Config {
@@ -113,8 +113,6 @@ func routeConfig(kept *Deps) Config {
BillingQ: billing.New(nil),
StripeQ: stripedb.New(nil),
DomainsQ: domains.New(nil),
StripeAPIKey: "sk_test_routes",
StripeWebhookSecret: "whsec_routes",
StripeMode: "test",
StripeKeyFingerprint: "fingerprint-routes",
BaseURL: routesBaseURL,
@@ -383,7 +381,7 @@ func routeTable(t *testing.T, env *routeEnv) string {
_, pattern := env.rt.mux.Handler(req)
fmt.Fprintf(&b, "extra: %s %s %s pattern=%s\n anon: %s\n", x.name, x.method, x.path, pattern, answer(t, env.serve(req)))
}
bare := newRouteEnvOver(t, func(c *Config) { c.StripeAPIKey = "" }, env.auth)
bare := newRouteEnvOver(t, func(c *Config) { c.StripeMode = "" }, env.auth)
req := probe(http.MethodPost, "/billing/checkout", nil)
_, pattern := bare.rt.mux.Handler(req)
fmt.Fprintf(&b, "bare: POST /billing/checkout pattern=%s\n anon: %s\n", pattern, answer(t, bare.serve(req)))
@@ -62,7 +62,7 @@ func newPlanLaddersFixture(t *testing.T) *planLaddersFixture {
f.shared = product("Shared")
extra := product("Extra")
w.EditProduct(extra, func(p *billing.UpdateProductParams) {
p.DisplayCategory = sql.NullString{String: displayCategoryAddon, Valid: true}
p.DisplayCategory = sql.NullString{String: "addon", Valid: true}
})
product("Bare")
w.Draft(product("Old"))
@@ -148,7 +148,7 @@ func rowDetailText(t *testing.T, doc *htmltest.Node, label string) string {
}
func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
priced := PriceReadiness{HasActivePrice: true, PriceID: "x", StripeMapped: true}
priced := PriceReadiness{HasActivePrice: true, PriceID: "x", Recurring: true, StripeMapped: true}
t.Run("incomplete shows missing list", func(t *testing.T) {
vm := buildVM(product("published", true, true), shapeFor(true, "unpriced"), PriceReadiness{}, false, "", nil)
@@ -176,8 +176,8 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
// One unpriced, off-ladder listed product: 4 met preconditions
// (entitlement set, published, active, listed), 2 unmet (active
// price, payment processing), plus the member-catalog-visibility
// diagnostic row (hidden — off-ladder, no addon category) that is
// neither met nor missing.
// diagnostic row (Not shown: on no ladder, with no price the member
// pages could sell) that is neither met nor missing.
vm := buildVM(product("published", true, true), shapeFor(true, "unpriced"), PriceReadiness{}, false, "", nil)
_, doc := renderPanel(t, vm)
@@ -195,25 +195,22 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
t.Run("sync-pending renders the amber pending badge", func(t *testing.T) {
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", SyncPending: true}, false, "", nil)
PriceReadiness{HasActivePrice: true, PriceID: "x", Recurring: true, SyncPending: true}, false, "", nil)
_, doc := renderPanel(t, vm)
wantCountOfText(t, doc, "span.badge", "Sync pending", 1)
wantCountOfText(t, doc, "span.badge", "Met", 5)
})
t.Run("fully purchasable but off-ladder: six met badges, zero missing, qualifier shown", func(t *testing.T) {
// Off-ladder (ladder_count=0, no addon category), so purchasable but not
// findable: the verdict badge must carry the catalog-visibility qualifier
// alongside ">Purchasable" (UX-8).
t.Run("fully purchasable off-ladder: six met badges, zero missing, shown", func(t *testing.T) {
// A product on no ladder that can be bought is shown: the member pages
// list it exactly when the price rule passes.
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"), priced, false, "", nil)
_, doc := renderPanel(t, vm)
out, doc := renderPanel(t, vm)
wantCountOfText(t, doc, "span.badge", "Met", 6)
wantCountOfText(t, doc, "span.badge", "Missing", 0)
// product-management "The readiness panel prints its verdict": the
// qualifier rides inside .Readiness.Verdict itself now ("Purchasable;
// not shown in the member catalog"), not a separately-rendered span.
wantVerdict(t, doc, "success", "Purchasable", vm.Verdict)
wantTextIn(t, doc, "#product-readiness .alert-success strong", "not shown in the member catalog")
wantAbsent(t, out, "not shown in the member catalog")
wantCountOfText(t, doc, "span", "Shown", 1)
})
t.Run("fully purchasable on-ladder: qualifier absent", func(t *testing.T) {
@@ -226,7 +223,7 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
t.Run("sync-failed renders the red failed badge and a Retry action", func(t *testing.T) {
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", SyncPending: true, StripeConfigured: true},
PriceReadiness{HasActivePrice: true, PriceID: "x", Recurring: true, SyncPending: true, StripeConfigured: true},
true, "stripe: invalid api key", nil)
_, doc := renderPanel(t, vm)
wantCountOfText(t, doc, "span.badge", "Sync failed", 1)
@@ -239,14 +236,14 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
t.Run("sync-pending emits live-poll attributes; terminal states do not", func(t *testing.T) {
pending := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", SyncPending: true, StripeConfigured: true}, false, "", nil)
PriceReadiness{HasActivePrice: true, PriceID: "x", Recurring: true, SyncPending: true, StripeConfigured: true}, false, "", nil)
_, doc := renderPanel(t, pending)
panel := doc.One(t, "#product-readiness")
wantAttr(t, panel, "hx-trigger", "every 3s")
wantAttr(t, panel, "hx-get", "/partials/operator/products/p1/readiness")
done := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", StripeMapped: true, StripeConfigured: true}, false, "", nil)
PriceReadiness{HasActivePrice: true, PriceID: "x", Recurring: true, StripeMapped: true, StripeConfigured: true}, false, "", nil)
out, doc := renderPanel(t, done)
wantAbsent(t, out, `hx-trigger="every 3s"`)
doc.None(t, "#product-readiness[hx-get]")
@@ -283,7 +280,7 @@ func TestProductEditRendersPurchasabilityPanel(t *testing.T) {
t.Run("stripe not configured renders the empty-state, not the Sync button", func(t *testing.T) {
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"),
PriceReadiness{HasActivePrice: true, PriceID: "x", StripeConfigured: false}, false, "", nil)
PriceReadiness{HasActivePrice: true, PriceID: "x", Recurring: true, StripeConfigured: false}, false, "", nil)
out, doc := renderPanel(t, vm)
wantTextIn(t, doc, "#product-readiness .alert", "Set the Stripe API key and webhook secret")
wantAbsent(t, out, "Sync to Stripe")
+4 -11
View File
@@ -24,12 +24,6 @@ import (
"github.com/sqlc-dev/pqtype"
)
// displayCategoryAddon is the display_category value the readiness panel's
// member catalog visibility row reads (product_readiness.go). The member
// catalog shows display_category as a label and does not decide what it lists
// by it.
const displayCategoryAddon = "addon"
// OperatorProductViewModel represents a product for operator template rendering.
type OperatorProductViewModel struct {
ProductID string
@@ -62,9 +56,8 @@ type OperatorProductViewModel struct {
// page's own Product, which never renders a Status cell.
VerdictState string
// StatusNote is the list row's muted text beside the Status badge: the
// catalog qualifier ("Not shown in the member catalog") or the first
// unmet leg ("Missing: Entitlement set"). Empty for draft/retired rows
// and for a fully purchasable product with no qualifier.
// first unmet leg ("Missing: Entitlement set"). Empty for draft and
// retired rows and for a purchasable or ready product.
StatusNote string
}
@@ -904,8 +897,8 @@ func (h *OperatorPartialsHandler) loadProductEditDataResult(r *http.Request, pro
Matches: h.loadProductNameMatches(r, product.Name, productID),
}
// Purchasability readiness: compose the shared price+mapping gate with the
// visibility/structural preconditions the member catalog enforces upstream.
// Purchasability readiness: the price facts the member pages build, beside
// the product's shape and set.
pr, err := computePriceReadiness(r.Context(), h.BillingQ, h.StripeQ, h.StripeConfigured, productID)
if err != nil {
h.Logger.Error("failed to read price readiness", slog.Any("error", err), slog.String("product_id", productID))
@@ -95,7 +95,7 @@ func newProductsListFixture(t *testing.T) *productsListFixture {
w.MapPrice(price)
rule(addon, goldenForumKey)
w.EditProduct(addon, func(p *billing.UpdateProductParams) {
p.DisplayCategory = sql.NullString{String: displayCategoryAddon, Valid: true}
p.DisplayCategory = sql.NullString{String: "addon", Valid: true}
})
noSet, price := product("No Set")
@@ -161,8 +161,8 @@ func (e *productListEnv) seedActivePrice(productID string, isDefault bool) strin
e.t.Helper()
var id string
err := e.database.QueryRowContext(context.Background(),
`INSERT INTO core.prices (product_id, currency, unit_amount, is_active, is_default)
VALUES ($1, 'usd', 1000, true, $2) RETURNING price_id`,
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active, is_default)
VALUES ($1, 'usd', 1000, 'month', true, $2) RETURNING price_id`,
productID, isDefault,
).Scan(&id)
if err != nil {
+42 -25
View File
@@ -27,8 +27,11 @@ import (
type positionLoader struct {
billing billing.Querier
ents entitlements.Querier
// stripeQ is nil when Stripe is not configured, which leaves every price
// unmapped.
// keyMode is the mode of the deployment's Stripe key, "" when it has none;
// the price facts judge a mapping's reach under it.
keyMode string
// stripeQ is nil when no database is set or Stripe is not configured
// (stripeConfigured), which leaves every price unmapped.
stripeQ internalstripe.Querier
now func() time.Time
predictions map[string]*cancelPrediction
@@ -48,15 +51,19 @@ type positionLoader struct {
}
// newPositionLoader builds a loader over the billing and entitlements
// queriers. db may be nil (Stripe not configured).
func newPositionLoader(billingQ billing.Querier, entsQ entitlements.Querier, db *sql.DB, now func() time.Time) *positionLoader {
// queriers. keyMode is the mode of the deployment's Stripe key, "" when it
// has none. The loader reads price mappings only when db is set and
// stripeConfigured(keyMode): with no key every price is unmapped, so nothing
// is offered for sale whatever mappings an earlier key left.
func newPositionLoader(billingQ billing.Querier, entsQ entitlements.Querier, db *sql.DB, keyMode string, now func() time.Time) *positionLoader {
l := &positionLoader{
billing: billingQ,
ents: entsQ,
keyMode: keyMode,
now: now,
predictions: make(map[string]*cancelPrediction),
}
if db != nil {
if db != nil && stripeConfigured(keyMode) {
l.stripeQ = internalstripe.New(db)
}
return l
@@ -424,27 +431,37 @@ type priceFacts struct {
Text string
}
// defaultPrice reads the facts of the product's default price: the price
// members are offered, per computePriceReadiness (is_default = TRUE,
// oldest-active fallback). A price is purchasable only when its Stripe price
// mapping carries an id keyMode can reach; a mapping recorded in the other
// environment, or marked stale by the environment check, is not
// (stripe-environment-stamp D5). A product with no active price has the zero
// Price.
func (l *positionLoader) defaultPrice(ctx context.Context, productID, keyMode string) (priceFacts, error) {
r, err := computePriceReadiness(ctx, l.billing, l.stripeQ, l.stripeQ != nil, productID)
if err != nil {
return priceFacts{}, err
}
// offeredPrice is the offer decision's facts of the price the readiness
// tracks (computePriceReadiness): the product's default price, recurring or
// not, purchasable when its Stripe mapping carries an id keyMode can reach;
// the zero Price when the product has no active price. The member pages and
// the readiness panel both build a target's price with it, so they cannot
// disagree about a price.
func offeredPrice(r PriceReadiness, keyMode string) plans.Price {
if !r.HasActivePrice {
return priceFacts{}, nil
return plans.Price{}
}
facts := priceFacts{Price: plans.Price{
return plans.Price{
ID: r.PriceID,
Default: true,
Recurring: r.Recurring,
Purchasable: readinessPurchasable(r, keyMode),
}}
}
}
// defaultPrice reads the facts of the product's default price: the price
// members are offered, per computePriceReadiness (is_default = TRUE,
// oldest-active fallback). A price is purchasable only when its Stripe price
// mapping carries an id the loader's key mode can reach; a mapping recorded in
// the other environment, or marked stale by the environment check, is not
// (stripe-environment-stamp D5). A product with no active price has the zero
// Price.
func (l *positionLoader) defaultPrice(ctx context.Context, productID string) (priceFacts, error) {
r, err := computePriceReadiness(ctx, l.billing, l.stripeQ, l.stripeQ != nil, productID)
if err != nil {
return priceFacts{}, err
}
facts := priceFacts{Price: offeredPrice(r, l.keyMode)}
if !facts.Price.Recurring || !facts.Price.Purchasable {
return facts, nil
}
@@ -469,7 +486,7 @@ func readinessPurchasable(r PriceReadiness, keyMode string) bool {
// picks, compared as the database returns it and never through the string the
// request posted (offer-enforcement D14). Whether it is purchasable is read
// for the default only: the decision refuses any other price before it asks.
func (l *positionLoader) postedPrice(ctx context.Context, price billing.Price, keyMode string) (plans.Price, error) {
func (l *positionLoader) postedPrice(ctx context.Context, price billing.Price) (plans.Price, error) {
r, err := computePriceReadiness(ctx, l.billing, l.stripeQ, l.stripeQ != nil, price.ProductID)
if err != nil {
return plans.Price{}, err
@@ -479,7 +496,7 @@ func (l *positionLoader) postedPrice(ctx context.Context, price billing.Price, k
ID: price.PriceID,
Default: isDefault,
Recurring: isRecurringPrice(price),
Purchasable: isDefault && readinessPurchasable(r, keyMode),
Purchasable: isDefault && readinessPurchasable(r, l.keyMode),
Currency: price.Currency,
Interval: price.RecurringInterval.String,
}, nil
@@ -556,7 +573,7 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
if err != nil {
return plans.Move{}, fmt.Errorf("switch: list tiers for ladder %s: %w", ladderID, err)
}
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)
held, err := loader.positions(ctx, orgID)
if err != nil {
return plans.Move{}, fmt.Errorf("switch: %w", err)
@@ -569,7 +586,7 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
if err != nil {
return plans.Move{}, fmt.Errorf("switch: read the position on ladder %s: %w", ladderID, err)
}
posted, err := loader.postedPrice(ctx, price, h.StripeMode)
posted, err := loader.postedPrice(ctx, price)
if err != nil {
return plans.Move{}, fmt.Errorf("switch: %w", err)
}
@@ -630,7 +647,7 @@ func (h *MemberProductsHandler) resumeOfferFor(ctx context.Context, orgID, poste
if err != nil {
return "", fmt.Errorf("resume: list tiers for ladder %s: %w", ladderID, err)
}
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)
held, err := loader.positions(ctx, orgID)
if err != nil {
return "", fmt.Errorf("resume: %w", err)
+97 -72
View File
@@ -13,19 +13,18 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
)
// PriceReadiness captures whether a product's pricing is wired up for purchase:
// it has an active price, and that price is mapped to a live Stripe price in
// this deployment. SyncPending distinguishes "a Stripe sync was enqueued but
// PriceReadiness is the facts of a product's tracked price: whether it exists,
// is recurring, and has a Stripe mapping, and that mapping's sync state.
// SyncPending distinguishes "a Stripe sync was enqueued but
// the mapping has not landed yet" from "never mapped", so the operator UI can
// show an in-flight state rather than a hard failure.
//
// This is the single definition of the price + Stripe-mapping purchasability
// gate, shared by the member catalog (the position loader's default-price
// facts) and the operator product readiness panel so the two can never
// disagree about whether a product can be bought. A member is purchasable on
// this axis exactly when HasActivePrice && StripeMapped.
// It carries the price facts the member pages and the readiness panel both
// build a target's price from (offeredPrice), so the two can never disagree
// about a price.
type PriceReadiness struct {
HasActivePrice bool
PriceID string
@@ -35,9 +34,8 @@ type PriceReadiness struct {
StripeMapped bool
SyncPending bool
// StripeConfigured reports whether Stripe is wired for this deployment
// (the Stripe API key and webhook secret are both set). When false, "not
// mapped" means "Stripe is off", not "needs syncing"; the panel renders an
// explanatory state, not an action.
// (stripeConfigured). When false, "not mapped" means "Stripe is off", not
// "needs syncing"; the panel renders an explanatory state, not an action.
StripeConfigured bool
// Livemode, VerifiedAt and SyncStatus are the mapping's environment
// stamp (stripe-environment-stamp D2), carried as read so the readiness
@@ -51,6 +49,17 @@ type PriceReadiness struct {
SyncStatus string
}
// stripeConfigured reports whether the deployment sells through Stripe, from
// the mode boot derives from its API key ("" when it has none). Boot refuses
// a key whose prefix names no mode, and a key set without its webhook secret
// or a secret without its key (the Stripe configuration group), so the mode
// is set exactly when both are. The member pages' position loader, the
// readiness panel, Checkout's route and the checkout return's reconcile all
// ask it, so none of them sells or answers for a sale the others refuse.
func stripeConfigured(keyMode string) bool {
return keyMode != ""
}
// computePriceReadiness resolves the product's tracked price and its Stripe
// mapping state. stripeQ may be nil (Stripe not configured), in which case the
// price is never considered mapped. The tracked price is the product's DEFAULT
@@ -62,9 +71,9 @@ type PriceReadiness struct {
// A failed read of the prices, or of the price's mapping for any reason but
// sql.ErrNoRows (which stays "not mapped"), is returned: a caller that offers
// a purchase must not read a failure as "nothing to buy".
func computePriceReadiness(ctx context.Context, billingQ billing.Querier, stripeQ internalstripe.Querier, stripeConfigured bool, productID string) (PriceReadiness, error) {
func computePriceReadiness(ctx context.Context, billingQ billing.Querier, stripeQ internalstripe.Querier, configured bool, productID string) (PriceReadiness, error) {
var r PriceReadiness
r.StripeConfigured = stripeConfigured
r.StripeConfigured = configured
prices, err := billingQ.ListPricesByProduct(ctx, productID)
if err != nil {
@@ -78,7 +87,7 @@ func computePriceReadiness(ctx context.Context, billingQ billing.Querier, stripe
r.PriceID = tracked.PriceID
r.Recurring = isRecurringPrice(tracked)
if !stripeConfigured || stripeQ == nil {
if !configured || stripeQ == nil {
return r, nil
}
mapping, err := stripeQ.GetPriceMappingByPriceID(ctx, r.PriceID)
@@ -126,9 +135,9 @@ func mappingState(r *PriceReadiness, m internalstripe.PriceMapping) {
// (ListPriceMappingsByPriceIDs), keyed by price_id. The tracked-price
// selection mirrors computePriceReadiness exactly: the DEFAULT active
// price, or the oldest active price when none is marked default.
func priceReadinessFromBatches(stripeConfigured bool, prices []billing.Price, mappingsByPriceID map[string]internalstripe.PriceMapping) PriceReadiness {
func priceReadinessFromBatches(configured bool, prices []billing.Price, mappingsByPriceID map[string]internalstripe.PriceMapping) PriceReadiness {
var r PriceReadiness
r.StripeConfigured = stripeConfigured
r.StripeConfigured = configured
if len(prices) == 0 {
return r
@@ -138,7 +147,7 @@ func priceReadinessFromBatches(stripeConfigured bool, prices []billing.Price, ma
r.PriceID = tracked.PriceID
r.Recurring = isRecurringPrice(tracked)
if !stripeConfigured {
if !configured {
return r
}
mapping, ok := mappingsByPriceID[r.PriceID]
@@ -240,11 +249,12 @@ func resolveProviderReadinessRows(rules []entitlements.EntitlementSetRule, resou
// ProductReadinessVM is the view model for the operator product purchasability
// readiness panel. It reads core.product_shape (set presence + billing shape)
// composed with the shared price+mapping gate (PriceReadiness). Readiness is
// set_present with at least one active rule AND (is_public ⇒ the product is
// priced and Stripe-mapped), and is_active either way; an unlisted wrap
// product (is_public=false) needs neither price nor listing, so those rows
// report "n/a" or "unlisted" (Doc 41 §5.4, design D2/D3).
// composed with the price facts (PriceReadiness) and the member offer's price
// rule. Readiness is set_present with at least one active rule AND (is_public
// ⇒ the member offer's price rule passes, plans.PriceRule), and is_active
// either way; an unlisted wrap product (is_public=false) needs neither price
// nor listing, so those rows report "n/a" or "unlisted" (Doc 41 §5.4, design
// D2/D3).
type ProductReadinessVM struct {
Purchasable bool
Verdict string
@@ -270,12 +280,6 @@ type ProductReadinessVM struct {
// template can emit live-update polling attributes only while a sync is
// actually in flight.
SyncPending bool
// CatalogQualifier, when non-empty, is a short qualifier the template
// renders next to a Purchasable verdict for an is_public product the
// member catalog does not render (no ladder tier, no addon category).
// Empty whenever the product is findable, not is_public, or not
// purchasable; the qualifier only ever attaches to "Purchasable".
CatalogQualifier string
}
// memberGate is the product-level member-visibility gate, decomposed into its
@@ -339,14 +343,19 @@ type readinessInputs struct {
// whether a key in stripeMode can reach it (design D3, and
// stripe-environment-stamp D5). met is false for the two states where the
// id is out of reach, which makes the Payment processing row unmet and
// the verdict name it.
// the verdict name it. met is readinessPurchasable's answer, the reach the
// member pages' price facts carry (offeredPrice), so the row and the member
// pages cannot disagree about a recorded id; the switch only picks the words.
//
// An unknown mode names nothing rather than guessing, and reaches
// everything: a deployment whose key the console could not classify has no
// environment to compare a stamp against.
// An empty mode names nothing and is met. A booted console has an empty mode
// only with no key, and then its readiness holds no mapping
// (stripeConfigured), so the branch is reached only by a view model built by
// hand; whether an empty mode should reach a recorded id at all is
// MappingReachable's question.
func syncedPaymentDetail(pr PriceReadiness, stripeMode string) (detail string, met bool) {
met = readinessPurchasable(pr, stripeMode)
if stripeMode == "" {
return "Synced", true
return "Synced", met
}
other := internalstripe.OtherMode(stripeMode)
switch {
@@ -355,13 +364,13 @@ func syncedPaymentDetail(pr PriceReadiness, stripeMode string) (detail string, m
// answered resource_missing, so the key's environment is the one
// the id is not in; the recorded flag may agree and still be no
// help, since two Stripe environments both report livemode false.
return "Not found in " + stripeMode + " mode", false
return "Not found in " + stripeMode + " mode", met
case !internalstripe.MappingAgrees(pr.Livemode, stripeMode):
return "Synced in " + other + ", key is " + stripeMode, false
return "Synced in " + other + ", key is " + stripeMode, met
case !pr.Livemode.Valid || !pr.VerifiedAt.Valid:
return "Synced, unverified", true
return "Synced, unverified", met
}
return "Synced, " + stripeMode, true
return "Synced, " + stripeMode, met
}
// readinessState is a precondition row's check state. The annotation states
@@ -375,6 +384,7 @@ const (
statePending readinessState = "pending"
stateFailed readinessState = "failed"
stateNoRules readinessState = "no_rules"
stateOneTime readinessState = "one_time"
stateNA readinessState = "n/a"
stateUnlisted readinessState = "unlisted"
stateShown readinessState = "shown"
@@ -447,7 +457,8 @@ func lifecycleRows(gate memberGate) []ProductReadinessRow {
// is the Payment processing row's answer to the second half of the mapping
// question: a recorded id the current key can actually reach
// (stripe-environment-stamp D5). It is false for an unmapped, pending or
// failed price; the purchasable verdict reads it beside pr.StripeMapped.
// failed price; buildProductReadinessVM reads it to offer the Sync control again
// and to relabel it (unreachable).
// syncPending is true when the row shows an in-flight sync.
func commerceRows(in readinessInputs, syncFailed bool, syncError string) (rows []ProductReadinessRow, reachable, syncPending bool) {
pr := in.price
@@ -467,8 +478,20 @@ func commerceRows(in readinessInputs, syncFailed bool, syncError string) (rows [
return []ProductReadinessRow{priceRow, stripeRow}, false, false
}
priceRow.State = metOrUnmet(priced && pr.HasActivePrice)
priceRow.Detail = "Add an active price on the Prices view."
switch {
case !priced || !pr.HasActivePrice:
priceRow.State = stateUnmet
priceRow.Detail = "Add an active price on the Prices view."
case !pr.Recurring:
// The price rule refuses a one-time default price for a tier and a
// product on no ladder alike (plans.PriceRule, OneTime): Checkout
// creates a subscription.
priceRow.State = stateOneTime
priceRow.MissingName = "recurring default price"
priceRow.Detail = "Make a recurring price the default on the Prices view; a one-time price is not sold."
default:
priceRow.State = stateMet
}
switch {
case pr.StripeMapped:
@@ -524,39 +547,33 @@ func missingFrom(rows []ProductReadinessRow) []string {
return missing
}
// catalogRow is the member-catalog-visibility row, reported for is_public
// products only; the member catalog renders exactly two sections, plan-ladder
// tiers and display_category='addon' add-ons (member_plans.go,
// member_addons.go), so a product outside both is purchasable but has no
// catalog path a member can find. This never flips the verdict; it only earns
// the verdict a qualifier so "Purchasable" can never be misread as "findable"
// (product-management delta, Requirement: readiness panel surfaces catalog
// visibility).
func catalogRow(isPublic, findable bool) ProductReadinessRow {
// catalogRow is the member-catalog-visibility row, reported for listed
// products: shown is whether the member pages draw the product for a member
// who holds nothing on its ladders (buildProductReadinessVM). It is a
// diagnostic, never a precondition, so it never enters Missing; an unlisted
// product's row is not applicable.
func catalogRow(isPublic, shown bool) ProductReadinessRow {
row := ProductReadinessRow{Label: "Member catalog visibility"}
switch {
case !isPublic:
row.State = stateNA
row.Detail = "Unlisted product; never shown in the member catalog."
case findable:
case shown:
row.State = stateShown
row.Detail = "This product is a plan-ladder tier or is tagged display_category=addon, so the member catalog renders it; a product that is not a tier is listed only while it has an active, synced, recurring price."
default:
row.State = stateHidden
row.Detail = "This product is on no plan ladder and is not tagged display_category=addon, so the member catalog does not render it; members have no path to find it."
}
return row
}
// verdict sets vm's Verdict and CatalogQualifier from vm.Missing and
// vm.Purchasable, which the caller sets first, and the facts it is passed.
// verdict sets vm's Verdict from vm.Missing and vm.Purchasable, which the
// caller sets first, and the facts it is passed.
// Precedence (design D3): what the product confers and whether it is
// published come first, because neither "Inactive" nor "Purchasable" says
// anything true about a product that confers nothing. Then is_active, which
// is a deliberate state rather than a gap: an inactive product is offered by
// neither the catalog nor the grant form, and "Purchasable" or "Ready to
// grant" would both be false of it.
func (vm *ProductReadinessVM) verdict(setPresent, hasRules bool, gate memberGate, findable bool) {
func (vm *ProductReadinessVM) verdict(setPresent, hasRules bool, gate memberGate) {
switch {
case !setPresent || !hasRules || !gate.Published:
vm.Verdict = "Incomplete; missing: " + strings.Join(vm.Missing, ", ")
@@ -564,10 +581,6 @@ func (vm *ProductReadinessVM) verdict(setPresent, hasRules bool, gate memberGate
vm.Verdict = "Inactive"
case vm.Purchasable && gate.Public:
vm.Verdict = "Purchasable"
if !findable {
vm.Verdict += "; not shown in the member catalog"
vm.CatalogQualifier = "Not shown in the member catalog"
}
case vm.Purchasable:
vm.Verdict = "Ready to grant"
default:
@@ -631,19 +644,31 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
}
}
// The price rule Checkout, the plan switch and the member pages ask
// (plans.PriceRule), over the price facts the member pages build
// (offeredPrice): the member gate, the default price, its recurrence and
// its reach. A tier and a product on no ladder answer alike; which move
// an organization is offered toward a tier depends on its position,
// which the panel does not judge.
sells := plans.PriceRule(plans.Target{
ProductID: product.ProductID,
Listed: gate.OK(),
Price: offeredPrice(pr, in.stripeMode),
}) == plans.NoReason
if isPublic {
// stripeReachable as well as StripeMapped: an id recorded in the
// environment the key is not in, or one the check could not fetch,
// is an id no member checkout can use, and checkout refuses it
// (stripe-environment-stamp D5).
vm.Purchasable = gate.Published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && stripeReachable && providersOK
vm.Purchasable = sells && priced && setPresent && hasRules && providersOK
} else {
vm.Purchasable = gate.Published && gate.Active && setPresent && hasRules && providersOK
}
findable := shape.LadderCount > 0 || (product.DisplayCategory.Valid && product.DisplayCategory.String == displayCategoryAddon)
vm.Rows = append(vm.Rows, catalogRow(isPublic, findable))
vm.verdict(setPresent, hasRules, gate, findable)
// The member pages draw a listed tier in its ladders' plan sections and
// list a product on no ladder exactly when the price rule passes
// (buildAddonsData, plans.OffLadder), so a Purchasable product is always
// shown.
shown := gate.OK() && (shape.LadderCount > 0 || sells)
vm.Rows = append(vm.Rows, catalogRow(isPublic, shown))
vm.verdict(setPresent, hasRules, gate)
return vm
}
@@ -653,9 +678,9 @@ func buildProductReadinessVM(in readinessInputs, syncFailed bool, syncError stri
// itself is the state and there is no note — an editorial state the
// operator chose, which the verdict would only restate as "missing:
// Published". For a published product the state is the verdict itself
// (purchasable, ready_to_grant, or incomplete) and the note is the muted
// qualifier or the first unmet leg, named exactly as the readiness panel's
// own rows name it so the list and the panel can never disagree.
// (purchasable, ready_to_grant, or incomplete) and the note is the first unmet
// leg, named exactly as the readiness panel's own rows name it so the list and
// the panel can never disagree.
func productListStatus(lifecycleStatus string, isPublic bool, vm ProductReadinessVM) (state string, note string) {
if lifecycleStatus != "published" {
return lifecycleStatus, ""
@@ -666,7 +691,7 @@ func productListStatus(lifecycleStatus string, isPublic bool, vm ProductReadines
case vm.Verdict == "Inactive":
return "inactive", ""
case vm.Purchasable && isPublic:
return "purchasable", vm.CatalogQualifier
return "purchasable", ""
case vm.Purchasable:
return "ready_to_grant", ""
default:
+170 -121
View File
@@ -13,6 +13,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
"github.com/spf13/viper"
)
@@ -25,12 +26,10 @@ func product(lifecycle string, active, public bool) billing.Product {
}
}
// productAddon is product() plus display_category=addon, for member-catalog-
// visibility cases: the catalog renders a product either because it is a
// ladder tier (shapeForLadder) or because it carries this category.
func productAddon(lifecycle string, active, public bool) billing.Product {
p := product(lifecycle, active, public)
p.DisplayCategory = sql.NullString{String: displayCategoryAddon, Valid: true}
// labelled is p with display_category set to label. The label decides nothing
// (product-catalog invariant 2), so the cases that set it show that.
func labelled(p billing.Product, label string) billing.Product {
p.DisplayCategory = sql.NullString{String: label, Valid: true}
return p
}
@@ -71,12 +70,7 @@ func buildVMRules(product billing.Product, shape billing.CoreProductShape, pr Pr
// rowState returns the State of the named precondition row, or "" if absent.
func rowState(vm ProductReadinessVM, label string) string {
for _, r := range vm.Rows {
if r.Label == label {
return string(r.State)
}
}
return ""
return string(readinessRow(vm, label).State)
}
func TestBuildProductReadinessVM(t *testing.T) {
@@ -94,8 +88,8 @@ func TestBuildProductReadinessVM(t *testing.T) {
{
name: "fully configured public product is purchasable",
product: product("published", true, true),
shape: shapeForLadder(1), // on-ladder: findable, no qualifier
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true},
shape: shapeForLadder(1), // a tier: shown
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true},
wantPurchasabl: true,
wantVerdict: "Purchasable",
},
@@ -112,7 +106,7 @@ func TestBuildProductReadinessVM(t *testing.T) {
name: "price present but stripe sync pending",
product: product("published", true, true),
shape: shapeFor(true, "recurring"),
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", SyncPending: true},
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true, SyncPending: true},
wantPurchasabl: false,
rowLabel: "Payment processing",
rowState: "pending",
@@ -121,7 +115,7 @@ func TestBuildProductReadinessVM(t *testing.T) {
name: "missing entitlement set is incomplete",
product: product("published", true, true),
shape: shapeFor(false, "recurring"),
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true},
pr: PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true},
wantPurchasabl: false,
rowLabel: "Entitlement set",
rowState: "unmet",
@@ -159,111 +153,164 @@ func TestBuildProductReadinessVM(t *testing.T) {
}
}
// catalogVisibilityCase is one product shape and what the readiness panel says
// of its member-catalog visibility. wantRow and wantVerdict are checked when
// set; the purchasable flag, the qualifier and the Missing list are checked in
// every case.
type catalogVisibilityCase struct {
name string
product billing.Product
shape billing.CoreProductShape
price PriceReadiness
wantRow readinessState
wantPurchasable bool
purchasableWhy string // why wantPurchasable holds; empty means visibility never flips it
wantVerdict string // exact
wantVerdictHas string // substring
wantQualified bool
// memberAnswer is what the member pages do with a product for a member who
// holds nothing on its ladders, from the facts the panel reads: whether they
// draw it and whether they sell it. A listed tier is drawn on every ladder it
// is a tier of (ListPublicPlanProducts) and sold when plans.Across of the
// offer decision's answers from an empty position, one per ladder, offers
// Checkout; a product on no ladder is drawn exactly when plans.OffLadder
// offers it Checkout (buildAddonsData). It is a model of the pages, not the
// pages, and shares offeredPrice with the panel: the tests that hold it to
// the pages are TestBuildAddonsDataOneTimePriceNotListed
// (member_extras_test.go), member_catalog_environment_db_test.go and
// TestTheStripeKeyDecidesWhatThePagesSell (new_handler_db_test.go).
func memberAnswer(p billing.Product, ladders int64, pr PriceReadiness, keyMode string) (drawn, sold bool) {
t := plans.Target{ProductID: "product", Listed: evaluateMemberGate(p).OK(), Price: offeredPrice(pr, keyMode)}
if ladders == 0 {
sold = plans.OffLadder(t).Allows(plans.Checkout)
return sold, sold
}
pos := plans.Position{Tiers: []plans.Tier{{ProductID: "product", Rank: 1}}}
moves := make([]plans.Move, ladders)
for i := range moves {
moves[i] = plans.Offer(pos, t)
}
return t.Listed, plans.Across(moves).Allows(plans.Checkout)
}
// checkCatalogVisibility builds the view model for c and checks the catalog
// row, the verdict and its qualifier. Visibility never flips Purchasable
// (Decision 6) and is a diagnostic, never a precondition, so the row is never
// in Missing.
func checkCatalogVisibility(t *testing.T, c catalogVisibilityCase) {
// panelRow is one product shape of the design's failure table: the facts the
// panel and the member pages read, and what the panel must say of them.
type panelRow struct {
name string
product billing.Product
shape billing.CoreProductShape
price PriceReadiness
mode string // the Stripe key's mode; "" is no key
wantVerdict string
wantCatalog readinessState
// wantOneTime is set on the rows whose default price is one-time: the
// Active price row reads one_time and the products list says what is missing.
wantOneTime bool
}
// oneTimeDetail is the Active price row's detail for a one-time default price.
const oneTimeDetail = "Make a recurring price the default on the Prices view; a one-time price is not sold."
// checkPanelAgainstMemberPages builds the panel and the member pages' answer
// from row's facts and reports, with t.Errorf so one row prints every failed
// check, each way the two can disagree and each text the change removed.
func checkPanelAgainstMemberPages(t *testing.T, row panelRow) {
t.Helper()
vm := buildVM(c.product, c.shape, c.price, false, "", nil)
if c.wantRow != "" {
if got := rowState(vm, "Member catalog visibility"); got != string(c.wantRow) {
t.Errorf("row state = %q, want %s", got, c.wantRow)
mode := row.mode
vm := buildVMRules(row.product, row.shape, row.price, 1, mode)
drawn, sold := memberAnswer(row.product, row.shape.LadderCount, row.price, mode)
catalog := readinessState(rowState(vm, "Member catalog visibility"))
setPresent, _ := row.shape.SetPresent.(bool)
if vm.Verdict != row.wantVerdict {
t.Errorf("%s: verdict %q, want %q", row.name, vm.Verdict, row.wantVerdict)
}
if catalog != row.wantCatalog {
t.Errorf("%s: catalog row %s, want %s", row.name, catalog, row.wantCatalog)
}
if (catalog == stateShown) != drawn {
t.Errorf("%s: the panel reads %s, the member pages draw it: %v", row.name, catalog, drawn)
}
if vm.Purchasable != (sold && setPresent) {
t.Errorf("%s: the panel calls it Purchasable: %v, the member pages sell it: %v", row.name, vm.Purchasable, sold)
}
checkPanelNotes(t, row, vm)
}
// checkPanelNotes holds the parts of the panel that name what is missing: the
// Active price row of a one-time price, the products list's Status note, and
// the absence of the catalog qualifier and of the catalog row from Missing.
func checkPanelNotes(t *testing.T, row panelRow, vm ProductReadinessVM) {
t.Helper()
state, note := productListStatus("published", true, vm)
switch {
case row.wantOneTime:
priceRow := readinessRow(vm, "Active price")
if priceRow.State != stateOneTime || priceRow.Detail != oneTimeDetail {
t.Errorf("%s: Active price row %s %q, want one_time %q", row.name, priceRow.State, priceRow.Detail, oneTimeDetail)
}
if state != "incomplete" || note != "Missing: recurring default price" {
t.Errorf("%s: list status %q %q, want incomplete \"Missing: recurring default price\"", row.name, state, note)
}
case row.wantVerdict == "Purchasable":
if state != "purchasable" || note != "" {
t.Errorf("%s: list status %q %q, want purchasable and no note", row.name, state, note)
}
}
if vm.Purchasable != c.wantPurchasable {
why := c.purchasableWhy
if why == "" {
why = "catalog visibility must never flip the verdict"
}
t.Errorf("Purchasable = %v, want %v (%s)", vm.Purchasable, c.wantPurchasable, why)
}
if c.wantVerdict != "" && vm.Verdict != c.wantVerdict {
t.Errorf("Verdict = %q, want %q", vm.Verdict, c.wantVerdict)
}
if c.wantVerdictHas != "" && !strings.Contains(vm.Verdict, c.wantVerdictHas) {
t.Errorf("Verdict = %q, want it to carry %q", vm.Verdict, c.wantVerdictHas)
}
if qualified := vm.CatalogQualifier != ""; qualified != c.wantQualified {
t.Errorf("CatalogQualifier = %q, want a qualifier: %v", vm.CatalogQualifier, c.wantQualified)
if strings.Contains(vm.Verdict, "not shown") {
t.Errorf("%s: verdict %q carries a catalog qualifier", row.name, vm.Verdict)
}
if slices.Contains(vm.Missing, "Member catalog visibility") {
t.Error("Member catalog visibility must never appear in Missing; it is a diagnostic, not a precondition")
t.Errorf("%s: the catalog row is in Missing", row.name)
}
}
// TestBuildProductReadinessVM_MemberCatalogVisibility pins UX-8: the readiness
// panel's member-catalog-visibility row and verdict qualifier. It never flips
// Purchasable (Decision 6) — only a ladder tier or a display_category=addon
// product is "shown"; everything else public is "hidden" and, when otherwise
// purchasable, earns a qualifier so the verdict cannot be misread as findable.
func TestBuildProductReadinessVM_MemberCatalogVisibility(t *testing.T) {
fullPrice := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true}
cases := []catalogVisibilityCase{
{
name: "ladder tier is shown, verdict carries no qualifier",
product: product("published", true, true),
shape: shapeForLadder(1),
price: fullPrice,
wantRow: stateShown,
wantPurchasable: true,
wantVerdict: "Purchasable",
},
{
name: "addon category is shown, verdict carries no qualifier",
product: productAddon("published", true, true),
shape: shapeFor(true, "recurring"),
price: fullPrice,
wantRow: stateShown,
wantPurchasable: true,
},
{
// ladder_count=0, no display_category: an otherwise fully configured
// public product. Spec: this is an ordinary shape, not limbo — the
// verdict stays Purchasable but gains the catalog-visibility qualifier.
name: "published off-ladder untyped product is an ordinary shape, purchasable but hidden",
product: product("published", true, true),
shape: shapeFor(true, "recurring"),
price: fullPrice,
wantRow: stateHidden,
wantPurchasable: true,
wantVerdictHas: "not shown in the member catalog",
wantQualified: true,
},
{
name: "incomplete off-ladder product carries no qualifier (verdict already says incomplete)",
product: product("published", true, true),
shape: shapeFor(true, "unpriced"),
purchasableWhy: "an unpriced product is not purchasable",
},
{
name: "wrap product's catalog visibility is not applicable",
product: product("published", true, false),
shape: shapeFor(true, "unpriced"),
wantRow: stateNA,
wantPurchasable: true,
},
// readinessRow returns the named row of vm, or the zero row when absent.
func readinessRow(vm ProductReadinessVM, label string) ProductReadinessRow {
for _, r := range vm.Rows {
if r.Label == label {
return r
}
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) { checkCatalogVisibility(t, c) })
return ProductReadinessRow{}
}
// TestTheReadinessPanelAgreesWithTheMemberPages builds the panel and the member
// pages' answer from the same facts for every product shape of the failure
// table in the design of panel-and-member-pages-sell-by-one-rule, and requires
// them to agree: a Purchasable product is sold and always shown, a product the
// pages draw reads Shown, and display_category decides neither.
func TestTheReadinessPanelAgreesWithTheMemberPages(t *testing.T) {
// reachable is a recurring default price with a mapping that records no
// environment, which every key reaches.
reachable := PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true}
oneTime := reachable
oneTime.Recurring = false
otherEnv := reachable
otherEnv.Livemode = sql.NullBool{Bool: true, Valid: true}
unsynced := reachable
unsynced.StripeMapped = false
noPrice := PriceReadiness{StripeConfigured: true}
noKey := PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true}
listed := product("published", true, true)
addon := labelled(listed, "addon")
tier := shapeForLadder(1)
tierOneTime := tier
tierOneTime.BillingShape = "one_time"
tierUnpriced := tier
tierUnpriced.BillingShape = "unpriced"
offLadder := func(billingShape string) billing.CoreProductShape { return shapeFor(true, billingShape) }
const missingRecurring = "Incomplete; missing: recurring default price"
const missingPayment = "Incomplete; missing: payment processing"
const missingPriceAndPayment = "Incomplete; missing: Active price, payment processing"
rows := []panelRow{
{name: "a tier with a recurring reachable price", product: listed, shape: tier, price: reachable, mode: "test", wantVerdict: "Purchasable", wantCatalog: stateShown},
{name: "a tier with a one-time price", product: listed, shape: tierOneTime, price: oneTime, mode: "test", wantVerdict: missingRecurring, wantCatalog: stateShown, wantOneTime: true},
{name: "a tier with no price", product: listed, shape: tierUnpriced, price: noPrice, mode: "test", wantVerdict: missingPriceAndPayment, wantCatalog: stateShown},
{name: "a tier whose mapping is in the other environment", product: listed, shape: tier, price: otherEnv, mode: "test", wantVerdict: missingPayment, wantCatalog: stateShown},
{name: "an unlabelled product on no ladder with a recurring reachable price", product: listed, shape: offLadder("recurring"), price: reachable, mode: "test", wantVerdict: "Purchasable", wantCatalog: stateShown},
{name: "an addon-labelled product on no ladder with a recurring reachable price", product: addon, shape: offLadder("recurring"), price: reachable, mode: "test", wantVerdict: "Purchasable", wantCatalog: stateShown},
{name: "an addon-labelled product on no ladder with a one-time price", product: addon, shape: offLadder("one_time"), price: oneTime, mode: "test", wantVerdict: missingRecurring, wantCatalog: stateHidden, wantOneTime: true},
{name: "an unlabelled product on no ladder with a one-time price", product: listed, shape: offLadder("one_time"), price: oneTime, mode: "test", wantVerdict: missingRecurring, wantCatalog: stateHidden, wantOneTime: true},
{name: "an addon-labelled product on no ladder whose price is not synced", product: addon, shape: offLadder("recurring"), price: unsynced, mode: "test", wantVerdict: missingPayment, wantCatalog: stateHidden},
{name: "an addon-labelled product on no ladder with no price", product: addon, shape: offLadder("unpriced"), price: noPrice, mode: "test", wantVerdict: missingPriceAndPayment, wantCatalog: stateHidden},
{name: "a tier with no Stripe key", product: listed, shape: tier, price: noKey, mode: "", wantVerdict: missingPayment, wantCatalog: stateShown},
{name: "an addon-labelled product on no ladder with no Stripe key", product: addon, shape: offLadder("recurring"), price: noKey, mode: "", wantVerdict: missingPayment, wantCatalog: stateHidden},
{name: "a draft tier", product: product("draft", true, true), shape: tier, price: reachable, mode: "test", wantVerdict: "Incomplete; missing: published", wantCatalog: stateHidden},
{name: "an inactive tier", product: product("published", false, true), shape: tier, price: reachable, mode: "test", wantVerdict: "Inactive", wantCatalog: stateHidden},
{name: "a product on no ladder with no entitlement set", product: listed, shape: shapeFor(false, "recurring"), price: reachable, mode: "test", wantVerdict: "Incomplete; missing: entitlement set", wantCatalog: stateShown},
{name: "a tier of two ladders with a recurring reachable price", product: listed, shape: shapeForLadder(2), price: reachable, mode: "test", wantVerdict: "Purchasable", wantCatalog: stateShown},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkPanelAgainstMemberPages(t, row) })
}
}
@@ -287,7 +334,7 @@ func TestBuildProductReadinessVM_RuleLessSet(t *testing.T) {
// A listed, fully priced and mapped product is held back the same way.
listed := buildVMRules(product("published", true, true), shapeForLadder(1),
PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true, StripeConfigured: true}, 0, "live")
PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true, StripeConfigured: true}, 0, "live")
if listed.Verdict != "Incomplete; missing: rules" {
t.Errorf("listed product Verdict = %q, want %q", listed.Verdict, "Incomplete; missing: rules")
}
@@ -298,7 +345,7 @@ func TestBuildProductReadinessVM_RuleLessSet(t *testing.T) {
// product, an inactive one reads "Inactive" rather than being called ready,
// and an unlisted product's Listed row is a state, not a gap.
func TestBuildProductReadinessVM_InactiveAndListed(t *testing.T) {
mapped := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true, StripeConfigured: true}
mapped := PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true, StripeConfigured: true}
t.Run("inactive unlisted product reads Inactive", func(t *testing.T) {
vm := buildVMRules(product("published", false, false), shapeFor(true, "unpriced"), PriceReadiness{}, 1, "")
@@ -340,10 +387,11 @@ func TestBuildProductReadinessVM_InactiveAndListed(t *testing.T) {
if got := rowState(vm, "Listed"); got != "unlisted" {
t.Errorf("Listed row state = %q, want unlisted (an unlisted product is not unmet)", got)
}
for _, m := range vm.Missing {
if m == "Listed" {
t.Error("Listed must never appear in Missing for an unlisted product")
}
if slices.Contains(vm.Missing, "Listed") {
t.Error("Listed must never appear in Missing for an unlisted product")
}
if got := rowState(vm, "Member catalog visibility"); got != "n/a" {
t.Errorf("unlisted product's catalog row = %q, want n/a", got)
}
})
@@ -365,6 +413,7 @@ func stampedMapping(livemode *bool, verified bool, syncStatus string) PriceReadi
pr := PriceReadiness{
HasActivePrice: true,
PriceID: "p1",
Recurring: true,
StripeMapped: true,
StripeConfigured: true,
SyncStatus: syncStatus,
@@ -528,7 +577,7 @@ func TestBuildProductReadinessVM_StripeSyncAffordance(t *testing.T) {
}{
{
name: "configured + active price + unmapped → can sync",
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1"},
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", Recurring: true},
wantCanSync: true,
wantConfigured: true,
wantStripeState: "unmet",
@@ -536,7 +585,7 @@ func TestBuildProductReadinessVM_StripeSyncAffordance(t *testing.T) {
},
{
name: "not configured → no action, explanatory detail",
pr: PriceReadiness{StripeConfigured: false, HasActivePrice: true, PriceID: "p1"},
pr: PriceReadiness{StripeConfigured: false, HasActivePrice: true, PriceID: "p1", Recurring: true},
wantCanSync: false,
wantConfigured: false,
wantStripeState: "unmet",
@@ -544,14 +593,14 @@ func TestBuildProductReadinessVM_StripeSyncAffordance(t *testing.T) {
},
{
name: "sync pending → no action",
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", SyncPending: true},
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", Recurring: true, SyncPending: true},
wantCanSync: false,
wantConfigured: true,
wantStripeState: "pending",
},
{
name: "already mapped → no action",
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", StripeMapped: true},
pr: PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true},
wantCanSync: false,
wantConfigured: true,
wantStripeState: "met",
@@ -647,7 +696,7 @@ func checkProviderRows(t *testing.T, c providerRowsCase, keys map[string]entitle
}
}
if c.blocksVerdict {
pr := PriceReadiness{HasActivePrice: true, PriceID: "p1", StripeMapped: true}
pr := PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true}
vm := buildVM(product("published", true, true), shapeFor(true, "recurring"), pr, false, "", rows)
if vm.Purchasable {
t.Error("Purchasable = true, want false with an unconfigured delivering provider")
+14 -16
View File
@@ -37,19 +37,17 @@ import (
// Config holds the configuration for the server.
type Config struct {
Env string
Logger *slog.Logger
Database *sql.DB // Raw DB connection
IdentityQ identity.Querier // Identity module queries
OrgQ organization.Querier // Organization module queries
EntitlementsQ entitlements.Querier // Entitlements module queries
BillingQ billing.Querier // Billing module queries
StripeQ stripedb.Querier // Stripe module queries
DomainsQ domains.Querier // Domains registry queries
Temporal *clientholder.Holder // empty until Temporal connects; handlers read Client per request
StripeWebhookSecret string // Stripe webhook signing secret
StripeAPIKey string // Stripe API secret key
StripeDashboardURL string // Stripe dashboard base URL for deep links
Env string
Logger *slog.Logger
Database *sql.DB // Raw DB connection
IdentityQ identity.Querier // Identity module queries
OrgQ organization.Querier // Organization module queries
EntitlementsQ entitlements.Querier // Entitlements module queries
BillingQ billing.Querier // Billing module queries
StripeQ stripedb.Querier // Stripe module queries
DomainsQ domains.Querier // Domains registry queries
Temporal *clientholder.Holder // empty until Temporal connects; handlers read Client per request
StripeDashboardURL string // Stripe dashboard base URL for deep links
// StripeMode is "test" or "live", derived at boot from the API key's
// prefix ("" when no key is configured). Every surface that shows or
// acts on the mode reads it from here, so none can disagree with the
@@ -621,7 +619,7 @@ func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConf
StripeDashboardURL: cfg.StripeDashboardURL,
StripeMode: cfg.StripeMode,
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
StripeConfigured: cfg.StripeAPIKey != "" && cfg.StripeWebhookSecret != "",
StripeConfigured: stripeConfigured(cfg.StripeMode),
Temporal: cfg.Temporal,
// The operator Domains surface reads and moderates claims through
// the registry, under the deployment's policy — the same policy the
@@ -708,7 +706,7 @@ func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConf
// Register billing checkout handler. start sets the stripe-go
// package-level API key once, at boot, through the Stripe integration's
// UseKey — not here; the handler reads the boot mode, cfg.StripeMode.
if cfg.StripeAPIKey != "" {
if stripeConfigured(cfg.StripeMode) {
billingCheckout := &BillingCheckoutHandler{
Database: cfg.Database,
BillingQ: cfg.BillingQ,
@@ -897,7 +895,7 @@ func registerMemberPages(httpRequestRouter *http.ServeMux, cfg Config, authConfi
// On a successful return, eagerly reconcile the member's latest Stripe
// subscription so a freshly purchased plan reflects immediately, without
// waiting on webhook delivery. Best-effort: see eagerReconcileOnReturn.
if checkoutStatus == "success" && cfg.StripeAPIKey != "" && session != nil {
if checkoutStatus == "success" && stripeConfigured(cfg.StripeMode) && session != nil {
eagerReconcileOnReturn(ctx, cfg, session.OrgID)
}
@@ -133,7 +133,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Retired>",
@@ -167,7 +167,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Shared Set B>",
@@ -184,7 +184,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Unconfigured Provider>",
@@ -201,7 +201,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Unlisted>",
@@ -133,7 +133,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Retired>",
@@ -167,7 +167,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Shared Set B>",
@@ -184,7 +184,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Unconfigured Provider>",
@@ -133,7 +133,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Retired>",
@@ -167,7 +167,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Shared Set B>",
@@ -184,7 +184,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Unconfigured Provider>",
@@ -201,7 +201,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Unlisted>",
@@ -209,7 +209,7 @@
<th scope="row"><a href="/operator/products/<product:Off Ladder>">Off Ladder</a></th>
<td><span class="text-muted">—</span></td>
<td><a href="/operator/entitlement-sets/<set:Off Ladder>">Off Ladder</a></td>
<td><span class="badge text-bg-success">Purchasable</span> <span class="text-muted small">Not shown in the member catalog</span></td>
<td><span class="badge text-bg-success">Purchasable</span></td>
<td>Listed</td>
<td class="text-nowrap"><today></td>
@@ -229,7 +229,7 @@
<th scope="row"><a href="/operator/products/<product:Shared Set A>">Shared Set A</a></th>
<td><span class="text-muted">—</span></td>
<td><a href="/operator/entitlement-sets/<set:Shared Set A>">Shared Set A</a></td>
<td><span class="badge text-bg-success">Purchasable</span> <span class="text-muted small">Not shown in the member catalog</span></td>
<td><span class="badge text-bg-success">Purchasable</span></td>
<td>Listed</td>
<td class="text-nowrap"><today></td>
@@ -239,7 +239,7 @@
<th scope="row"><a href="/operator/products/<product:Shared Set B>">Shared Set B</a></th>
<td><span class="text-muted">—</span></td>
<td><a href="/operator/entitlement-sets/<set:Shared Set A>">Shared Set A</a></td>
<td><span class="badge text-bg-success">Purchasable</span> <span class="text-muted small">Not shown in the member catalog</span></td>
<td><span class="badge text-bg-success">Purchasable</span></td>
<td>Listed</td>
<td class="text-nowrap"><today></td>
@@ -133,7 +133,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Retired>",
@@ -167,7 +167,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Shared Set B>",
@@ -184,7 +184,7 @@
"CreatedAt": "<today>",
"Lifecycle": "Published",
"VerdictState": "purchasable",
"StatusNote": "Not shown in the member catalog"
"StatusNote": ""
},
{
"ProductID": "<product:Unconfigured Provider>",
@@ -1,7 +1,6 @@
status 286
small.text-muted: Add a rule to the entitlement set; a set with none confers nothing.
small.text-muted: The last Stripe sync failed: fixture refusal. Use Retry to run it again.
small.text-muted: This product is on no plan ladder and is not tagged display_category=addon, so the member catalog does not render it; members have no path to find it.
small.text-muted: The last sync to Stripe failed; retry to run it again.
outbox
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-30
@@ -0,0 +1,205 @@
## Context
**What the member pages decide, and with what.** The offer decision (`internal/plans/offer.go`) reads no row; the position loader (`internal/server/plan_offer.go`) supplies its facts. A target price carries `Default`, `Recurring` and `Purchasable`, the last true when the price's Stripe mapping carries an id the key mode can reach (`readinessPurchasable`, `:461-465`). The price rule (`priceRule`, `offer.go:450-466`) refuses, in order: a product outside the member gate (`NotListed`, no move), no price (`NoPrice`, the move disabled), a price that is not the default (`NotDefault`, no move), a one-time price (`OneTime`, disabled) and an unreachable one (`Unpurchasable`, disabled). `Offer` applies it to every Checkout and switch (`:373`, `:437`, `:443`); `OffLadder` is it with Checkout the move (`:495-499`).
- The Products page's plan sections draw every published, active, listed tier under each ladder it is a tier of (`ListPublicPlanProducts` and `ListPlanLadders`, which reads every ladder, active or not; `internal/server/member_plans.go:308-363`, `buildTier` `:448-519`). A tier whose price rule refuses keeps its card, with the control disabled and "Not available for purchase yet" (`setMove`, `:595-618`), or with "Included" for the rank-0 tier with no price, which the decision calls conferred.
- The products section lists a listed product that is a tier of no ladder exactly when `plans.OffLadder` offers it Checkout (`buildAddonsData`, `internal/server/member_addons.go:100-116`); a product it refuses is not listed at all.
- Checkout refuses by the same function for a product on no ladder, and by `Offer` and `Across` for a tier (`checkoutOffer`, `internal/server/billing.go:258-293`).
**What the panel decides, and with what.** `buildProductReadinessVM` (`internal/server/product_readiness.go:582-648`) builds rows from the product, `core.product_shape`, the tracked price's readiness (`PriceReadiness`) and the providers. For a listed product it is Purchasable when it is published and active, its set has an active rule, it is priced, its price is Stripe-mapped and reachable, and its providers are configured (`:639`). Recurrence is not read, although `PriceReadiness.Recurring` is filled (`:79`, `:139`). The catalog row is Shown when `shape.LadderCount > 0 || display_category = 'addon'` (`:644`, `catalogRow` `:535-549`), and a Purchasable product that is not Shown gets the verdict "Purchasable; not shown in the member catalog" and the qualifier the products list prints as its note (`verdict` `:559-576`, `productListStatus` `:659-678`). The list fills the same function from batch reads (`internal/server/operator_products.go:748-800`), so the list and the detail page cannot disagree with each other; both disagree with the member pages:
- a product on no ladder, recurring and reachable, with no label: listed with Add; the panel says "Purchasable; not shown in the member catalog";
- a product on no ladder labelled `addon` with a one-time default price: not listed, refused by Checkout; the panel says Shown and Purchasable;
- a tier with a one-time default price: drawn disabled, refused by Checkout and the switch; the panel says Purchasable;
- a product on no ladder labelled `addon` whose price is unsynced or missing: not listed; the panel says Shown;
- a listed tier that is a draft or inactive: not drawn; the panel says Shown.
`display_category` "carries no behavioral weight" and no code may branch on it (`openspec/specs/product-catalog/spec.md:11`, `docs/models/product-catalog.md` invariant 2); the panel's rule is the one place that does.
**The tracked price.** The panel and the member pages read the same price: `computePriceReadiness` and `priceReadinessFromBatches` both call `trackedPrice` (`product_readiness.go:95-105`), the product's default price, or its oldest active price when none is marked. The loader's `defaultPrice` (`plan_offer.go:427-459`) reads `computePriceReadiness` and calls the tracked price the default (`Default: true`); `postedPrice` (`:467-486`) calls a posted price the default exactly when it is the tracked one. So `NotDefault` answers only a posted price that is not the tracked one, and the fallback is the same on both sides. `price-management` requires the fallback ("No default marked falls back to the oldest active price", `openspec/specs/price-management/spec.md:77-80`). No console writer leaves a product's active prices without a default: `CreatePrice` makes a product's first price its default and every price active (`internal/billing/queries/prices.sql:21-37`), `DeactivatePrice` refuses the default (`:63-69`), and `MarkDefaultPrice` refuses an inactive price (`:54-61`).
**Reach.** The Payment processing row decides met with its own switch (`syncedPaymentDetail`, `product_readiness.go:347-365`), and the verdict reads that as `stripeReachable`. It equals `readinessPurchasable` in every state: an empty mode is met and reachable; `stale` is neither; a recorded environment that disagrees with the key is neither; an agreeing or unrecorded one is both. Two computations of one fact.
**"Stripe is configured".** Three readers, three answers:
- the panel: `cfg.StripeAPIKey != "" && cfg.StripeWebhookSecret != ""` (`internal/server/server.go:624`);
- Checkout's route and the checkout return's reconcile: `cfg.StripeAPIKey != ""` (`:711`, `:900`);
- the member pages: a database is present, the very signal `product-management` rules out ("not merely by a Stripe querier being constructed", `openspec/specs/product-management/spec.md:327-329`). Every caller of `newPositionLoader` passes `h.Database` (`member_addons.go:100`, `member_plans.go:311`, `member_plan_moves.go:228`, `billing.go:264`, `plan_offer.go:559`, `:633`), and the loader builds a Stripe querier whenever it gets one (`plan_offer.go:59-61`). `computePriceReadiness` then reads the mapping (`product_readiness.go:81-91`), and `MappingReachable` answers true for an empty key mode (`mapping_environment.go:87-94`).
Boot ties the key and the secret together: `stripe-api-key` and `stripe-webhook-secret` share the configuration group "Stripe", and boot refuses a configuration with one set and not the other, reading both trimmed (`internal/integrations/stripe/stripe.go:112-133`, `internal/config/validate.go:174`, `:186-196`). It derives the mode from the key and refuses a key whose prefix names no mode (`internal/integrations/stripe/stripe.go:284-297`, `cmd/start.go:155-163`). So for every configuration boot accepts, the mode is set exactly when both values are, except one: a key and a secret of spaces pass the group check as unset and give an empty mode, while the raw `!= ""` checks call them set. The operator's billing pages and the setup checklist read "configured" through the integration registry's required keys, trimmed the same way (`configurationReadiness`, `internal/server/operator_integration_settings.go:106-114`), so they agree with the mode. Those registry readers stay as they are (Q3): `operator_billing.go:349`, `:505`, `:803`, `:975`, `:1118`, `setup_state.go:163`, the Stripe provider page (`operator_integrations_stripe.go:598`), the Integrations list (`operator_pages.go:279`) and the overview's Integrations card (`operator_overview.go:368`). The operator handlers that read `h.StripeConfigured` (`operator_billing.go:1345`, `:1352`, `:1476`, `:1606`; `operator_integrations_stripe.go:654`, `:718`) read the mode through the panel's flag.
**What `MappingReachable` answers for an empty mode, and who asks.** It answers true, because "a deployment whose key the console could not classify knows nothing about which environment it is in" (`mapping_environment.go:87-90`). A booted console has no such key: boot refuses it, so an empty mode means no key. Besides the loader, the member invoice page's Pay link (`internal/server/member_invoices.go:405-410`), the hosted-invoice redirects (`internal/server/member_hosted_invoice.go:141-160`, `:292-300`), the plan acts' reach checks (`internal/fulfillment/plan_change.go:248` in `SwitchPlan`, `:605` in `ResumePlan`) and the background sweeps (`internal/fulfillment/sweep.go:123`, `collection_sweep.go:151`, `reconcile.go:218`) ask it with the mode they were given, which is empty with no key.
**Where the old rule is written.** `openspec/specs/product-management/spec.md:300-309` (the catalog line) and the scenarios at `:438-467`, with the qualifier at `:641` and `:693`, and `:498-499`, the sync action's refusal; `openspec/specs/member-product-discovery/spec.md:75-111`, which says "not a tier on any active plan ladder" while its plan requirement (`:25`) and `:598` say any `plan_ladder_tiers` row, and whose scenario at `:105` has the panel explain the listing rule; `docs/models/product-catalog.md:72-74`; `docs/stripe.md:230`, which asks for a "product type" and calls an untyped product on no ladder "limbo".
## Goals / Non-Goals
**Goals:** the panel and the member pages answer "can a member find this" and "can it be bought" from one rule over the same facts, for a tier and a product on no ladder; "Stripe is configured" has one source for the member pages, the panel and Checkout's route; a test builds both answers from the same facts for every product shape and requires them to agree.
**Non-Goals:** the panel judging an organization's position; which flags decide who sees, buys or is granted a product (#145); whether a product has a display category (#95); what a priceless conferred tier reads (#102); the member surfaces that act on an existing subscription with no key (Open questions).
## Decisions
### D1. One price rule, which the panel asks
`internal/plans/offer.go` exports the rule it already applies:
```go
func PriceRule(t Target) Reason
```
It returns `NoReason` when a member may buy the target's price, and otherwise the first of `NotListed`, `NoPrice`, `NotDefault`, `OneTime` and `Unpurchasable`, in today's order. The private `priceRule(kind, t) Move` becomes `priceMove(kind, t) Move`, which asks `PriceRule` and turns its reason into a move: enabled for `NoReason`, no move for `NotListed` and `NotDefault`, the move disabled for the rest. `OffLadder` and every Checkout and switch of `Offer` call `priceMove`, so their answers are unchanged and cannot drift from `PriceRule`.
The panel asks `plans.PriceRule` for a listed product, with `Listed` set to the member gate (`evaluateMemberGate(product).OK()`) and the price built by the function the member pages use. That function is `offeredPrice(r PriceReadiness, keyMode string) plans.Price`, extracted from `defaultPrice`: the tracked price with `Default: true`, its recurrence, and `Purchasable` from `readinessPurchasable`; the zero `Price` when the product has no active price. Purchasable for a listed product becomes: the price rule passes, the set has an active rule, and the providers are configured. The rule covers the member gate, the price, its recurrence and its reach, so `StripeMapped` and `stripeReachable` leave the formula. `priced` stays: the Active price row reads it, and the detail page reads the shape and the price in separate statements, so keeping it in the verdict means the row and the verdict cannot split when a price is added between the two reads. An unlisted product is judged as before, as a grant ("Ready to grant").
A product's price-level answer is the same whether it is a tier or not; only the move differs, and the move for a tier depends on the organization's position. The panel speaks for the product:
| Reason | Does the panel speak for it? | Where it shows |
|---|---|---|
| `NotListed` | Yes | The Published and Active rows; the Listed row for an unlisted product, judged as a grant |
| `NoPrice` | Yes | Active price, "Add an active price on the Prices view." |
| `NotDefault` | Never arises | The panel reads the tracked price, which the member pages call the default too (D4); the reason answers only a posted price |
| `OneTime` | Yes, new | Active price, One-time (a gap), with the new detail (Copy) and the missing name "recurring default price" |
| `Unpurchasable` | Yes | Payment processing: not configured, not synced, pending, failed, other environment, stale |
| `NotOnLadder`, `Unplaced`, `Current`, `Scheduled`, `HeldMoveMade`, `HeldAbove`, `BelowBoth`, `FreeRungNotFollowing`, `BelowGrant`, `Suspended`, `Paused`, `PaidHere`, `PaysOnLadder`, `OtherCurrency`, `OtherLadder` (`Across`) | No | They depend on what an organization holds; the Products page draws them for that organization |
| `Conferred` | No | The rank-0 tier with no price; the panel reads it "Incomplete; missing: Active price, payment processing", which #102 owns |
Every reason the panel speaks for has a row that is a gap when the rule refuses, so a listed product the rule refuses always names what is missing. The rows stay computed from the facts, not from the rule's first reason, because the rule stops at its first refusal: a draft with no price is refused as `NotListed` and must still show the Active price row unmet.
The Payment processing row's met becomes `readinessPurchasable(pr, stripeMode)`; `syncedPaymentDetail`'s switch only picks the words. The row and the verdict's input are then one computation, and every state reads as before (Context, Reach).
| Option | Effect | Verdict |
|---|---|---|
| A. Export the price rule as `PriceRule`; the panel asks it for every listed product | One rule for a tier and a product on no ladder; `Offer` and `OffLadder` are built on it | Chosen |
| B. The panel calls `OffLadder` for a product on no ladder and `Offer` for a tier | `Offer` needs a position; the panel would invent one ("holds nothing") and speak for an organization | Rejected |
| C. The panel calls `OffLadder` for both | The same answers as A, under a name that says "a tier of no ladder" for a tier | Rejected |
| D. Keep the panel's own rule and add recurrence to it | Two rules again; the next leg added to one is missed by the other | Rejected |
#178 asks that the panel and the catalog call the predicate Checkout uses outside plan tiers. They do: the products section and Checkout call `OffLadder`, which is `PriceRule` with Checkout the move, and the panel calls `PriceRule`.
### D2. The catalog row follows the member pages, and the qualifier goes
The "Member catalog visibility" row reads, for a listed product:
- **Shown** when the member pages draw it for a member who holds nothing: the product clears the member gate and is a tier of a ladder (`shape.LadderCount > 0`, the same `plan_ladder_tiers` rows `ListPublicPlanProducts` reads), or is a tier of no ladder and the price rule passes;
- **Not shown** otherwise.
An unlisted product's row stays "Not applicable", and no state of the row carries a detail (Copy); the Listed row already reads "Unlisted; issued as grants". The verdict names what is missing and the Plan ladders section says whether the product is a tier, so the long details told the operator nothing the page does not; the first two also named the `addon` label as a rule.
The member pages' current-rung exception (an organization keeps seeing the rung it holds after its product leaves publication) is about one organization's position, which the row does not speak for.
A Purchasable listed product passes the price rule with the member gate, so it is Shown whether it is a tier or not: the qualifier "Purchasable; not shown in the member catalog" and the products list's note "Not shown in the member catalog" can no longer arise. They are removed, with `ProductReadinessVM.CatalogQualifier`, the verdict's `findable` parameter and `displayCategoryAddon`, whose only reader was the panel. The products list's Status note is then the first unmet leg or nothing (`productListStatus`).
| Option | Effect | Verdict |
|---|---|---|
| A. Remove the qualifier; Purchasable implies Shown by construction, and a test holds it | One statement of the rule | Chosen |
| B. Keep the qualifier for a case that cannot arise | Dead code that restates the rule a second way | Rejected |
### D3. One source of "Stripe is configured"
`internal/server/product_readiness.go` gains:
```go
func stripeConfigured(keyMode string) bool { return keyMode != "" }
```
documented with the boot facts above: the mode is set exactly when the key and its webhook secret are. Every reader asks it:
- **The member pages.** `newPositionLoader(billingQ, entsQ, db, keyMode, now)` takes the key mode, keeps it on the loader, and builds the Stripe querier only when `db != nil && stripeConfigured(keyMode)`. `defaultPrice` and `postedPrice` lose their `keyMode` parameter and read the loader's, so a loader cannot read mappings under one mode and judge them under another. Every caller already holds the mode it passes: `h.StripeMode` on `MemberProductsHandler` (`member_addons.go:100`, `member_plans.go:311`, `member_plan_moves.go:228`, `plan_offer.go:559`, `:633`) and `keyMode` in `checkoutOffer` (`billing.go:264`), both set from `cfg.StripeMode` (`server.go:660`, `:719`).
- **The panel.** `OperatorPartialsConfig.StripeConfigured` is set from `stripeConfigured(cfg.StripeMode)` (`server.go:624`). The field stays: twenty-odd tests build the handler with it, and production sets it in one line from the same function.
- **Checkout's route** (`server.go:711`) and **the checkout return's reconcile** (`:900`) ask `stripeConfigured(cfg.StripeMode)`.
- **`server.Config`** loses `StripeAPIKey` and `StripeWebhookSecret` (`:50-51`), which nothing then reads; `cmd/start.go:310-311` stops setting them. The stripe-go key is set by `UseKey` at boot and the webhook secret is read by the Stripe adapter, neither through `server.Config`, so the mode is the only fact of the key the server package holds.
With no key the loader reads no mapping, every price is unmapped, the price rule answers `Unpurchasable`, a tier's control is disabled and a product on no ladder is not listed, whatever an earlier key synced. The panel already said "Stripe is not configured for this deployment".
**`MappingReachable`'s empty-mode answer stays.** After this change neither the loader nor the panel asks it without a key: the loader reads no mapping, and `computePriceReadiness` returns before the mapping when Stripe is not configured, so `syncedPaymentDetail` and `readinessPurchasable` meet no mapping. Its other callers still ask it with an empty mode (Context): the invoice page's Pay link, the hosted-invoice redirects and the plan acts. Answering false would change each of those surfaces with no key, and what each should then show (no Pay link, a refusal, a disabled control, a sweep that skips) is a decision per surface. That is the second sighting of #282's defect, its own issue (Open questions, Q1). `syncedPaymentDetail`'s branch for an empty mode stays for the same reason and is unreachable in a booted console.
| Option | Effect | Verdict |
|---|---|---|
| A. The key mode for every reader; the loader is built with it; `server.Config` drops the key and the secret | One input; the configuration with spaces agrees too | Chosen |
| B. Thread the panel's boolean (key and secret) into the member handlers | The same answers for every other configuration; the loader would hold a boolean and a mode that a test can set apart | Rejected |
| C. Make `MappingReachable` answer false for an empty mode | Fixes the member pages through a function the invoice pages and the plan acts share, changing them without deciding them | Rejected here (Q1) |
### D4. The tracked price
The panel and the member pages read the same price, `trackedPrice`: the product's default price, or its oldest active price when none is marked, as `price-management` requires. The panel's price facts come from `offeredPrice`, the function `defaultPrice` uses, so the two cannot read different prices or call the same price default on one side and not on the other.
Dropping the fallback, so that both read only a marked default, was considered and left for its own issue (Q2): it changes `price-management`'s requirement and its scenario, the state it guards needs a write no console path makes, and with it a rank-0 tier whose active prices hold no default would read as the conferred free rung.
### D5. Ride-alongs and what is left out
Quick fixes, listed in the proposal: the parameters `computePriceReadiness` and `priceReadinessFromBatches` name `stripeConfigured`, renamed `configured` because the new function of that name lives in their file; the `member-product-discovery` contradiction ("any active plan ladder" against "any `plan_ladder_tiers` row"); `product-catalog`'s cross-reference to a "storefront add-on filter" that does not exist; `TestSwitchRefusesWhenAReadFails`'s failing handlers, which lack the control's key mode. Structural findings are named in Open questions and not built.
## Failure table
Every row is a listed product (published, active, public) whose set has an active rule and whose providers are configured, in a deployment with a test key, unless the row says otherwise. "Member pages" is what a member who holds nothing on the product's ladders sees, and what Checkout answers.
| # | Product | Panel before | Member pages before | Panel after | Member pages after |
|---|---|---|---|---|---|
| F1 | Tier; default price recurring and reachable | Purchasable; Shown, with the long detail | Card under Plans with Subscribe; Checkout sells it | Purchasable; Shown | Unchanged |
| F2 | Tier; default price one-time, mapped | Purchasable; Shown | Card drawn, control disabled, "Not available for purchase yet"; Checkout and the switch refuse (`OneTime`) | Incomplete; missing: recurring default price; Active price One-time with the new detail; Shown | Unchanged |
| F3 | Tier above rank 0; no price | Incomplete; missing: Active price, payment processing; Shown | Card drawn, control disabled | Same verdict; Shown | Unchanged (a rank-0 tier reads "Included", #102) |
| F4 | Tier; mapping in the other environment, or stale | Incomplete; missing: payment processing; Shown | Card drawn, control disabled | Same verdict; Shown | Unchanged |
| F5 | No ladder; recurring and reachable; no label, or a label other than `addon` | "Purchasable; not shown in the member catalog"; Not shown; list note "Not shown in the member catalog" | Listed with Add; Checkout sells it | Purchasable; Shown; no note | Unchanged |
| F6 | No ladder; recurring and reachable; labelled `addon` | Purchasable; Shown | Listed with Add | Purchasable; Shown | Unchanged |
| F7 | No ladder; one-time default price, mapped; labelled `addon` | Purchasable; Shown | Not listed; Checkout refuses (`OneTime`) | Incomplete; missing: recurring default price; Not shown | Unchanged |
| F8 | No ladder; one-time default price, mapped; no label | "Purchasable; not shown in the member catalog"; Not shown | Not listed | Incomplete; missing: recurring default price; Not shown | Unchanged |
| F9 | No ladder; recurring, not synced (or pending, failed, unreachable); labelled `addon` | Incomplete; missing: payment processing; Shown | Not listed | Same verdict; Not shown | Unchanged |
| F10 | No ladder; no price; labelled `addon` | Incomplete; missing: Active price, payment processing; Shown | Not listed | Same verdict; Not shown | Unchanged |
| F11 | No key; tier whose recurring price an earlier key mapped | Incomplete; missing: payment processing, "Stripe is not configured for this deployment…"; Shown | Subscribe enabled; its post reaches no route and answers 404 | Unchanged | Control disabled, "Not available for purchase yet"; nothing posts to Checkout |
| F12 | No key; no ladder; recurring price an earlier key mapped | Incomplete; missing: payment processing; Shown with `addon`, Not shown without | Listed with Add; its post answers 404 | Same verdict; Not shown | Not listed |
| F13 | No key; a member posts a price id to `POST /billing/checkout` | — | 404, no route | — | Unchanged: 404 |
| F14 | Tier, listed but draft, retired or inactive | Incomplete; missing: published, or Inactive; Shown | Not drawn (an organization that holds it keeps its current card) | Same verdict; Not shown | Unchanged |
| F15 | Unlisted product | Ready to grant; Not applicable | Not drawn | Unchanged | Unchanged |
| F16 | No ladder; recurring and reachable; no entitlement set | Incomplete; missing: entitlement set; Shown with `addon`, Not shown without | Listed with Add (#272) | Same verdict; Shown | Unchanged |
| F17 | Tier the organization cannot move to: below a grant, the held rung, another ladder of the product, a suspended or paused position, another currency | Purchasable (the product) | No control, or disabled, for that organization | Unchanged: the panel does not speak for a position | Unchanged |
| F18 | Active prices, none marked default (no console writer leaves this) | Tracks the oldest active price | Offers the oldest active price as the default | Unchanged | Unchanged (Q2) |
| F19 | A key and a webhook secret of spaces (boot reads both as unset; the mode is empty) | Configured; a mapped price reads Synced and Purchasable | Offers earlier mappings; Checkout is registered and its Stripe calls carry no key | As F11 and F12 | As F11 to F13 |
| F20 | Tier of two ladders; default price recurring and reachable; the member holds nothing | Purchasable; Shown | Drawn under each ladder; Checkout sells it through `plans.Across` of two identical answers | Unchanged | Unchanged |
## Copy
Every operator-visible string the fix adds, changes or removes. No member-visible string changes: with no key a member sees the existing "Not available for purchase yet" on a tier and no row for a product on no ladder.
| # | Where | Old | New | Status |
|---|---|---|---|---|
| C1 | Readiness panel, Active price row's detail, when the default price is one-time | None; the row read Met | "Make a recurring price the default on the Prices view; a one-time price is not sold." | approved |
| C2 | Readiness verdict, same case | "Purchasable" (a tier, or a product labelled `addon`), or "Purchasable; not shown in the member catalog" | "Incomplete; missing: recurring default price" | approved |
| C3 | Products list, Status note, same case | None, or "Not shown in the member catalog" | "Missing: recurring default price" | approved |
| C4 | Readiness panel, Member catalog visibility row's detail when Shown | "This product is a plan-ladder tier or is tagged display_category=addon, so the member catalog renders it; a product that is not a tier is listed only while it has an active, synced, recurring price." | None | approved |
| C5 | Same row's detail when Not shown | "This product is on no plan ladder and is not tagged display_category=addon, so the member catalog does not render it; members have no path to find it." | None | approved |
| C6 | Readiness verdict for a Purchasable product the member pages do not draw | "Purchasable; not shown in the member catalog" | None; the case no longer arises | approved |
| C7 | Products list, Status note, same case | "Not shown in the member catalog" | None | approved |
| C8 | Readiness panel, Active price row's badge, when the default price is one-time | "Met" | "One-time", in the warning tone of "No rules" | approved |
| C9 | Readiness panel, Member catalog visibility row's detail for an unlisted product | "Unlisted product; never shown in the member catalog." | None | approved |
C1 is one line, a prerequisite with its remedy, under 100 characters, in the order the Entitlement set row uses ("Assign an entitlement set; a product with none confers nothing."). C2 and C3 follow from C1's missing name, "recurring default price", which stays true for a product whose recurring price is not its default. C8 is the row's existing idiom for a present thing the product cannot use (the Entitlement set row's "No rules"), so the row does not read "Missing" beside a price the Prices section lists. C9 fails the same deletion test as C4 and C5: the Listed row two rows up already says the product is unlisted.
## Open questions
**Q1. With no Stripe key, the member pages and the billing sweeps still act on what an earlier key made.** A second sighting of #282's defect: a recorded Stripe id is treated as reachable when the deployment has no key. It needs a decision per surface, so it is proposed as its own issue, not built here. Draft, for the maintainer's go:
> **With no Stripe key, the Products and Billing pages and the billing sweeps still act on Stripe objects an earlier key made**
>
> A deployment can run without a Stripe key. A price mapping, a subscription mapping and an invoice mapping are the console's records of the Stripe objects its prices, subscriptions and invoices were synced to; a deployment that once had a key keeps them after the key is removed. The question "can the current key reach this recorded Stripe id" answers yes when there is no key (`MappingReachable`, `internal/integrations/stripe/store/mapping_environment.go`), a rule written for a key the console cannot classify, which boot no longer accepts.
>
> So on such a deployment, an organization whose subscription the earlier key sold sees Cancel plan, Keep plan, Resume plan and the downgrade to a held or free rung on the Products page, because those moves buy nothing and read no price; the Billing page draws Pay on an open invoice. Each plan move reaches a Stripe call made with no key, or an outbox entry that cannot run, and the member reads a failure; Pay returns the member to the Billing page without saying why.
>
> The same question is asked by background callers with the empty mode: the sweep that applies scheduled plan changes (its schedule starts whatever the key), the invoice collection sweep and the reconcile skip. The setup checklist's price step counts any recorded price mapping as done. Whether the reach question should answer no decides all of them, and what each surface should then show is open.
**Q2. Cleanup, not a defect: the oldest-price fallback.** Every purchase surface falls back to the oldest active price when no price is marked default, and both sides agree on it. `CreatePrice` makes a product's first price its default and every price active, the default cannot be deactivated, and an inactive price cannot be made the default, so the fallback (`trackedPrice`; `price-management`, "No default marked falls back to the oldest active price") guards only rows written outside the console. Whether to drop it, so that every surface reads only the marked default, changes `price-management`'s requirement and decides what a rank-0 tier with active prices and no default reads. Left as a question for the maintainer; an issue only if he wants the cleanup.
**Q3. Two statements of "Stripe is configured" remain in the operator pages.** Five readers read the integration registry's required keys (`configurationReadiness`): the billing pages' empty-state gate, the setup checklist, the Stripe provider page, the Integrations list and the overview's Integrations card. The member pages, the product page, Checkout's route and the checkout return read the key's mode. They agree for every configuration boot accepts, but one rule is stated twice (the readers are named in D3). The new `stripeConfigured` also shares its name with six locals (five in `operator_billing.go`, one at `setup_state.go:163`) that hold the other answer; the fold removes them. The two parameters of that name in its own file are renamed here (D5). Two main specs pin the registry reading: `operator-billing-views` ("Empty billing views distinguish unconfigured Stripe from no data", "per the same required-key completeness check") and `integration-settings` ("The Stripe provider page carries its status and the delivery-queue report", "from the required-key resolution"). The fold below changes the first; the provider page, the Integrations list and the overview's Integrations card keep the registry answer, because they print the settings that are missing, so the second stays. Draft, for the maintainer's go:
> **The billing pages and the setup checklist decide "Stripe is configured" from the integration settings; the member pages and Checkout decide it from the key's mode**
>
> The operator's billing pages show an empty state when Stripe is not configured, and the setup checklist counts Stripe as configured, from the integration registry's required settings. The member Products page, the operator's product page and the Checkout route take the answer from the key's mode, which the console derives from the API key at start. Boot accepts a key only with its webhook secret and a prefix that names a mode, so the two answers agree for every configuration it accepts. Fold the billing pages' gate and the checklist onto the mode, so one rule is stated once; the Stripe provider page, the Integrations list and the overview's Integrations card keep the registry's list of missing settings, which they print. The fold changes the billing views' empty-state requirement, which names the required-key check.
## Out of scope
- **#145**, which flags decide who sees, buys or is granted a product, and the operator pages that hide what members see.
- **#95**, whether a product has a display category; the panel stops reading it, and the products list and badges keep showing it.
- **#102**, what the panel calls a product that is delivered rather than sold, including the conferred free rung.
- **Q1's surfaces**: the plan acts, the invoice Pay link, the hosted-invoice redirects and the billing sweeps with no key.
- **Q2**: the tracked price's fallback.
- **Q3**: the operator pages' reading of "configured" from the integration registry.
@@ -0,0 +1,64 @@
## Why
Whether a member can find and buy a product is decided twice, by two rules, and the two answers disagree about the same product.
The member pages decide it with the offer decision (`internal/plans/offer.go`). A plan tier is drawn on the Products page's plan sections, and its buy control is enabled only when the price rule passes: the product clears the member gate, and its default price is recurring and has a Stripe price the key can reach (`priceRule`). A product on no ladder is listed in the products section exactly when `plans.OffLadder`, the same price rule, offers it Checkout (`internal/server/member_addons.go:100-116`), and Checkout refuses by the same function (`internal/server/billing.go:258-293`). The operator's readiness panel decides it with a rule of its own (`internal/server/product_readiness.go:582-648`): a product is findable when it is a tier or carries `display_category = 'addon'` (`:644`), and Purchasable when its price is mapped, whether or not the price is recurring (`:639`). So a product on no ladder with a recurring price and no label is listed while the panel says it is not shown; an `addon` product with a one-time price is hidden while the panel says it is shown and Purchasable; and a tier with a one-time default price reads Purchasable while its card on the Products page is disabled and Checkout refuses it (#178). `display_category` carries no behavioural weight (`openspec/specs/product-catalog/spec.md:11`), and the panel branches on it.
The two sides also take "Stripe is configured" from different places. The panel reads it from the API key and webhook secret (`internal/server/server.go:624`), Checkout is registered only with a key (`:711-722`), and the member pages' position loader reads price mappings whenever a database is present (`internal/server/plan_offer.go:52-63`). A deployment that synced its prices under a key and then removed the key keeps those mappings, and an empty key mode reaches every recorded Stripe id (`MappingReachable`, `internal/integrations/stripe/store/mapping_environment.go:87-101`). Its Products page lists the add-ons with Add and enables Subscribe on the tiers; the buttons post to `POST /billing/checkout`, which nothing serves, while the operator's page says Stripe is not configured (#282).
Both are one defect: the panel and the member pages answer "can a member find and buy this" from different facts.
## What Changes
- **One price rule, exported.** `plans.PriceRule(Target) Reason` is the price rule `Offer` and `OffLadder` already apply, returning its reason; the private `priceRule` becomes `priceMove`, which turns that reason into a Checkout or Switch move. No answer of `Offer` or `OffLadder` changes.
- **The panel asks it.** The readiness panel builds the target's price with the function the member pages use (`offeredPrice`, extracted from the loader's `defaultPrice`) and asks `plans.PriceRule`; Purchasable requires it to pass, so a one-time default price reads "Incomplete; missing: recurring default price" for a tier and a product on no ladder alike, and its Active price row reads One-time in the warning tone of "No rules". The Payment processing row's reach is the loader's `readinessPurchasable`, one computation instead of two equal ones.
- **The catalog row follows the member pages.** "Member catalog visibility" reads Shown when the member pages draw the product: a published, active, listed tier, or a product on no ladder whose price rule passes. `display_category` decides nothing. A Purchasable product is therefore always shown, so the verdict's qualifier ("Purchasable; not shown in the member catalog") and the products list's matching note can no longer arise and are removed, with `ProductReadinessVM.CatalogQualifier` and `displayCategoryAddon`. The row's three details go: Shown's, Not shown's and the unlisted product's.
- **One source of "Stripe is configured".** `stripeConfigured(keyMode)` answers it from the mode boot derives from the API key; boot accepts a key only with its webhook secret and only with a prefix that names a mode, so the mode is set exactly when both are. The position loader takes the key mode when it is built and reads no price mapping without one, so a deployment with no key offers nothing for sale whatever an earlier key synced. The panel's flag, Checkout's registration and the checkout return's reconcile read the same function, and `server.Config` loses `StripeAPIKey` and `StripeWebhookSecret`, which nothing else reads.
- **Specs and docs.** `product-management` and `member-product-discovery` state the one rule; `docs/models/product-catalog.md` drops the disagreement it records; `docs/stripe.md` and `docs/plan-management.md` drop the "product type" precondition.
- **Tests.** A table test builds the panel and the member pages' answer from the same facts for every product shape of the failure table and requires them to agree; `plans` pins `PriceRule` against `OffLadder` and `Offer`; database tests show the member pages sell nothing with no key, and a router test shows the Products page, the product page and Checkout agree with and without a key.
### Ride-alongs (quick fixes, listed openly)
- `member-product-discovery` says a non-plan product is "not a tier on any active plan ladder" while its plan requirement and the code take any ladder; the delta says "any plan ladder" (#178 names this contradiction).
- `product-catalog` cross-references a "storefront add-on filter" by `display_category` that does not exist; the delta says the label renders as a badge and decides nothing. The requirement's rule is unchanged.
- `MappingReachable`'s comment justifies its empty-mode answer by a key the console cannot classify, which boot refuses; it says an empty mode means no key.
- `computePriceReadiness` and `priceReadinessFromBatches` take a parameter named `stripeConfigured`, which the new function of that name, in the same file, would shadow; the parameter is renamed `configured`.
- `TestSwitchRefusesWhenAReadFails` builds its failing handlers without the control's key mode; they get it, so each differs from its control only by the failing read.
- `docs/plan-management.md` still names a "product type" remedy for an "invisible-limbo" product and calls a one-time "typed" product purchasable off-ladder; it states the second rule this change removes, so its purchasability steps and panel paragraph follow the one rule.
## What does not change
- **The offer decision's answers.** `Offer`, `OffLadder` and `Across` answer as before; `PriceRule` is their existing rule given a name.
- **Which price is tracked.** The panel and the member pages both read `trackedPrice`: the product's default price, or the oldest active price when none is marked, as `price-management` requires. The fallback is named as an open question (design, Open questions).
- **What the panel speaks for.** It answers for a product, not for an organization: a held rung, a grant below the target, another ladder of the product, a suspended or paused position and another currency stay the member pages' alone. The free rung (a rank-0 tier with no price) still reads "Incomplete; missing: Active price, payment processing", which #102 owns. (The first name is capitalized beside the lowercase "recurring price"; the spec already calls it "active price"; renaming it moves seven notes in one golden, so it waits for the maintainer.)
- **The member gate, the entitlement-set leg and the provider leg.** A product on no ladder with no entitlement set is still listed for members and still Incomplete on the panel (#272, closed as intended).
- **`MappingReachable`.** Its empty-mode answer stays; its remaining callers with no key are a second sighting, named as its own issue (design, Open questions).
- **The operator pages' reading of "configured" from the registry.** The billing pages, the setup checklist, the Stripe provider page, the Integrations list and the overview's Integrations card read the integration registry's required keys and agree with the mode for every configuration boot accepts; folding them in is its own issue (design, Open questions).
- **Member-visible copy.** No member string is added or changed.
- **Out of scope:** #145 (which product flags decide who sees, buys or is granted a product); #95 (whether a product has a display category at all).
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `product-management`: the readiness requirement asks the member offer's price rule and one "Stripe is configured" answer, and its catalog-visibility line follows the member pages; the sync action's refusal reads the same answer; the verdict and Status column requirements lose the qualifier.
- `member-product-discovery`: the non-plan section lists nothing with no Stripe key, and names any plan ladder.
- `product-catalog`: the cross-reference for `display_category` names what the member pages do with it.
## Impact
- **Code:** `internal/plans/offer.go`; `internal/server/product_readiness.go`, `plan_offer.go`, `member_addons.go`, `member_plans.go`, `member_plan_moves.go`, `billing.go`, `operator_products.go`, `server.go`; `cmd/start.go`; `internal/integrations/stripe/store/mapping_environment.go` (a comment); the template of `internal/embeds/templates/partials/operator_product_readiness.html`.
- **Tests:** new tests in `internal/plans/offer_test.go`, `internal/server/product_readiness_test.go`, `internal/server/member_catalog_environment_db_test.go` and `internal/server/new_handler_db_test.go`; fixture corrections and rewritten qualifier tests in `product_readiness_test.go` and `operator_product_readiness_render_test.go`; the products-list fixture `seedActivePrice` in `operator_products_list_test.go`, whose price becomes monthly; the failing-read handlers of `member_switch_offer_db_test.go`; the label literals of `operator_plan_ladders_golden_db_test.go` and `operator_products_golden_db_test.go`; compile fixes in `member_catalog_environment_db_test.go`, `new_handler_test.go`, `new_handler_db_test.go`; six golden files edited by hand, each diff only the removed qualifier or detail.
- **Docs:** `docs/models/product-catalog.md`, `docs/stripe.md`, `docs/plan-management.md`.
- **Schema, routes, configuration keys, Stripe calls:** none. Checkout's route is registered under the same condition as before for every configuration boot accepts but one: a key of spaces, which boot already reads as no key, no longer registers it.
- **What an operator sees:** a tier or product whose default price is one-time reads Incomplete with "recurring default price" missing; a product on no ladder that members can buy reads Purchasable with no qualifier; the catalog row's long explanations are gone.
- **What a member sees:** on a deployment with no Stripe key, no product is offered for sale, as the operator's page already said.
## Related issues
- **#178** is this change's first half; it closes with it.
- **#282** is its second half; it closes with it.
@@ -0,0 +1,49 @@
## MODIFIED Requirements
### Requirement: Add-on products displayed as table rows
The member catalog SHALL display, in one non-plan section separate from the ladder-grouped plans, **every published, public product that is not a tier on any plan ladder and that can be bought**: a product whose default price is active, recurring, and Stripe-mapped (`member-offer`, "Outside plan ladders, one predicate decides what a member may buy"), and whose mapping the current API key can reach (its recorded environment agrees with the key's mode, or is unverified, and the environment check has not marked it stale; stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). A deployment with no Stripe API key has no key to reach a mapping with: it SHALL list no product here and SHALL draw every Checkout and switch control a plan tier carries disabled, whatever price mappings an earlier key synced, so no control posts to a Checkout the deployment does not serve. Plan-ness is the structural fact Requirement: Plan products displayed as comparison cards states (a tier in some `billing.plan_ladder_tiers` row, whatever the ladder's state). A published, public non-tier product without such a price SHALL NOT be listed (maintainer, 2026-09-19: a catalog offers what can be bought; the operator's readiness panel on the product's own page reports the missing price or the unreachable mapping), so no row ever carries text where its purchase control would be. The earlier `display_category = 'addon'` membership gate stays dissolved: `display_category` remains presentation-only grouping metadata (Doc 41 §4.2, §6.7) carrying no behavioral weight; when a product carries a label it renders as a small grouping badge on its row, and the label neither includes nor excludes a product from the section. The section's heading adapts to what the page actually shows (a plan-less deployment's whole catalog is this section, and must not read as an appendix to plans that do not exist): beneath rendered plan sections it is headed "More products" with the line "Products you can add alongside your plan, or use on their own."; standing alone it is headed plain "Products" with no plan-referencing framing. A product that is a ladder tier renders in its plan section and SHALL NOT be duplicated into the non-plan section regardless of its label. Each entry SHALL show the product `name` and `description` and its purchase control.
#### Scenario: Non-tier purchasable products all render in the non-plan section
- **WHEN** published, public products with a purchasable price exist that are not tiers on any ladder, with `display_category` values of `addon`, some other label, and blank
- **THEN** all of them render in the non-plan section, each with its purchase control
- **AND** a carried label renders as a grouping badge without affecting membership
#### Scenario: The heading adapts to the presence of plans
- **WHEN** plan sections render above the non-plan section
- **THEN** it is headed "More products" with its definition line
- **WHEN** no plans exist and the section is the whole catalog
- **THEN** it is headed plain "Products" and no plan-referencing copy renders
#### Scenario: No purchasable products
- **WHEN** no published, public non-tier product with a purchasable price exists
- **THEN** the non-plan section SHALL NOT be rendered
#### Scenario: A tier is not duplicated into the non-plan section
- **WHEN** a product is a tier in a `plan_ladder_tiers` row and also carries `display_category = 'addon'`
- **THEN** it renders in its plan ladder section only
- **AND** the non-plan section does not repeat it
#### Scenario: A product that cannot be bought is not listed
- **WHEN** a published, public non-tier product's default price is missing, one-time, or not Stripe-mapped
- **THEN** the product does not render in the non-plan section
- **AND** the operator's readiness panel for that product names the missing precondition and reports the product as not shown in the member catalog
#### Scenario: A product the key cannot reach is not listed
- **WHEN** a published, public non-tier product's default price is mapped, but its mapping records the other Stripe environment or is marked stale by the environment check
- **THEN** the product does not render in the non-plan section, and a plan tier in the same state renders its move control disabled
- **AND** the operator's readiness panel for that product names the unreachable mapping
#### Scenario: A deployment with no Stripe key sells nothing
- **WHEN** the deployment has no Stripe API key, and a published, public non-tier product and a plan tier each have a recurring default price whose mapping an earlier key synced
- **THEN** the non-tier product does not render in the non-plan section
- **AND** the tier's card renders its move control disabled with "Not available for purchase yet"
- **AND** no control on the page posts to Checkout
- **AND** the operator's readiness panel for either product reads that Stripe is not configured for this deployment
@@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: Product catalog for entitlement rule lookup
The system SHALL maintain a product catalog where each product represents something the cooperative offers. Products SHALL have a `product_id` (UUID), `name`, `description`, `display_category` (nullable free-text presentation grouping; see below), `is_active` (whether it can be referenced by new grants), `is_public` (whether it appears in member-facing UI), and `entitlement_set_id` (UUID, FK to `entitlement_sets`). The `entitlement_set_id` SHALL declare what capabilities this product confers when provisioned.
`display_category` SHALL be a nullable free-text column carrying no enforced domain — no CHECK constraint restricts its values — and no behavioral weight (Doc 41 Decision 136). It supersedes the previously documented `product_type` column and its `{plan, addon, usage, one_time}` domain: that domain never matched the code's actual stored values (`'plan'` was never a legal stored value — plan membership is, and remains, derived structurally from `billing.plan_ladder_tiers` membership, never from a label). No schema constraint and no application code SHALL branch on `display_category`'s value to determine conferral eligibility, publication readiness, ladder membership, catalog listing, or any other behavior. `display_category`'s sole legitimate purpose is presentation, such as a label on a storefront row and operator catalog filters (cross-reference: `member-product-discovery`, where the label renders as a badge on a product's row and neither includes nor excludes it; `product-management` for the operator filter, whose readiness panel reads `billing.product_shape` and the member offer's price rule instead of this column).
Products MAY include a `metadata` JSONB field. When the `metadata` field contains a `features` key, its value SHALL be an array of strings representing qualitative feature descriptions for member-facing display (e.g., `["Custom domains", "Community support"]`). These descriptions supplement the quantitative features derived from the product's entitlement set rules. The `features` key is optional — its absence or a NULL `metadata` value SHALL NOT cause errors in member-facing views.
#### Scenario: Product referenced by entitlement set
- **WHEN** a product exists with `is_active = true` and `entitlement_set_id` set
- **THEN** the product SHALL be available for use in grants
- **AND** pool provisions created from this product SHALL resolve `entitlement_set_id` from the product
#### Scenario: Non-public product hidden from members
- **WHEN** a product has `is_public = false`
- **THEN** the product SHALL NOT appear in member-facing product listings
- **AND** the product SHALL remain available for admin grant operations
#### Scenario: Product with metadata features
- **WHEN** a product has `metadata = {"features": ["Custom domains", "Community support"]}`
- **THEN** member-facing product displays SHALL include these strings as qualitative feature descriptions
- **AND** these descriptions SHALL appear after any entitlement-rule-derived features
#### Scenario: Product with no metadata features
- **WHEN** a product has `metadata` as NULL or a JSON object without a `features` key
- **THEN** member-facing product displays SHALL show only entitlement-rule-derived features
- **AND** no error SHALL occur
#### Scenario: display_category accepts any free-text value with no domain enforcement
- **WHEN** an operator sets a product's `display_category` to an arbitrary string, including a value that does not match any previously used grouping label or contains a typo
- **THEN** the write SHALL succeed with no CHECK-constraint violation
- **AND** no schema or application behavior SHALL differ as a result, beyond which presentation grouping the product is shown under
#### Scenario: NULL display_category does not affect eligibility
- **WHEN** a product is created or updated with `display_category` left NULL
- **THEN** the write SHALL succeed
- **AND** the product SHALL remain fully eligible for grants, conferral, ladder membership, and publication independent of `display_category`
#### Scenario: The readiness panel does not read display_category
- **WHEN** two listed products differ only in their `display_category`, one `addon` and one blank
- **THEN** the operator's readiness panel gives both the same verdict and the same member-catalog-visibility line
@@ -0,0 +1,438 @@
## MODIFIED Requirements
### Requirement: Operator product edit page surfaces purchasability readiness
The operator product edit page (`/operator/products/{id}`) SHALL display a
**purchasability readiness panel** that evaluates every precondition standing between
the product and a member being able to purchase it, and renders a single verdict —
**purchasable**, **ready to grant**, **inactive** or **incomplete** — with the specific unmet
preconditions named.
The panel SHALL evaluate and display the live state of each of these preconditions,
read from `billing.product_shape`:
- **Published** — `lifecycle_status = 'published'`.
- **Active** — `is_active = TRUE`, for **every** product. An inactive product is
offered by neither the member catalog nor the grant form, so the panel SHALL
report the row as unmet and the verdict SHALL read **Inactive** (never
**Purchasable** or **Ready to grant**) whenever the set and published
preconditions are met.
- **Listed** — `is_public = TRUE`. For an unlisted product (`is_public = FALSE`;
Requirement: Wrap-product creation) the panel SHALL report the row as
"Unlisted; issued as grants" rather than **unmet** — unlisted products are never
meant to be in the catalog, and only the entitlement-set half of the gate applies
to them.
- **Entitlement set present, with rules** — `product_shape.set_present`
(`entitlement_set_id IS NOT NULL`) and the set holds at least one active rule;
a present set with no active rule reads "No rules" and is unmet ("Incomplete;
missing: rules"). Required for **every** product regardless of
`is_public` — a published product with no entitlement set confers nothing,
whether it is a storefront product or an internal wrap product (Requirement:
Wrap-product creation).
- **Has an active price** — required **only for `is_public` products**:
`product_shape.billing_shape <> 'unpriced'` (at least one `billing.prices` row
with `is_active = TRUE`), and the price the product offers (its default price,
`price-management`) SHALL be recurring, because Checkout creates a subscription
and the member offer's price rule refuses a one-time price for a plan tier and a
product on no ladder alike (`member-offer`). A one-time default price SHALL read
One-time (a gap) with the detail "Make a recurring price the default on the Prices view; a
one-time price is not sold." and SHALL be named "recurring default price" in the
verdict's missing list. Products with `is_public = FALSE` (wrap products) are
**exempt** from this precondition — they are minted priceless by design, and the
panel SHALL NOT report a missing price against them.
- **Payment processing** (the Stripe-mapped price precondition, labeled in
operator-facing, implementation-neutral terms) — evaluated only where a price is
required (`is_public` products); for `is_public = FALSE` products the panel SHALL
report this precondition as **not applicable** rather than unmet. Where it does
apply, the active price has a `stripe.price_mappings` entry, distinguishing
**synced**, **sync pending** (a sync was enqueued and the mapping has not landed
yet), and **sync failed** (the enqueued sync has terminally failed). The row's
detail SHALL name the environment the mapping records against the mode the
console derives from its API key, so the surface where syncing is triggered says
whether the id it holds can be reached:
- the mapping records no environment, or records the key's and has not been read
back: `Synced, unverified`
- the mapping records the key's environment and has been read back: `Synced, live`
/ `Synced, test`
- the mapping records the other environment: `Synced in test, key is live` /
`Synced in live, key is test`
- the mapping is `stale`, the environment check having asked Stripe for the id
under the current key and been answered `resource_missing`: `Not found in live
mode` / `Not found in test mode`, naming the key's environment
A mapping that records the other environment, and a stale mapping, SHALL be
reported as **unmet**, because no member checkout can reach the id, and the
verdict SHALL read "Incomplete; missing: payment processing". An unverified
mapping SHALL be reported as met, because it blocks nothing
(stripe-integration-infrastructure, Requirement: Mappings record the environment
that made their ids). Whether the row is met SHALL be the same answer the member
pages read for the price's reach, from one computation.
The panel SHALL additionally report, for `is_public` products, a **member catalog
visibility** line: whether the member pages draw this product for a member who
holds nothing — **Shown** when the product clears the member gate and is a tier on
a plan ladder (drawn in the ladder's plan section), or is a tier on no ladder and
the member offer's price rule passes for it (listed in the non-plan section,
`member-product-discovery`); **Not shown** otherwise. `display_category` SHALL NOT
decide the line. The line SHALL carry no detail in any state. This line
SHALL NOT change the verdict and SHALL NOT count as a missing precondition. Because
a Purchasable listed product passes the price rule with the member gate, it is
always Shown, and the verdict SHALL carry no catalog qualifier. For
`is_public = FALSE` products the line reports **not applicable**.
The panel SHALL additionally report, for **diagnostics only and never as a
pass/fail precondition**, the product's shape from `billing.product_shape`:
`ladder_count`, `billing_shape`, and `consumption_shape`. These per-dimension
values are reported side by side and are never resolved into a single "kind"; a
product legitimately reporting, for example, `ladder_count = 0` alongside
`billing_shape = 'mixed'` is an ordinary, fully describable shape, not an
ambiguous or error state. A published product that is on no ladder and carries no
`display_category` is likewise an ordinary shape — off-ladder is not a limbo
state — and whether the member pages list it is the member-catalog-visibility
line's answer, which follows its price, never its `display_category`.
The readiness verdict SHALL ask the **same price rule** the member pages and
Checkout ask (`member-offer`: the rule `plans.Offer` applies to a ladder's tiers
and `plans.OffLadder` to a product on no ladder, `plans.PriceRule`), over the same
price facts the member pages build, so the operator panel and the member catalog
cannot disagree about whether a product can be bought. A **Purchasable** listed
product SHALL pass that rule with the member gate, have an entitlement set with an
active rule, and have its providers configured. The panel answers for the product,
not for an organization: whether a given organization is offered a move toward a
tier (a held rung, a grant below the target, another ladder of the product, a
suspended or paused position, another currency) is the member pages' answer alone.
This gate remains **application-level**, not schema/CHECK-enforced.
"Stripe configured for the deployment" SHALL be one answer for the panel, the member
pages and the Checkout route: the deployment has a Stripe API key, read as the mode
the console derives from it at boot. Boot accepts the key only together with the
webhook secret and only with a prefix that names a mode, so the mode is set exactly
when both credentials are. When Stripe is **not** configured, the
panel SHALL render an explanatory "Stripe is not configured" state for the
Stripe-mapped precondition rather than a bare "missing", and SHALL NOT offer the sync
action, and the member pages SHALL offer nothing for sale, whatever price mappings
an earlier key left (`member-product-discovery`).
The Stripe-mapped precondition SHALL be **actionable**, not merely a marker:
- When Stripe is configured, the product has an active price, and that price is
neither mapped nor a sync is already pending, the panel SHALL offer an explicit
**"Sync to Stripe"** action that enqueues the Stripe product and price sync.
- When a previously-enqueued sync has **terminally failed** (dead-lettered), the panel
SHALL show the Stripe-mapped precondition as **sync failed** — surfacing the failure
(with the recorded error) rather than leaving it pinned at "sync pending" — and
SHALL offer a **Retry** action.
While a sync is **pending**, the panel SHALL live-update without a manual page
refresh: it polls its own readiness partial and stops polling once the state is
terminal (synced or failed), so the operator watches "sync pending" resolve in
place.
The panel MUST NOT change member-facing catalog behavior; it is an operator-side
legibility surface only, and the sync-failed state is derived without altering the
shared purchasability gate.
#### Scenario: A rule-less set is not ready
- **WHEN** an operator views the readiness panel of a published, active product whose entitlement set has no active rule
- **THEN** the Entitlement set row reads "No rules" and the verdict reads "Incomplete; missing: rules"
#### Scenario: Fully configured plan shows purchasable
- **WHEN** an operator views the edit page for a published, public, active plan
product that is a tier on a ladder, has an active, recurring default price, and
whose price is Stripe-mapped
- **THEN** the readiness panel shows every precondition met and a verdict of
**Purchasable**
- **AND** the member-catalog-visibility line reports the product as shown in the
member catalog
#### Scenario: A tier whose default price is one-time is not purchasable
- **WHEN** an operator views a published, public, active product that is a tier on a
ladder, has an entitlement set with an active rule, and whose default price is
one-time and Stripe-mapped
- **THEN** the Active price row reads One-time (a gap) with the detail "Make a recurring
price the default on the Prices view; a one-time price is not sold."
- **AND** the verdict reads "Incomplete; missing: recurring default price"
- **AND** the member-catalog-visibility line reports the product as shown, as the
member pages draw its card with the control disabled
#### Scenario: Plan missing a price shows incomplete with guidance
- **WHEN** an operator views a published, on-ladder plan product that has no active
`billing.prices` row
- **THEN** the readiness panel shows a verdict of **Incomplete**, names the missing
"active price" precondition, and offers inline guidance to add a price
#### Scenario: Unmapped price (Stripe configured) offers the Sync to Stripe action
- **WHEN** an operator views a product with an active price that has no Stripe
mapping, and Stripe is configured for the deployment
- **THEN** the readiness panel shows the Stripe-mapped precondition as unmet **and**
offers a "Sync to Stripe" action for it
#### Scenario: Price present but Stripe mapping pending
- **WHEN** an operator views a product that has an active price for which a Stripe
sync has been enqueued (a `stripe.price_mappings` row with `sync_status = 'pending'`)
and the enqueued outbox actions have not terminally failed, but no `stripe_price_id`
has landed yet
- **THEN** the readiness panel shows the Stripe-mapping precondition as **sync
pending** (not synced), does not offer the Sync action again, and the overall
verdict as **Incomplete**
#### Scenario: Pending sync resolves in place without a refresh
- **WHEN** an operator triggers Sync to Stripe and stays on the product page
- **THEN** the readiness panel SHALL poll while the sync is pending and update to
the terminal state (synced or failed) automatically, ceasing to poll thereafter
#### Scenario: Terminally-failed sync shows Sync failed with a Retry action
- **WHEN** an operator views a product whose enqueued Stripe sync has dead-lettered
(the `create_stripe_product` / `create_stripe_price` outbox entry reached
`dead_letter`) while the mapping is still unmapped
- **THEN** the readiness panel shows the Stripe-mapped precondition as **sync failed**
with the recorded error, and offers a **Retry** action rather than showing an
indefinite "sync pending"
#### Scenario: Stripe not configured shows an explanatory state, not a dead end
- **WHEN** an operator views a product with an active price and the deployment's
Stripe credentials are not set
- **THEN** the readiness panel shows the Stripe-mapped precondition with an
explanatory "Stripe not configured for this deployment" note and SHALL NOT offer
the "Sync to Stripe" action
#### Scenario: With no Stripe key the panel and the member pages agree
- **WHEN** the deployment has no Stripe key and a listed product's recurring default
price carries a mapping an earlier key synced
- **THEN** the readiness panel's Payment processing row reads that Stripe is not
configured and the verdict is not Purchasable
- **AND** the member pages offer the product for sale nowhere
#### Scenario: A mapping that records no environment reads unverified and is met
- **WHEN** an operator views a product whose active price carries a mapping written
before the environment stamp, or one written under the current key and not yet
read back
- **THEN** the Payment processing row reads `Synced, unverified`, is met, and the
verdict is unchanged by it
#### Scenario: A mapping from the other environment is unmet
- **WHEN** an operator views a product whose active price carries a mapping
recorded in test while the API key is live
- **THEN** the Payment processing row reads `Synced in test, key is live`, is
reported as unmet, and the verdict reads
"Incomplete; missing: payment processing"
#### Scenario: A stale mapping is unmet and names the key's environment
- **WHEN** an operator views a product whose active price carries a mapping the
environment check marked `stale` under a live key
- **THEN** the Payment processing row reads `Not found in live mode`, is reported as
unmet, and the verdict reads "Incomplete; missing: payment processing"
#### Scenario: Published, off-ladder, untyped product is an ordinary shape, not limbo
- **WHEN** an operator views a published, `is_public` product with `display_category`
blank, `ladder_count = 0`, an entitlement set, and an active, recurring,
Stripe-mapped default price
- **THEN** the readiness panel reports `ladder_count = 0` as diagnostic shape
information alongside `billing_shape` and `consumption_shape`, not as a violation
- **AND** the panel does NOT flag the product as being in a limbo or ambiguous state
- **AND**, having an entitlement set and an active, recurring, mapped default price,
the verdict is **Purchasable**
- **AND** the member-catalog-visibility line reports the product as shown in the
member catalog, and the verdict carries no qualifier
#### Scenario: Off-ladder public product with price and mapping is purchasable
- **WHEN** an operator views a published, public, active product that is on no
ladder, carries an entitlement set, and has an active, recurring, Stripe-mapped
default price
- **THEN** the readiness panel shows the entitlement-set and price preconditions met
(ladder membership is not required for purchasability) and a verdict of
**Purchasable**
- **AND** the member-catalog-visibility line reports the product as shown, whatever
its `display_category`
#### Scenario: The catalog line follows the member pages, not the display category
- **WHEN** an operator views a published, public, active product that is on no
ladder and carries `display_category = 'addon'`, and whose default price is
one-time, missing, or not reachable under the current key
- **THEN** the member-catalog-visibility line reports the product as not shown in the
member catalog, as the member pages do not list it
- **AND** the verdict names the missing precondition and is not **Purchasable**
- **WHEN** the product is instead a tier on a ladder in the same state
- **THEN** the line reports it as shown, as the member pages draw its card
#### Scenario: A product outside the member gate is not shown
- **WHEN** an operator views a public product that is a tier on a ladder and is a
draft, retired, or inactive
- **THEN** the member-catalog-visibility line reports the product as not shown in the
member catalog
#### Scenario: Wrap product is exempt from the price precondition
- **WHEN** an operator views the edit page for an internal wrap product
(`is_public = FALSE`, `lifecycle_status = 'published'`, carrying an entitlement
set, and no `billing.prices` rows)
- **THEN** the readiness panel reports the "Entitlement set present" precondition
as met
- **AND** the panel SHALL NOT report the "Has an active price" or "Payment
processing" preconditions as unmet — they are reported as not applicable,
because `is_public = FALSE` products are exempt from the price half of the gate
#### Scenario: Wrap product's Visible precondition is not applicable, not unmet
- **WHEN** an operator views the edit page for an internal wrap product
(`is_public = FALSE`, `lifecycle_status = 'published'`, carrying an entitlement
set, and no `billing.prices` rows)
- **THEN** the readiness panel reports the **Visible** precondition as **not
applicable**
- **AND** the panel SHALL NOT report **Visible** as unmet or count it against the
product's readiness verdict — wrap products are never meant to be visible, and
only the entitlement-set half of the gate applies to them
### Requirement: The readiness panel prints its verdict
The readiness panel SHALL render the verdict string the readiness evaluation computes ("Purchasable", "Ready to grant" for an unlisted product, "Inactive" for a product whose set and published preconditions are met but whose `is_active` is false, "Incomplete; missing: …") and SHALL NOT render any literal derived from a single boolean in its place. No verdict SHALL carry a catalog qualifier: a Purchasable product is always shown in the member catalog (Requirement: Operator product edit page surfaces purchasability readiness).
#### Scenario: An inactive product reads Inactive
- **WHEN** an operator views the readiness panel of a product with an entitlement set, published, and `is_active = FALSE`, listed or not
- **THEN** the verdict reads "Inactive" and neither "Purchasable" nor "Ready to grant" appears
#### Scenario: An unlisted product reads Ready to grant
- **WHEN** an operator views the readiness panel of an active unlisted product whose entitlement set is present
- **THEN** the panel's verdict reads "Ready to grant" and the word "Purchasable" does not appear
### Requirement: The Status column is the verdict
For a published product the Products list's Status cell SHALL render the readiness verdict `buildProductReadinessVM` computes for the detail page, from the same definition: "Purchasable" in the success tone, with no note; "Ready to grant" in the info tone for a private product that is complete; "Incomplete" in the warning tone followed by the first unmet leg as muted text ("Missing: Entitlement set"), named as the readiness panel names it; "Inactive", with no note, for a product the panel calls Inactive. For a draft or retired product the cell SHALL render "Draft" or "Retired" in the secondary tone. The lifecycle word SHALL NOT render as a badge in any other tone, and the detail page's Details list SHALL state the lifecycle as text. The list SHALL compute the verdict for the page's products from batch reads of the shapes, prices, price mappings, and set rules, never one query per product, and SHALL NOT cache it: the cell says what the tables hold at render time.
#### Scenario: A set-less published product is not green
- **WHEN** an operator views the list with a published product that has no entitlement set
- **THEN** its Status cell reads "Incomplete" in the warning tone with "Missing: Entitlement set" beside it, and no "Published" badge renders
#### Scenario: A complete tier is Purchasable
- **WHEN** a published, public, active product on a ladder has an active, recurring default price mapped to Stripe and its providers configured
- **THEN** its Status cell reads "Purchasable" in the success tone
#### Scenario: A one-time default price is named
- **WHEN** a published, public, active product whose entitlement set has an active rule and whose providers are configured has a one-time default price
- **THEN** its Status cell reads "Incomplete" in the warning tone with "Missing: recurring default price" beside it
#### Scenario: A purchasable product on no ladder carries no note
- **WHEN** a published, public, active product on no ladder has an entitlement set with an active rule, an active, recurring default price mapped to Stripe, and its providers configured
- **THEN** its Status cell reads "Purchasable" in the success tone with no note beside it
#### Scenario: The list and the detail agree
- **WHEN** an operator opens a product from the list
- **THEN** the detail page's readiness panel states the same verdict the list's cell showed
#### Scenario: A page of products costs a fixed number of reads
- **WHEN** the list renders a page of fifty products
- **THEN** the verdicts come from the batch reads for that page, not from fifty per-product readiness computations
### Requirement: Operator can sync a product to Stripe from the readiness panel
The operator product surface SHALL expose an action
`POST /partials/operator/products/{productID}/sync-stripe` that drives a product and
its active price to Stripe-mapped state by enqueuing the catalog sync, and that
doubles as the **retry** for a terminally-failed sync. When invoked it SHALL:
- Reject with an explanatory message and enqueue nothing when Stripe is not configured
for the deployment (the deployment has no Stripe API key, read as the mode the console derives from it at
boot; Requirement: Operator product edit page surfaces purchasability readiness).
- Reject with guidance to add a price, and enqueue nothing, when the product has no
active price.
- Be idempotent for in-flight and completed syncs in the current environment: when
the active price's mapping records the key's environment or records none, and is
not `stale`, or a product- or price-mapping is `pending` with no terminally-failed
outbox entry, it SHALL NOT enqueue a duplicate sync (so repeat invocations do not
create duplicate Stripe objects) and SHALL refuse with
`This price is already synced to Stripe.`
- **Create the object again when the key cannot reach it:** when the active price's
mapping records the other environment, or carries `sync_status = 'stale'`, the
refusal SHALL NOT fire; the action SHALL write the mappings and enqueue
`create_stripe_product` and `create_stripe_price` as it does for a never-synced
product, and the mapping the executor writes replaces the unreachable id. The
control SHALL be labelled `Create in live` or `Create in test` in that case,
naming the environment the key is in, with no confirm modal: the modal is the
destructive idiom and creating an object in the current environment destroys
nothing, while the readiness row beside the control states why the old id is
unreachable.
- **Retry a terminally-failed sync:** when the product's `create_stripe_product` /
`create_stripe_price` outbox entries have dead-lettered, it SHALL re-drive them
(reset the dead-lettered entries to `pending` so the outbox drains them again)
rather than treating the still-`pending` mapping as an in-flight no-op.
- Otherwise (never synced) write `stripe.product_mappings` and `stripe.price_mappings`
rows with `sync_status = 'pending'` and enqueue both a `create_stripe_product` and a
`create_stripe_price` outbox entry, so the readiness panel immediately reflects
**sync pending** and the mappings land when the outbox drains.
Creating a price SHALL NOT by itself enqueue a Stripe sync; the explicit
Sync-to-Stripe action is the single operator-driven sync trigger.
#### Scenario: Sync action enqueues product and price and shows pending
- **WHEN** an operator invokes the Sync-to-Stripe action for a product that has an
active price, is not yet mapped, and for which no sync is pending, with Stripe
configured
- **THEN** a `stripe.product_mappings` row and a `stripe.price_mappings` row SHALL be
written with `sync_status = 'pending'`
- **AND** a `create_stripe_product` and a `create_stripe_price` outbox entry SHALL be
enqueued
- **AND** the re-rendered readiness panel SHALL show the Stripe-mapped precondition as
**sync pending**
#### Scenario: Sync action is a no-op when already pending (in-flight) or synced
- **WHEN** an operator invokes the Sync-to-Stripe action for a product whose price is
already mapped in the key's environment or in none, and not `stale`, or whose
product/price mapping is `pending` with no dead-lettered outbox entry
- **THEN** no additional outbox entry SHALL be enqueued and no duplicate Stripe object
SHALL be created
- **AND** the action SHALL answer `This price is already synced to Stripe.`
#### Scenario: Sync creates the object again when the mapping is unreachable
- **WHEN** an operator invokes the Sync-to-Stripe action for a product whose active
price carries a mapping recorded in the environment the key is not in, or one
marked `stale`
- **THEN** the action SHALL NOT refuse; it SHALL write the mappings and enqueue
`create_stripe_product` and `create_stripe_price`, the readiness panel SHALL show
**sync pending**, and the mapping the executor writes SHALL replace the
unreachable id
- **AND** the control SHALL be labelled `Create in live` under a live key and
`Create in test` under a test key, and SHALL open no confirm modal
#### Scenario: Retry re-drives a dead-lettered sync
- **WHEN** an operator invokes the Sync-to-Stripe action for a product whose
`create_stripe_product` / `create_stripe_price` outbox entries have dead-lettered
- **THEN** those dead-lettered entries SHALL be reset to `pending` (so the outbox
re-processes them) rather than the action no-op'ing on the stuck `pending` mapping
- **AND** the re-rendered readiness panel SHALL show the Stripe-mapped precondition
back at **sync pending**
#### Scenario: Sync action rejected when Stripe is not configured
- **WHEN** the Sync-to-Stripe action is invoked while the deployment's Stripe
credentials are not set
- **THEN** it SHALL render an explanatory message and SHALL enqueue nothing
@@ -0,0 +1,404 @@
Tests follow `docs/testing.md`; code follows `docs/go-conventions.md`. The new tests of items 1.3 and 4.1 need no database; those of items 4.2 and 4.3 read the shared test database (`TEST_DATABASE_URL`) and commit their rows, as the files they join already do.
Sections 1 to 5 are the parts of one commit; section 6 is not a commit. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). The existing tests whose code or result changes are exactly those section 4 names: the readiness fixtures that gain `Recurring: true`, the products-list fixture whose price becomes monthly and the read-failure handlers that gain a key mode (no assertion changes), the catalog-visibility test that the agreement test replaces, one render subtest that asserted the qualifier, one subtest of `TestBuildProductReadinessVM_InactiveAndListed` that gains an assertion and has its `Missing` loop rewritten with the same message, the two label literals and the render test's `row structure` comment, the compile fixes of item 2.6, and six golden files whose diffs are only the removed qualifier notes and catalog-row details. Any other change of an existing test's result is a stop. A script's usage is learned from its header or source, never by running it.
**Breaks.** The list of item 4.7 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit, from the repository root, after its green control run, with `test/.env` loaded (`set -a; . test/.env; set +a`), since some records' tests read the database. A break of Go code is a test record (`name`, which the tool reports by, `file`, `old`, `new`, `pkg`, `run_new`, `want_new`). A break caught by two rows is two records, one per row, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the items write it, tabs included; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. Check that each `old` occurs exactly once in its `file` before the run: with `grep -c` for a one-line `old`, and, for the three multi-line `old` texts of the price rule's order, with a script that counts the exact text. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
## 1. The price rule, exported
- [x] 1.1 `internal/plans/offer.go` (design D1): `priceRule` (`:450-466`) is replaced by two functions, in its place:
```go
// PriceRule is the price rule: NoReason when a member may buy the target's
// price, and otherwise the first refusal of NotListed (the product is
// outside the member gate), NoPrice, NotDefault, OneTime and Unpurchasable,
// in that order. Every Checkout and switch Offer answers, and OffLadder's
// Checkout, pass through it. It reads no position, so a surface that
// answers for a product rather than for an organization's position, such
// as the operator's readiness panel, asks it and agrees with them.
func PriceRule(t Target) Reason {
p := t.Price
switch {
case !t.Listed:
return NotListed
case p.ID == "":
return NoPrice
case !p.Default:
return NotDefault
case !p.Recurring:
return OneTime
case !p.Purchasable:
return Unpurchasable
}
return NoReason
}
// priceMove is the price rule's answer as a Checkout or a Switch toward the
// target: the move enabled when the rule passes; no move for a product
// outside the member gate or a price that is not the default; the move
// disabled for a tier with no price, a one-time price, or a price with no
// Stripe price the key can reach.
func priceMove(kind Kind, t Target) Move {
switch r := PriceRule(t); r {
case NoReason:
return Move{Kind: kind, Enabled: true}
case NotListed, NotDefault:
return Move{Reason: r}
default:
return Move{Kind: kind, Reason: r}
}
}
```
The four calls of `priceRule` (`:373`, `:437`, `:443`, `:498`) call `priceMove` with the same arguments; `OffLadder`'s body reads ` return priceMove(Checkout, t)`. No other line of the file changes but item 1.2's.
- [x] 1.2 `Offer`'s doc comment, its last paragraph (`:302-305`): "The price rule decides Checkout and Switch:" reads "The price rule (PriceRule) decides Checkout and Switch:"; the rest of the paragraph stays.
- [x] 1.3 `internal/plans/offer_test.go`, after `TestOffLadder`: `TestPriceRule`, a table whose rows each edit `target("plus")` (the fixture's buyable, listed target):
- Each row a `t.Run` named as written below.
- `offered`: no edit; want `NoReason`.
- `outside the member gate`: `Listed = false`; want `NotListed`.
- `outside the member gate with no price`: `Listed = false`, `Price = Price{}`; want `NotListed`.
- `no price`: `Price = Price{}`; want `NoPrice`.
- `another price`: `Price.Default = false`; want `NotDefault`.
- `another price that is one-time`: `Price.Default = false`, `Price.Recurring = false`; want `NotDefault`.
- `a one-time price`: `Price.Recurring = false`; want `OneTime`.
- `a one-time price out of reach`: `Price.Recurring = false`, `Price.Purchasable = false`; want `OneTime`.
- `not purchasable`: `Price.Purchasable = false`; want `Unpurchasable`.
Each row checks four answers, with these failure texts (the row's name first): `PriceRule(tg)` equals the want, `%s: PriceRule = %q, want %q`; `OffLadder(tg)` carries the want as its reason and allows Checkout exactly when the want is `NoReason`, `%s: OffLadder answers %+v, the price rule %q`; `Offer(Position{Tiers: ladderTiers}, tg)` (the organization holds nothing, so the move is Checkout) carries the want, `%s: Offer's Checkout answers %q, the price rule %q`; `Offer(holds("basic", true), tg)` (a subscription holds a lower rung, so the move is a switch) carries the want, `%s: Offer's switch answers %q, the price rule %q`. `TestOffer`, `TestOffLadder` and every other test of the package keep their results.
## 2. One source of "Stripe is configured" (#282)
- [x] 2.1 `internal/server/product_readiness.go`, after `PriceReadiness` (design D3):
```go
// stripeConfigured reports whether the deployment sells through Stripe, from
// the mode boot derives from its API key ("" when it has none). Boot refuses
// a key whose prefix names no mode, and a key set without its webhook secret
// or a secret without its key (the Stripe configuration group), so the mode
// is set exactly when both are. The member pages' position loader, the
// readiness panel, Checkout's route and the checkout return's reconcile all
// ask it, so none of them sells or answers for a sale the others refuse.
func stripeConfigured(keyMode string) bool {
return keyMode != ""
}
```
`PriceReadiness.StripeConfigured`'s comment (`:37-40`): "(the Stripe API key and webhook secret are both set)" reads "(stripeConfigured)". The parameter named `stripeConfigured` in `computePriceReadiness` (`:65`, `:67`, `:81`) and in `priceReadinessFromBatches` (`:129`, `:131`, `:141`) is renamed `configured`, since it would shadow the new function (a ride-along, design D5).
- [x] 2.2 `internal/server/plan_offer.go`, `positionLoader` (`:27-48`): a field `keyMode string` after `ents`, with the comment "keyMode is the mode of the deployment's Stripe key, \"\" when it has none; the price facts judge a mapping's reach under it." The `stripeQ` comment (`:30-31`) reads "stripeQ is nil when no database is set or Stripe is not configured (stripeConfigured), which leaves every price unmapped." `newPositionLoader` (`:50-63`) becomes:
```go
// newPositionLoader builds a loader over the billing and entitlements
// queriers. keyMode is the mode of the deployment's Stripe key, "" when it
// has none. The loader reads price mappings only when db is set and
// stripeConfigured(keyMode): with no key every price is unmapped, so nothing
// is offered for sale whatever mappings an earlier key left.
func newPositionLoader(billingQ billing.Querier, entsQ entitlements.Querier, db *sql.DB, keyMode string, now func() time.Time) *positionLoader {
l := &positionLoader{
billing: billingQ,
ents: entsQ,
keyMode: keyMode,
now: now,
predictions: make(map[string]*cancelPrediction),
}
if db != nil && stripeConfigured(keyMode) {
l.stripeQ = internalstripe.New(db)
}
return l
}
```
- [x] 2.3 Same file, the price facts (design D1, D4). New, before `defaultPrice`:
```go
// offeredPrice is the offer decision's facts of the price the readiness
// tracks (computePriceReadiness): the product's default price, recurring or
// not, purchasable when its Stripe mapping carries an id keyMode can reach;
// the zero Price when the product has no active price. The member pages and
// the readiness panel both build a target's price with it, so they cannot
// disagree about a price.
func offeredPrice(r PriceReadiness, keyMode string) plans.Price {
if !r.HasActivePrice {
return plans.Price{}
}
return plans.Price{
ID: r.PriceID,
Default: true,
Recurring: r.Recurring,
Purchasable: readinessPurchasable(r, keyMode),
}
}
```
`defaultPrice` (`:427-459`) loses its `keyMode` parameter: `func (l *positionLoader) defaultPrice(ctx context.Context, productID string) (priceFacts, error)`. After `computePriceReadiness` and its error check, its body reads ` facts := priceFacts{Price: offeredPrice(r, l.keyMode)}` followed by the existing `if !facts.Price.Recurring || !facts.Price.Purchasable { return facts, nil }` and the rest unchanged; the separate `if !r.HasActivePrice { return priceFacts{}, nil }` goes, since `offeredPrice` answers the zero `Price` and the check after it returns. Its doc comment's "A price is purchasable only when its Stripe price mapping carries an id keyMode can reach" reads "the loader's key mode". `postedPrice` (`:467-486`) loses its `keyMode` parameter too: `func (l *positionLoader) postedPrice(ctx context.Context, price billing.Price) (plans.Price, error)`, with `Purchasable: isDefault && readinessPurchasable(r, l.keyMode),`.
- [x] 2.4 The callers, each passing the mode it already holds (design D3):
- `internal/server/member_addons.go:100`: `loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)`; `:105`: `price, err := loader.defaultPrice(ctx, p.ProductID)`.
- `internal/server/member_plans.go:311`: `loader: newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now),`; `:491`: `price, err := b.loader.defaultPrice(ctx, t.ProductID)`.
- `internal/server/member_plan_moves.go:228`: `loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)`.
- `internal/server/plan_offer.go:559` and `:633`: `loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, h.StripeMode, time.Now)`; `:572`: `posted, err := loader.postedPrice(ctx, price)`.
- `internal/server/billing.go:264`: `loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, keyMode, time.Now)`; `:265`: `price, err := loader.postedPrice(ctx, target.price)`. `checkoutOffer` keeps its `keyMode` parameter.
- `internal/server/member_plans.go:36-39`, the `Database` field's comment, which says a nil database leaves plans "not purchasable" (the assumption behind #282; production always sets it), reads: "Database resolves Stripe price mappings for the price facts; the loader reads them only when StripeMode is set (stripeConfigured), and reads none when Database is nil."
- [x] 2.5 `internal/server/server.go` (design D3):
- `Config` loses `StripeWebhookSecret` and `StripeAPIKey` (`:50-51`); the `StripeMode` comment (`:53-57`) is unchanged. gofmt realigns the 11 remaining lines of the field block (`:39-52`), so the diff shows them.
- `registerCoreHandlers`, the operator partials' config (`:624`): ` StripeConfigured: stripeConfigured(cfg.StripeMode),`.
- The Checkout registration (`:711`): ` if stripeConfigured(cfg.StripeMode) {`; its comment (`:708-710`) is unchanged.
- The checkout return (`:900`): ` if checkoutStatus == "success" && stripeConfigured(cfg.StripeMode) && session != nil {`.
`cmd/start.go:310-311`, the two lines that set `StripeWebhookSecret` and `StripeAPIKey`, go. `git grep -n 'StripeAPIKey\|StripeWebhookSecret' -- cmd internal` then finds nothing.
- [x] 2.6 Compile fixes, with no assertion changed:
- `internal/server/member_catalog_environment_db_test.go:113`: `loader := newPositionLoader(h.BillingQ, nil, database, h.StripeMode, time.Now)`; `:119`: `facts, err := loader.defaultPrice(ctx, tc.productID)`. The file's header comment (`:13-18`) reads "the position loader builds the Stripe querier from h.Database when the key mode is set".
- `internal/server/new_handler_test.go`: `routeConfig` (`:116-117`) loses the two fields; `routeTable`'s bare configuration (`:386`) reads `bare := newRouteEnvOver(t, func(c *Config) { c.StripeMode = "" }, env.auth)`, the configuration a deployment with no key has. `routes.golden` is unchanged: its `bare:` row already records no Checkout route.
- `internal/server/new_handler_db_test.go:382`: `bare := newRouteEnv(t, func(c *Config) { onDatabase(f.db)(c); c.StripeMode = "" })`.
## 3. The readiness panel asks the price rule (#178)
- [x] 3.1 `internal/server/product_readiness.go`, `commerceRows` (`:452-503`; design D1, Copy C1, C8): the `readinessState` constants (`:373-381`) gain `stateOneTime readinessState = "one_time"`, a gap like `stateNoRules` (`missingFrom` skips only met, n/a and unlisted). `commerceRows`'s doc (`:446-451`), where it says the purchasable verdict reads the price beside `pr.StripeMapped`, reads "buildProductReadinessVM reads it to offer the Sync control again and to relabel it (unreachable)." The Active price row's two lines (`:470-471`) become:
```go
switch {
case !priced || !pr.HasActivePrice:
priceRow.State = stateUnmet
priceRow.Detail = "Add an active price on the Prices view."
case !pr.Recurring:
// The price rule refuses a one-time default price for a tier and a
// product on no ladder alike (plans.PriceRule, OneTime): Checkout
// creates a subscription.
priceRow.State = stateOneTime
priceRow.MissingName = "recurring default price"
priceRow.Detail = "Make a recurring price the default on the Prices view; a one-time price is not sold."
default:
priceRow.State = stateMet
}
```
- [x] 3.2 Same file, `syncedPaymentDetail` (`:337-365`; design D1): its body becomes
```go
met = readinessPurchasable(pr, stripeMode)
if stripeMode == "" {
return "Synced", met
}
other := internalstripe.OtherMode(stripeMode)
switch {
case pr.SyncStatus == internalstripe.SyncStatusStale:
// The check asked Stripe for this id under the current key and was
// answered resource_missing, so the key's environment is the one
// the id is not in; the recorded flag may agree and still be no
// help, since two Stripe environments both report livemode false.
return "Not found in " + stripeMode + " mode", met
case !internalstripe.MappingAgrees(pr.Livemode, stripeMode):
return "Synced in " + other + ", key is " + stripeMode, met
case !pr.Livemode.Valid || !pr.VerifiedAt.Valid:
return "Synced, unverified", met
}
return "Synced, " + stripeMode, met
```
and its doc comment gains, after its first sentence: "met is readinessPurchasable's answer, the reach the member pages' price facts carry (offeredPrice), so the row and the member pages cannot disagree about a recorded id; the switch only picks the words." The paragraph on an unknown mode reads: "An empty mode names nothing and is met. A booted console has an empty mode only with no key, and then its readiness holds no mapping (stripeConfigured), so the branch is reached only by a view model built by hand; whether an empty mode should reach a recorded id at all is MappingReachable's question."
- [x] 3.3 Same file, `buildProductReadinessVM` (`:582-648`; design D1, D2). The `priced` variable (`:588`) stays: the Active price row reads the same shape (`commerceRows`), so the verdict and the row cannot split when a price is added between the two reads (the detail page reads the price, `operator_products.go:909`, and the shape, `:916`, in separate statements). The Purchasable block (`:634-642`) and the findability lines (`:644-646`) become:
```go
// The price rule Checkout, the plan switch and the member pages ask
// (plans.PriceRule), over the price facts the member pages build
// (offeredPrice): the member gate, the default price, its recurrence and
// its reach. A tier and a product on no ladder answer alike; which move
// an organization is offered toward a tier depends on its position,
// which the panel does not judge.
sells := plans.PriceRule(plans.Target{
ProductID: product.ProductID,
Listed: gate.OK(),
Price: offeredPrice(pr, in.stripeMode),
}) == plans.NoReason
if isPublic {
vm.Purchasable = sells && priced && setPresent && hasRules && providersOK
} else {
vm.Purchasable = gate.Published && gate.Active && setPresent && hasRules && providersOK
}
// The member pages draw a listed tier in its ladders' plan sections and
// list a product on no ladder exactly when the price rule passes
// (buildAddonsData, plans.OffLadder), so a Purchasable product is always
// shown.
shown := gate.OK() && (shape.LadderCount > 0 || sells)
vm.Rows = append(vm.Rows, catalogRow(isPublic, shown))
vm.verdict(setPresent, hasRules, gate)
```
The comment above the old block ("stripeReachable as well as StripeMapped …", `:635-638`) goes with it; `stripeReachable` stays for `unreachable` (`:608`). The import block gains `"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"`. The detail page's comment in `internal/server/operator_products.go` (`:907-908`), "compose the shared price+mapping gate with the visibility/structural preconditions", reads "Purchasability readiness: the price facts the member pages build, beside the product's shape and set."
- [x] 3.4 Same file, `catalogRow` (`:527-549`; design D2, Copy C4, C5, C9):
```go
// catalogRow is the member-catalog-visibility row, reported for listed
// products: shown is whether the member pages draw the product for a member
// who holds nothing on its ladders (buildProductReadinessVM). It is a
// diagnostic, never a precondition, so it never enters Missing; an unlisted
// product's row is not applicable.
func catalogRow(isPublic, shown bool) ProductReadinessRow {
row := ProductReadinessRow{Label: "Member catalog visibility"}
switch {
case !isPublic:
row.State = stateNA
case shown:
row.State = stateShown
default:
row.State = stateHidden
}
return row
}
```
- [x] 3.5 Same file, the verdict (design D2, Copy C6): `verdict` (`:551-576`) loses its `findable` parameter, `func (vm *ProductReadinessVM) verdict(setPresent, hasRules bool, gate memberGate)`, and its Purchasable case reads ` vm.Verdict = "Purchasable"` with no qualifier; its doc comment's first sentence reads "verdict sets vm's Verdict from vm.Missing and vm.Purchasable, which the caller sets first, and the facts it is passed." `ProductReadinessVM.CatalogQualifier` (`:273-278`) goes. `ProductReadinessVM`'s comment (`:241-247`) reads "(is_public ⇒ the member offer's price rule passes, plans.PriceRule)" in place of "(is_public ⇒ the product is priced and Stripe-mapped)", and "composed with the price facts (PriceReadiness) and the member offer's price rule" in place of "composed with the shared price+mapping gate (PriceReadiness)". `PriceReadiness`'s comment (`:24-28`) loses its sentence "This is the single definition of the price + Stripe-mapping purchasability gate…" and its last, "A member is purchasable on this axis exactly when HasActivePrice && StripeMapped."; it reads, in their place: "It carries the price facts the member pages and the readiness panel both build a target's price from (offeredPrice), so the two can never disagree about a price." `PriceReadiness`'s first sentence (`:18-20`) reads "PriceReadiness is the facts of a product's tracked price: whether it exists, is recurring, and has a Stripe mapping, and that mapping's sync state."
- [x] 3.6 Same file, `productListStatus` (`:650-678`; Copy C7): `case vm.Purchasable && isPublic:` returns `"purchasable", ""`; its doc comment's "the note is the muted qualifier or the first unmet leg" reads "the note is the first unmet leg".
- [x] 3.7 `internal/server/operator_products.go`: `displayCategoryAddon` and its comment (`:27-31`) go. `StatusNote`'s comment (`:64-67`) reads "StatusNote is the list row's muted text beside the Status badge: the first unmet leg (\"Missing: Entitlement set\"). Empty for draft and retired rows and for a purchasable or ready product."
- [x] 3.8 `internal/embeds/templates/partials/operator_product_readiness.html`, the verdict comment (`:22-29`): the list of verdicts drops its two lines `"Purchasable; not` / `shown in the member catalog",` (the string wraps at `:26-27`). The header comment's list of text annotations (`:13-15`) gains "One-time" after "No rules". The row's state switch gains, after the "No rules" branch, `{{ else if eq .State "one_time" }}<span class="text-warning-emphasis small">One-time</span>` (Copy C8); the detail line below the row already prints for every state but met.
- [x] 3.9 `internal/integrations/stripe/store/mapping_environment.go:87-90`, `MappingReachable`'s comment: the reason "a deployment whose key the console could not classify knows nothing about which environment it is in" is a case boot refuses (`internal/integrations/stripe/stripe.go:284-297`); the paragraph reads: "An empty keyMode means the deployment has no key, since boot refuses a key whose mode it cannot classify. It answers true."
## 4. Tests and their breaks
- [x] 4.1 `internal/server/product_readiness_test.go`: `TestTheReadinessPanelAgreesWithTheMemberPages` replaces `TestBuildProductReadinessVM_MemberCatalogVisibility`, `catalogVisibilityCase`, `checkCatalogVisibility` and `productAddon` (`:28-35`, `:162-268`), which go. A helper beside it:
```go
// memberAnswer is what the member pages do with a product for a member who
// holds nothing on its ladders, from the facts the panel reads: whether they
// draw it and whether they sell it. A listed tier is drawn on every ladder it
// is a tier of (ListPublicPlanProducts) and sold when plans.Across of the
// offer decision's answers from an empty position, one per ladder, offers
// Checkout; a product on no ladder is drawn exactly when plans.OffLadder
// offers it Checkout (buildAddonsData). It is a model of the pages, not the
// pages, and shares offeredPrice with the panel: the tests that hold it to
// the pages are TestBuildAddonsDataOneTimePriceNotListed (member_extras_test.go),
// member_catalog_environment_db_test.go and TestTheStripeKeyDecidesWhatThePagesSell (new_handler_db_test.go).
func memberAnswer(p billing.Product, ladders int64, pr PriceReadiness, keyMode string) (drawn, sold bool) {
t := plans.Target{ProductID: "product", Listed: evaluateMemberGate(p).OK(), Price: offeredPrice(pr, keyMode)}
if ladders == 0 {
sold = plans.OffLadder(t).Allows(plans.Checkout)
return sold, sold
}
pos := plans.Position{Tiers: []plans.Tier{{ProductID: "product", Rank: 1}}}
moves := make([]plans.Move, ladders)
for i := range moves {
moves[i] = plans.Offer(pos, t)
}
return t.Listed, plans.Across(moves).Allows(plans.Checkout)
}
```
and `labelled(p billing.Product, label string) billing.Product`, which sets `DisplayCategory`. The fixtures: `reachable` is `PriceReadiness{StripeConfigured: true, HasActivePrice: true, PriceID: "p1", Recurring: true, StripeMapped: true}` (a mapping with no recorded environment, which every key reaches); `oneTime` is `reachable` with `Recurring: false`; `otherEnv` is `reachable` with `Livemode: sql.NullBool{Bool: true, Valid: true}`; `unsynced` is `reachable` with `StripeMapped: false`; `noPrice` is `PriceReadiness{StripeConfigured: true}`; `noKey` is `PriceReadiness{HasActivePrice: true, PriceID: "p1", Recurring: true}`. A tier's shape is `shapeForLadder(1)` (`shapeForLadder(2)` for a tier of two ladders), with `BillingShape` set to `"one_time"` or `"unpriced"` where the price says so; a product on no ladder's is `shapeFor(true, "recurring")`, `shapeFor(true, "one_time")` or `shapeFor(true, "unpriced")`. Each row builds the panel with `buildVMRules(product, shape, price, 1, mode)`, with mode `"test"` unless the row says `""`, and the member pages' answer with `memberAnswer(product, shape.LadderCount, price, mode)`. Rows, each `t.Run` named as written, with the design's failure-table row:
| Row | Product | Price, mode | Verdict | Catalog row |
|---|---|---|---|---|
| `a tier with a recurring reachable price` (F1) | `product("published", true, true)`, tier | `reachable` | `Purchasable` | shown |
| `a tier with a one-time price` (F2) | the same | `oneTime` | `Incomplete; missing: recurring default price` | shown |
| `a tier with no price` (F3) | the same | `noPrice` | `Incomplete; missing: Active price, payment processing` | shown |
| `a tier whose mapping is in the other environment` (F4) | the same | `otherEnv` | `Incomplete; missing: payment processing` | shown |
| `an unlabelled product on no ladder with a recurring reachable price` (F5) | `product("published", true, true)`, no ladder | `reachable` | `Purchasable` | shown |
| `an addon-labelled product on no ladder with a recurring reachable price` (F6) | `labelled(…, "addon")`, no ladder | `reachable` | `Purchasable` | shown |
| `an addon-labelled product on no ladder with a one-time price` (F7) | the same | `oneTime` | `Incomplete; missing: recurring default price` | hidden |
| `an unlabelled product on no ladder with a one-time price` (F8) | no label, no ladder | `oneTime` | `Incomplete; missing: recurring default price` | hidden |
| `an addon-labelled product on no ladder whose price is not synced` (F9) | `addon`, no ladder | `unsynced` | `Incomplete; missing: payment processing` | hidden |
| `an addon-labelled product on no ladder with no price` (F10) | `addon`, no ladder | `noPrice` | `Incomplete; missing: Active price, payment processing` | hidden |
| `a tier with no Stripe key` (F11) | tier | `noKey`, mode `""` | `Incomplete; missing: payment processing` | shown |
| `an addon-labelled product on no ladder with no Stripe key` (F12) | `addon`, no ladder | `noKey`, mode `""` | `Incomplete; missing: payment processing` | hidden |
| `a draft tier` (F14) | `product("draft", true, true)`, tier | `reachable` | `Incomplete; missing: published` | hidden |
| `an inactive tier` (F14) | `product("published", false, true)`, tier | `reachable` | `Inactive` | hidden |
| `a product on no ladder with no entitlement set` (F16) | no label, no ladder, `shapeFor(false, "recurring")` | `reachable` | `Incomplete; missing: entitlement set` | shown |
| `a tier of two ladders with a recurring reachable price` (F20) | `product("published", true, true)`, tier, `shapeForLadder(2)` | `reachable` | `Purchasable` | shown |
Each row runs through a named function, `checkPanelAgainstMemberPages(t, row)`: gocognit counts a `t.Run` closure toward its enclosing function (`docs/testing.md`), and seven checks written inline sit at the linter's limit of 30. Every check reports with `t.Errorf`, so one failing row prints every failed check. The checks, with these failure texts (the row's name first):
- the verdict, exactly: `%s: verdict %q, want %q`;
- the catalog row's state against the table: `%s: catalog row %s, want %s`;
- the catalog row against the member pages: shown exactly when drawn, `%s: the panel reads %s, the member pages draw it: %v` (the row's state, then `drawn`);
- Purchasable against the member pages: `vm.Purchasable` equals `sold` and the shape's set being present, `%s: the panel calls it Purchasable: %v, the member pages sell it: %v`;
- on the three one-time rows, the Active price row's state is `one_time` and its detail is exactly `Make a recurring price the default on the Prices view; a one-time price is not sold.` (written as a literal in the test), `%s: Active price row %s %q, want one_time %q`;
- on the three one-time rows, `productListStatus("published", true, vm)` returns `("incomplete", "Missing: recurring default price")`, `%s: list status %q %q, want incomplete "Missing: recurring default price"`; on every row whose verdict is `Purchasable`, it returns `("purchasable", "")`, `%s: list status %q %q, want purchasable and no note`;
- on every row, the verdict carries no catalog qualifier: `%s: verdict %q carries a catalog qualifier` when it contains `not shown`;
- on every row, the catalog row is not in `Missing`: `%s: the catalog row is in Missing`.
F15 (an unlisted product) keeps its test, `TestBuildProductReadinessVM`'s `unlisted product needs no price and is ready to grant`, and its catalog row is held by the added assertion of item 4.4. F17 is the offer decision's own (`TestOffer`), which this change does not touch. F18 keeps `product_readiness_default_price_test.go`. F19 is the configuration item 2.6's bare environments and item 4.3's keyless one build, and `TestModeForKey`'s `whitespace only` row (`internal/integrations/stripe/stripe_test.go:36`) holds that a key of spaces gives an empty mode: with the key fields gone from `server.Config`, a key of spaces reaches the server as an empty mode, the input those tests give.
- [x] 4.2 `internal/server/member_catalog_environment_db_test.go`, `TestMemberCatalogGateFollowsTheMappingEnvironment`, after its loop (F11, F12): the cases' type becomes `mappingCase`, and the test calls `checkCatalogWithoutAKey(t, ctx, h, database, cases)`, a named function, since gocognit counts the subtest closures toward the enclosing function (the parent stays at 29, the helper is about 14). The helper builds a handler `keyless := &MemberProductsHandler{BillingQ: h.BillingQ, Logger: h.Logger, Database: database}` (no key mode), reads its `buildAddonsData(ctx)` once into a set of listed ids, builds `keylessLoader := newPositionLoader(h.BillingQ, nil, database, "", time.Now)`, and for each of the four cases runs a subtest named `"without a key " + tc.name` checks that the product is not listed, `%s: listed in Extras with no Stripe key`; that `keylessLoader.defaultPrice(ctx, tc.productID)` is not purchasable, `%s: defaultPrice purchasable with no Stripe key`; and that it still names the price, `%s: defaultPrice names no price with no Stripe key`. The same four mappings that the keyed loop sells or refuses by their environment are all refused without a key.
- [x] 4.3 `internal/server/new_handler_db_test.go`: `TestTheStripeKeyDecidesWhatThePagesSell` (F11 to F13, and the wiring of item 2.5). The fixture: `registerGoldenKeys(t, w)`; three products, each from `w.Product`, with a boolean rule on `goldenForumKey`, a price and `w.MapPrice` (a mapping with no recorded environment): two with a monthly price from `w.Price`, one of them the only tier of a new ladder (`w.Ladder`) and the other on no ladder, and a third on no ladder whose default price is one-time, from `w.OneTimePrice` (`w.Price` is always monthly). Each subtest runs as a named method on a fixture struct, as `memberPagesEnv` does (`new_handler_db_test.go:186-213`), since written inline with three helper closures the test scores 28 against gocognit's limit of 30. A Stripe mock that answers every call with an error, installed with `stripetest.Install`, so a page that called Stripe fails. Two routers: `keyed := newRouteEnv(t, onDatabase(db))` (mode `test`) and `keyless := newRouteEnv(t, func(c *Config) { onDatabase(db)(c); c.StripeMode = "" })`. On each, a member of a new organization type signed in with `member`, and an operator signed in as `TestNewHandlerPages` signs one in. Subtests:
- `with a key the Products page offers both`: `GET /products` as the member; among the elements matching `[hx-post="/billing/checkout"]`, one's `hx-vals` holds the tier's price id and one's the monthly product's; `with a key the Products page offers no Checkout of %s` (the product's name) otherwise.
- `with a key a one-time product is not listed and reads Incomplete`: on `keyed`, no element matching `[hx-post="/billing/checkout"]` carries the one-time product's price id, `with a key the Products page lists the one-time product`; `GET /operator/products/<the one-time product>` as the operator reads `Incomplete; missing: recurring default price` in `#product-readiness .alert strong`, `with a key the one-time product's page reads %q, want "Incomplete; missing: recurring default price"`.
- `with no key the Products page offers neither`: the same on `keyless`, for the two monthly products; no element matches `[hx-post="/billing/checkout"]`, `with no Stripe key the Products page posts %d controls to Checkout, want none`; and the element `#move-disabled-<ladder id>-<tier id>` exists and its text holds `Not available for purchase yet`, `with no Stripe key the tier's card does not read %q`.
- `with a key the product page reads Purchasable`: `GET /operator/products/<product on no ladder>` as the operator on `keyed`; the text of `#product-readiness .alert strong` is `Purchasable`, `with a key the product page reads %q, want "Purchasable"`.
- `with no key the product page says Stripe is not configured`: the same on `keyless`; the verdict is `Incomplete; missing: payment processing`, `with no Stripe key the product page reads %q, want "Incomplete; missing: payment processing"`, and the page holds `Stripe is not configured for this deployment`, `with no Stripe key the product page does not say Stripe is not configured`.
- `with no key Checkout is not served`: `POST /billing/checkout` with the tier's price id as the member on `keyless` answers 404, `with no Stripe key POST /billing/checkout answered %d, want 404`.
- [x] 4.4 Existing tests, with no assertion changed except the two subtests named:
- Every `PriceReadiness` the readiness tests build with an active price gains `Recurring: true`, because the panel now reads the recurrence the fixtures left unset: `internal/server/product_readiness_test.go:98`, `:115`, `:124`, `:290`, `:301`, `:531`, `:539`, `:547`, `:554`, `:650` and `stampedMapping`'s literal (`:365-371`), and `internal/server/operator_product_readiness_render_test.go:151`, `:198`, `:229`, `:242`, `:249`, `:286`.
- `TestProductEditRendersPurchasabilityPanel`'s subtest `fully purchasable but off-ladder: six met badges, zero missing, qualifier shown` (`operator_product_readiness_render_test.go:204-217`) becomes `fully purchasable off-ladder: six met badges, zero missing, shown`: the same view model, `wantVerdict(t, doc, "success", "Purchasable", vm.Verdict)`, `wantAbsent(t, out, "not shown in the member catalog")` and `wantCountOfText(t, doc, "span", "Shown", 1)`, in place of the qualifier's assertions, with its comment saying a product on no ladder that can be bought is shown. The `row structure` subtest's comment (`:178-180`) reads "plus the member-catalog-visibility diagnostic row (Not shown: on no ladder, with no price the member pages could sell) that is neither met nor missing."
- `TestSwitchRefusesWhenAReadFails` (`internal/server/member_switch_offer_db_test.go:358-388`): the failing handlers' config (`:361-363`) gains `StripeMode: m.h.StripeMode,`, so each differs from its control only by the failing read.
- `internal/server/operator_products_list_test.go`, `seedActivePrice` (`:160-171`): the insert names `recurring_interval` and its value `'month'`, `INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active, is_default) VALUES ($1, 'usd', 1000, 'month', true, $2)`. The fixture means a fully wired product, which a one-time default price no longer is (`TestOperatorProductsPageVerdictPerRow`'s "Verdict Purchasable" product). No assertion changes.
- `internal/server/product_readiness_test.go`, `TestBuildProductReadinessVM_InactiveAndListed`, subtest `active unlisted product with a rule reads Ready to grant`: one assertion, `rowState(vm, "Member catalog visibility")` is `n/a`, with failure text `unlisted product's catalog row = %q, want n/a`. After item 4.1 removes the old test, this holds the unlisted product's catalog row. In the same subtest, the loop over `vm.Missing` (`:343-347`) becomes `if slices.Contains(vm.Missing, "Listed") { t.Error("Listed must never appear in Missing for an unlisted product") }`, with the same message, so the function scores 28 (29 at HEAD; 31 with the assertion alone, over `.golangci.yml`'s gocognit limit of 30); `slices` is already imported.
- `internal/server/operator_plan_ladders_golden_db_test.go:65` and `internal/server/operator_products_golden_db_test.go:98` write the label as the literal `"addon"`.
- [x] 4.5 Golden files, edited by deleting the expected lines by hand before the code changes (`docs/testing.md`), so the owning tests pass without `-update` and every diff is a removal; each diff is read before the commit: `internal/server/testdata/operator_products/stripe-configured.json` and `stripe-configured.html` (the notes "Not shown in the member catalog" of the products on no ladder that can be bought, Off Ladder and Shared Set A and B, go), `failed-ListEntitlementSetRulesBySetIDs.json`, `failed-ListEntitlementSetsByIDs.json` and `failed-ListResourceKeys.json` (the same notes go, and in the first and the last Unconfigured Provider's note at `:204` goes too; in the HTML golden each of the three Status cells loses ` <span class="text-muted small">Not shown in the member catalog</span>` within its line, so its diff changes three lines rather than deleting them), and `internal/server/testdata/operator_sync_stripe/readiness_panel_names_a_dead-lettered_sync.golden` (the catalog row's detail line goes). A diff with any other line is a stop. `git grep -n -i 'not shown in the member catalog\|display_category=addon' -- internal` then finds only the render test's two `wantAbsent` calls.
- [x] 4.6 The failure table's rows and their tests: F1 to F12, F14, F16 and F20 are item 4.1's rows; F11 to F13 are also items 4.2 and 4.3; F15, F17, F18 and F19 are named in item 4.1. `PriceRule` itself is item 1.3. Every new test passes `go test -count=1 -run '^(TestPriceRule)$' ./internal/plans` and `go test -count=1 -run '^(TestTheReadinessPanelAgreesWithTheMemberPages|TestMemberCatalogGateFollowsTheMappingEnvironment|TestTheStripeKeyDecidesWhatThePagesSell)$' ./internal/server` with `test/.env` loaded, before `make test`.
- [x] 4.7 Breaks, 36 records. Each is `name`, `file`, `old`, `new`, `pkg`, `run_new` and `want_new`, in the form the tool's list file takes; a break caught by two rows is two records. `old` occurs exactly once in its `file`, as the committed code has it or as the items above write it; `new` compiles; `run_new` for item 4.1's rows is `^TestTheReadinessPanelAgreesWithTheMemberPages$/` followed by the row's name with spaces as underscores, anchored; `want_new` is a text the failing test prints only on failure.
- `one-time price reads met`: `file` `internal/server/product_readiness.go`; `old` ` case !pr.Recurring:`; `new` ` case false:`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_one-time_price$`; `want_new` `want "Incomplete; missing: recurring default price"`.
- `catalog row by the addon label (unlabelled, recurring)`: `file` `internal/server/product_readiness.go`; `old` ` shown := gate.OK() && (shape.LadderCount > 0 || sells)`; `new` ` shown := gate.OK() && (shape.LadderCount > 0 || product.DisplayCategory.String == "addon")`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^an_unlabelled_product_on_no_ladder_with_a_recurring_reachable_price$`; `want_new` `the panel reads hidden, the member pages draw it: true`.
- `catalog row by the addon label (addon, one-time)`: `file` `internal/server/product_readiness.go`; `old` ` shown := gate.OK() && (shape.LadderCount > 0 || sells)`; `new` ` shown := gate.OK() && (shape.LadderCount > 0 || product.DisplayCategory.String == "addon")`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^an_addon-labelled_product_on_no_ladder_with_a_one-time_price$`; `want_new` `the panel reads shown, the member pages draw it: false`.
- `catalog row without the gate`: `file` `internal/server/product_readiness.go`; `old` ` shown := gate.OK() && (shape.LadderCount > 0 || sells)`; `new` ` shown := shape.LadderCount > 0 || sells`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_draft_tier$`; `want_new` `the panel reads shown, the member pages draw it: false`.
- `purchasable without the price rule (one-time)`: `file` `internal/server/product_readiness.go`; `old` ` vm.Purchasable = sells && priced && setPresent && hasRules && providersOK`; `new` ` vm.Purchasable = gate.Published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && providersOK`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_one-time_price$`; `want_new` `the panel calls it Purchasable: true, the member pages sell it: false`.
- `purchasable without the price rule (other environment)`: `file` `internal/server/product_readiness.go`; `old` ` vm.Purchasable = sells && priced && setPresent && hasRules && providersOK`; `new` ` vm.Purchasable = gate.Published && gate.Active && setPresent && hasRules && priced && pr.StripeMapped && providersOK`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_whose_mapping_is_in_the_other_environment$`; `want_new` `the panel calls it Purchasable: true, the member pages sell it: false`.
- `payment processing met when mapped`: `file` `internal/server/product_readiness.go`; `old` ` met = readinessPurchasable(pr, stripeMode)`; `new` ` met = pr.StripeMapped`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_whose_mapping_is_in_the_other_environment$`; `want_new` `want "Incomplete; missing: payment processing"`.
- `offered price always recurring (panel)`: `file` `internal/server/plan_offer.go`; `old` ` Recurring: r.Recurring,`; `new` ` Recurring: true,`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_one-time_price$`; `want_new` `want "Incomplete; missing: recurring default price"`.
- `offered price always recurring (router)`: `file` `internal/server/plan_offer.go`; `old` ` Recurring: r.Recurring,`; `new` ` Recurring: true,`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_a_key_a_one-time_product_is_not_listed_and_reads_Incomplete$`; `want_new` `with a key the Products page lists the one-time product`.
- `loader reads mappings without a key (database test)`: `file` `internal/server/plan_offer.go`; `old` ` if db != nil && stripeConfigured(keyMode) {`; `new` ` if db != nil {`; `pkg` `./internal/server`; `run_new` `^TestMemberCatalogGateFollowsTheMappingEnvironment$/^without_a_key_recorded_in_the_key's_own_environment$`; `want_new` `listed in Extras with no Stripe key`.
- `loader reads mappings without a key (router)`: `file` `internal/server/plan_offer.go`; `old` ` if db != nil && stripeConfigured(keyMode) {`; `new` ` if db != nil {`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_no_key_the_Products_page_offers_neither$`; `want_new` `controls to Checkout, want none`.
- `default price judged under no key mode`: `file` `internal/server/plan_offer.go`; `old` ` facts := priceFacts{Price: offeredPrice(r, l.keyMode)}`; `new` ` facts := priceFacts{Price: offeredPrice(r, "")}`; `pkg` `./internal/server`; `run_new` `^TestMemberCatalogGateFollowsTheMappingEnvironment$/^recorded_in_test_under_a_live_key$`; `want_new` `listed in Extras = true, want false`.
- `keyless facts drop the price`: `file` `internal/server/plan_offer.go`; `old` ` if !r.HasActivePrice {`; `new` ` if !r.HasActivePrice || !r.StripeMapped {`; `pkg` `./internal/server`; `run_new` `^TestMemberCatalogGateFollowsTheMappingEnvironment$/^without_a_key_recorded_in_the_key's_own_environment$`; `want_new` `names no price with no Stripe key`.
- `stripe always configured`: `file` `internal/server/product_readiness.go`; `old` ` return keyMode != ""`; `new` ` return true`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_no_key_Checkout_is_not_served$`; `want_new` `want 404`.
- `stripe never configured (Products page)`: `file` `internal/server/product_readiness.go`; `old` ` return keyMode != ""`; `new` ` return false`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_a_key_the_Products_page_offers_both$`; `want_new` `with a key the Products page offers no Checkout of`.
- `stripe never configured (product page)`: `file` `internal/server/product_readiness.go`; `old` ` return keyMode != ""`; `new` ` return false`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_a_key_the_product_page_reads_Purchasable$`; `want_new` `with a key the product page reads`.
- `checkout route always registered`: `file` `internal/server/server.go`; `old` ` if stripeConfigured(cfg.StripeMode) {`; `new` ` if true {`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_no_key_Checkout_is_not_served$`; `want_new` `want 404`.
- `panel always configured`: `file` `internal/server/server.go`; `old` ` StripeConfigured: stripeConfigured(cfg.StripeMode),`; `new` ` StripeConfigured: true,`; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_no_key_the_product_page_says_Stripe_is_not_configured$`; `want_new` `want "Incomplete; missing: payment processing"`.
- `return reconciles without a key`: `file` `internal/server/server.go`; `old` ` if checkoutStatus == "success" && stripeConfigured(cfg.StripeMode) && session != nil {`; `new` ` if checkoutStatus == "success" && session != nil {`; `pkg` `./internal/server`; `run_new` `^TestMemberPagesThroughTheRouter$/^no_return_reconciles_without_a_Stripe_key$`; `want_new` `a return without a Stripe key read the subscriptions`.
- `catalog row counted as missing`: `file` `internal/server/product_readiness.go`; `old` ` vm.verdict(setPresent, hasRules, gate)`; `new` ` vm.Missing = missingFrom(vm.Rows)` and, on the next line, ` vm.verdict(setPresent, hasRules, gate)`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^an_addon-labelled_product_on_no_ladder_with_a_one-time_price$`; `want_new` `the catalog row is in Missing`.
- `qualifier restored`: `file` `internal/server/product_readiness.go`; `old` ` vm.Verdict = "Purchasable"`; `new` ` vm.Verdict = "Purchasable; not shown"`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_recurring_reachable_price$`; `want_new` `carries a catalog qualifier`.
- `one-time state unmet`: `file` `internal/server/product_readiness.go`; `old` ` priceRow.State = stateOneTime`; `new` ` priceRow.State = stateUnmet`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_one-time_price$`; `want_new` `want one_time`.
- `one-time detail reworded`: `file` `internal/server/product_readiness.go`; `old` ` priceRow.Detail = "Make a recurring price the default on the Prices view; a one-time price is not sold."`; `new` ` priceRow.Detail = "Make a recurring price the default."`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_one-time_price$`; `want_new` `want one_time "Make a recurring price`.
- `one-time price unnamed`: `file` `internal/server/product_readiness.go`; `old` ` priceRow.MissingName = "recurring default price"`; `new` ` priceRow.MissingName = ""`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^a_tier_with_a_one-time_price$`; `want_new` `want incomplete "Missing: recurring default price"`.
- `price rule sells one-time`: `file` `internal/plans/offer.go`; `old` ` return OneTime`; `new` ` return NoReason`; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^a_one-time_price$`; `want_new` `want "the price is one-time"`.
- `not-default offered disabled`: `file` `internal/plans/offer.go`; `old` ` case NotListed, NotDefault:`; `new` ` case NotListed:`; `pkg` `./internal/plans`; `run_new` `^TestOffLadder$/^another_price$`; `want_new` `OffLadder = {Kind:checkout`.
- `switch around the rule`: `file` `internal/plans/offer.go`; `old` ` m = priceMove(Switch, a.Target)`; `new` ` m = Move{Kind: Switch, Enabled: true}`; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^a_one-time_price$`; `want_new` `Offer's switch answers`.
- `checkout around the rule`: `file` `internal/plans/offer.go`; `old` ` m = priceMove(Checkout, a.Target)`; `new` ` m = Move{Kind: Checkout, Enabled: true}`; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^a_one-time_price$`; `want_new` `Offer's Checkout answers`.
- `off-ladder around the rule`: `file` `internal/plans/offer.go`; `old` ` return priceMove(Checkout, t)`; `new` ` return Move{Kind: Checkout, Enabled: true}`; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^a_one-time_price$`; `want_new` `OffLadder answers`.
- `price rule order: gate before price`: `file` `internal/plans/offer.go`; `old` ` case !t.Listed:\n return NotListed\n case p.ID == "":\n return NoPrice`; `new` the two cases swapped; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^outside_the_member_gate_with_no_price$`; `want_new` `PriceRule = "the tier has no price", want "the product is outside the member gate"`.
- `price rule order: default before recurrence`: `file` `internal/plans/offer.go`; `old` ` case !p.Default:\n return NotDefault\n case !p.Recurring:\n return OneTime`; `new` the two cases swapped; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^another_price_that_is_one-time$`; `want_new` `PriceRule = "the price is one-time", want "the price is not the product's default price"`.
- `price rule order: recurrence before reach`: `file` `internal/plans/offer.go`; `old` ` case !p.Recurring:\n return OneTime\n case !p.Purchasable:\n return Unpurchasable`; `new` the two cases swapped; `pkg` `./internal/plans`; `run_new` `^TestPriceRule$/^a_one-time_price_out_of_reach$`; `want_new` `PriceRule = "the price has no Stripe price the key can reach", want "the price is one-time"`.
- `a purchasable product carries a note`: `file` `internal/server/product_readiness.go`; `old` ` return "purchasable", ""`; `new` ` return "purchasable", "Not shown in the member catalog"`; `pkg` `./internal/server`; `run_new` `^TestTheReadinessPanelAgreesWithTheMemberPages$/^an_unlabelled_product_on_no_ladder_with_a_recurring_reachable_price$`; `want_new` `want purchasable and no note`.
- `one-time price reads met (router)`: the same `old` and `new` as the first record; `pkg` `./internal/server`; `run_new` `^TestTheStripeKeyDecidesWhatThePagesSell$/^with_a_key_a_one-time_product_is_not_listed_and_reads_Incomplete$`; `want_new` `with a key the one-time product's page reads`. With the break the listing check passes and the verdict reads `"Incomplete; missing: "`.
- `unlisted catalog row hidden`: `file` `internal/server/product_readiness.go`; `old` ` case !isPublic:`; `new` ` case false:`; `pkg` `./internal/server`; `run_new` `^TestBuildProductReadinessVM_InactiveAndListed$/^active_unlisted_product_with_a_rule_reads_Ready_to_grant$`; `want_new` `catalog row = "hidden", want n/a`.
- `catalog row carries a detail`: `file` `internal/server/product_readiness.go`; `old` `\t\trow.State = stateHidden` (once, in `catalogRow`); `new` that line followed by `\t\trow.Detail = "Not shown."`; `pkg` `./internal/server`; `run_new` `^TestSyncProductToStripeAnswers$/^readiness_panel_names_a_dead-lettered_sync$`; `want_new` `small.text-muted: Not shown.`
Not in the list, with the reasons for the commit message: the removal of `CatalogQualifier`, which leaves nothing to break. The catalog row's dropped details are held by the record above for the one test that reads them.
## 5. Documents
- [x] 5.1 `docs/models/product-catalog.md`:
- invariant 8 (`:52`): "has a row in `stripe.price_mappings` whose Stripe id the current key can reach" reads "has a row in `stripe.price_mappings` whose Stripe id the current key can reach" is unchanged; "; without that, a plan tier's buy control renders disabled and a product on no ladder is not listed." is followed by the new sentence "A deployment with no key reads no mapping, so it offers no price." before "Checkout"; and "(`plans.Offer`, `plans.OffLadder`)" reads "(`plans.Offer`, `plans.OffLadder`, both through `plans.PriceRule`)".
- `:70`: "the storefront's 'add-ons' page is this grouping at work, not a structural category" reads "Deliberately not a behavioral axis (invariant 2): a member sees it as a badge on a product's row, and it decides nothing."
- `:72`: "and an active default price with its Stripe mapping, and is computed in `product_readiness.go` for the operator readiness panel." reads "and an active, recurring default price whose Stripe mapping the current key can reach, and is computed in `product_readiness.go` for the operator readiness panel, which asks the price rule (`plans.PriceRule`) the member pages and Checkout ask."
- `:74`: "A product whose only price is one-time, or not yet mapped, is real but has no catalog path." reads "A product on no ladder whose default price is one-time, or not yet mapped, is real but has no catalog path." The sentence from "The readiness panel reports this as a diagnostic" to the end of the paragraph reads: "The readiness panel reports findability as a diagnostic \"member catalog visibility\" row from the same facts: shown for a published, active, listed tier, and for a listed product on no ladder exactly when the price rule passes. A Purchasable product is therefore always shown, and the row never changes the verdict."
- [x] 5.2 `docs/stripe.md`, "Making a product purchasable":
- item 2 (`:229`): "mark it Active and Public" reads "mark it Active and Listed", the word the panel and the form use, and the item's bold heading "**Public & active**" reads "**Listed & active**".
- item 3 (`:230`) reads: "**Where members find it** — put it on a plan ladder to sell it as a plan (a single-tier ladder is the idiom for a standalone plan); a product on no ladder is listed in the Products page's products section once its default price can be bought. The panel's Member catalog visibility row says whether members see it; the display category only labels a row."
- `:236`, the paragraph "If Stripe is **not configured** for the deployment…", gains after its sentence: "The member pages then offer nothing for sale, whatever prices an earlier key synced."
- [x] 5.3 `git grep -n -E "display_category = 'addon'|display_category=addon|display_category='addon'|product type \(\`addon\`|invisible-limbo|assigned a product type" -- docs internal ':!*_test.go'` finds nothing; before the change it finds `docs/models/product-catalog.md:74`, `docs/plan-management.md:53`, `docs/stripe.md:230`, `internal/server/product_readiness.go:529`, `:543` and `:546`, and `internal/server/testdata/operator_sync_stripe/readiness_panel_names_a_dead-lettered_sync.golden:4` (`':!*_test.go'` does not exclude testdata), which items 5.1, 5.2, 5.4, 3.4 and 4.5 rewrite. The two tests that set or name the label to show it decides nothing (`internal/entitlements/conferral_proof_test.go:503`, `internal/server/member_extras_test.go:113`) keep it.
- [x] 5.4 `docs/plan-management.md`, "Making a plan purchasable" (ride-along; it states the "product type" precondition this change removes):
- `:43`: "**Published, public, active.**" reads "**Published, listed, active.**", and "mark the product Active and Public" reads "mark the product Active and Listed".
- `:44`: "Add an active price on the product's Prices view." reads "Add an active, recurring price on the product's Prices view and make it the default; a one-time default price reads \"Incomplete; missing: recurring default price\"."
- `:53` reads: "The panel's **Member catalog visibility** row says whether members see the product: a published, active, listed tier is shown in its ladders' plan sections, and a listed product on no ladder is shown in the Products page's products section exactly when its default price is recurring and has a Stripe price the current key can reach. The display category only labels a row; it decides nothing." The blockquote at `:55` and the blank line before it are deleted.
## 6. Landing
- [x] 6.1 `openspec validate panel-and-member-pages-sell-by-one-rule --strict` passes, and with the test stack up, `make test` passes (it runs `make lint` first, whose `filler-copy` rule reads the new detail of item 3.1).
- [x] 6.2 `make screens` changes the six product detail screens (`operator-product-detail`, `-open`, `-invalid`, at desktop and mobile width) by one line each: the Member catalog visibility row's detail under Not applicable (Copy C9). No other screen changes: every product in the demo snapshot is unlisted, and the snapshot holds no price mapping, so the Products page offers nothing either way. A changed screen is read and explained in the commit message, and the new baseline waits for the maintainer's screens-accept. States the screens cannot reach, which the tests of section 4 carry: the operator product page's panel for each listed product shape of the failure table, and the member Products page and its plan sections with no Stripe key over mappings an earlier key made.
- [x] 6.3 The commit message lists the break list of item 4.7 with its results, the breaks left out and their reasons, and the ride-alongs of the proposal.
- [ ] 6.4 After the push, post on #178 and on #282 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close both. Draft, the same text on each:
> The operator's product page and the member Products page now decide by one rule whether a member can find and buy a product. The product page's readiness panel asks the same price rule Checkout and the member pages ask: the product is published, active and listed, and its default price is recurring and has a Stripe price the current key can reach. Its "Member catalog visibility" row reads Shown when the member pages show the product: a plan tier in its plan sections, or a product on no ladder in the products list once its default price can be bought. The display category no longer decides anything there. A product whose default price is one-time now reads "Incomplete; missing: recurring default price" instead of Purchasable, and because a product that can be bought is always shown, the "not shown in the member catalog" note is gone from the panel and the products list.
>
> A deployment with no Stripe key now offers nothing for sale on the member pages, whatever the console's records of prices synced under an earlier key say: products on no ladder are not listed, and plan tiers show their buy control disabled, as the operator's product page already said. The member pages, the operator's product page and the Checkout route take "Stripe is configured" from one place, the key's mode the console reads at start.
>
> Commit: …
- [ ] 6.5 The issue drafts of the design's Open questions (Q1, Q3) and the cleanup question Q2 are shown to the maintainer; none is opened without his go.
- [ ] 6.6 The archive waits for the maintainer's go; the deltas carry the spec changes, and no Purpose needs an edit a delta cannot make.