Restore the rules in the model cards and the Stripe guide

This commit is contained in:
2026-10-07 06:28:59 -05:00
parent 93b8f8f515
commit f66a80b26a
4 changed files with 7 additions and 5 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ One gloss first, because four rules below depend on it. A **commitment** is a mi
9. **[db-code]** A scheduled change fires exactly once: firing claims the row by flipping scheduled → applied in a conditional update, so a concurrent sweeper and webhook cannot both fire it; the loser sees no row and stops.
10. **[app]** An immediate plan switch disarms any pending cancellation: the switch supersedes the intent row and clears Stripe's cancel-at-period-end flag in the same Stripe call that changes the price.
11. **[app]** Mid-term downgrades and cancellations dispatch on the commitment policy: `allow` proceeds now; `block` defers the change to the commitment boundary as a scheduled change; `fee` means a termination fee would be owed, and since fee collection is unbuilt, the console refuses the change rather than charging. Upgrades are never gated.
12. **[app]** Checkout requires a published, active, public product (the shared member gate — see the product-catalog card) and an active, Stripe-mapped price, and refuses when the organization already holds an active subscription on a ladder that the product is a tier of — so neither a stale tab nor a crafted request can buy an unpublished product or create a second concurrent subscription.
12. **[app]** Checkout sells only what the offer decision (`internal/plans`) offers: the default, recurring price of a published, active, public product, with a Stripe mapping the key can reach, and, for a plan tier, only where every ladder the product is a tier of offers Checkout, which excludes a ladder a subscription already pays for and a tier at or below a rung a grant holds. A read it cannot make refuses the request. So neither a stale tab nor a crafted request can buy an unpublished product, a price other than the default, a one-time price, a tier below a grant, or a second concurrent subscription on a ladder.
13. **[db]** Every payment references an invoice — there are no invoice-less payments.
14. **[app]** The webhook receiver acknowledges only what it recorded: it answers 2xx once the event row is inserted or identified as a duplicate of an already-recorded delivery, and answers 500 when the insert fails, so Stripe redelivers instead of the event being silently lost. The Discourse receiver follows the same contract.
+2
View File
@@ -41,6 +41,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Default restoration | Resolves the org type's default ladder to its rank-0 product and confers it — plus the floor guard that restores only when the pool has no live occupancy on any ladder | `internal/entitlements/reapply_defaults.go` |
| Stripe-driven convergence | Translates a subscription's remote state into confer / end / suspend-resume calls (see the Payments and Billing card) | `internal/fulfillment/reconcile.go` |
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
| Operator ladder management | Ladder and tier CRUD, reorder and removal (with a preview of what happens to pools sitting on affected rank-0 tiers; removing the last tier of a ladder an org type defaults to is refused, because signups land on that ladder's entry tier), and the structural validation page (orphaned products, malformed rank sequences, pools with multiple active positions) | `internal/server/operator_plan_ladders.go` |
| Operator enrollment | Issue, revoke, and extend grants; revoke ends positions then restores the guarded default | `internal/server/operator_enrollment.go` |
| Org-type default changes | Changing a type's default ladder, with a per-pool preview offering two dispositions: **grandfather** (the pool keeps its old position, re-decreed as a legacy grant) or **migrate** (end the old position, then floor-guarded restore onto the new default) | `internal/server/operator_org_types.go` |
@@ -69,6 +70,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
16. **[db]** A ladder's display name is unique, compared without regard to case (`uq_plan_ladders_name_ci`). The name is what every catalog and composite surface leads with, so two ladders sharing one would be indistinguishable to an operator. Migration `00012` deduped colliding names before adding the index, and folded the existing ladder keys to the key grammar (lower-cased, separator runs to `_`, leading non-letters removed), clearing any value the grammar still rejected and any that collided after folding.
17. **[app]** Audit reasons that name a ladder (`plan-ladder tier reorder (…)`, `tier removal (… from …)`) are composed at the moment of the action and stored on the transition rows, so they are write-time snapshots: they record the name as it read then, and a later rename does not rewrite them. The ladder's UUID stands in when the row cannot be loaded.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up, through Checkout. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
## Dimensions
+2 -2
View File
@@ -48,7 +48,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 and has a row in `stripe.price_mappings`; without the mapping, the buy control renders disabled instead of submitting to checkout.
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`).
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,6 +67,6 @@ These axes are independent, and holding them separate is most of understanding t
- **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.
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 plus price activity and the Stripe mapping before contacting Stripe. The one leg reported only on the operator panel is the linked entitlement set. **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 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. **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."
+2 -2
View File
@@ -216,8 +216,8 @@ A member can only buy a product once **every** purchasability precondition is me
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.
4. **Active price** — add a price on the product's Prices view.
5. **Stripe-mapped price** — the active price is mapped to a live Stripe price.
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).