Suspend a test subscription the way the reconcile does

First section of #148. The test kit's Suspend suspended the provisions a
subscription confers and left the subscription itself active, a pair the
app never writes: the reconcile sets the subscription's Stripe status
(past_due, unpaid or paused) and suspends its provisions together.
Suspend now takes that status, writes it to the subscription row with
the row's own period, cancellation and end fields, and then suspends
and settles as before; any other status fails the test.

The three callers pass past_due. The organization page's golden for a
suspended subscription changes accordingly: its Billing card read
Active and now reads Past due. The other two callers read no
subscription status and keep their results.

This change also adds openspec/changes/suspended-plans-stay-held, the
plan for #148: a suspended plan stays the member's on every surface,
confers nothing, refuses a second Checkout and a switch, and offers a
way to pay the unpaid invoice.

Full suite on reset databases: 4517 pass, 15 skip, none failed. make
lint passes. Breaks, each caught:
- Suspend without the status write
- Suspend writes an empty period end
This commit is contained in:
2026-10-24 02:17:28 -04:00
parent e9258a246a
commit 979e38b985
19 changed files with 1239 additions and 10 deletions
@@ -213,7 +213,7 @@ func newEnrollmentFixture(t *testing.T) *enrollmentFixture {
o = f.org("suspended-subscription", plain)
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, small2)
f.norm.Name("subscription", "suspended", sub.ID)
w.Suspend(sub)
w.Suspend(sub, "past_due")
add("suspended-subscription", o.ID, "")
lite2 := f.product("Lite2")
@@ -239,7 +239,7 @@ func TestDeletePlanLadderTier_RefusesATierHeldOnlyBySuspendedPositions(t *testin
basic, plus := w.Product("Basic"), w.Product("Plus")
w.Price(plus, 200)
ladder := w.Ladder("Main", basic, plus)
w.Suspend(w.Subscribe(w.Org("Holder", w.OrgType("susp"), operator), world.SubOpts{}, plus))
w.Suspend(w.Subscribe(w.Org("Holder", w.OrgType("susp"), operator), world.SubOpts{}, plus), "past_due")
var active, suspended int
for status, n := range map[string]*int{"active": &active, "suspended": &suspended} {
+1 -1
View File
@@ -868,7 +868,7 @@ var planActScenarios = []struct {
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
f.w.Price(plus, 200)
main := f.ladder("Main", basic, plus)
f.w.Suspend(f.w.Subscribe(f.org("Holder", f.orgType("T", &main)), world.SubOpts{}, plus))
f.w.Suspend(f.w.Subscribe(f.org("Holder", f.orgType("T", &main)), world.SubOpts{}, plus), "past_due")
return f.act(actDeleteTier, nil, actOnTier(main, plus)...)
}},
{"delete-org-types-unread", func(f *actFixture) actGolden {
@@ -51,7 +51,7 @@
<div class="text-muted small">Subscriptions</div>
<div>
<span class="badge text-bg-success">Active</span>
<span class="badge text-bg-danger">Past due</span>
<a href="/operator/billing/subscriptions?q=suspended-subscription&#43;">1 subscription</a>
<small class="text-muted">· renews Jan 15, 2099</small>
@@ -5,7 +5,7 @@
"BillingAccountID": "<uuid>",
"AccountStatus": "active",
"SubscriptionCount": 1,
"SubscriptionState": "active",
"SubscriptionState": "past_due",
"SubscriptionNote": "renews Jan 15, 2099",
"HasInvoice": false,
"LatestInvoiceID": "",
+22 -5
View File
@@ -343,13 +343,30 @@ func (w *World) defaultPriceID(p Product) string {
return ""
}
// Suspend suspends the provisions sub confers and settles the pool, as the
// reconcile does for a past-due subscription. The subscription row keeps its
// status, a state the reconcile does not leave, so Suspend writes a state the
// app does not produce.
func (w *World) Suspend(sub Subscription) {
// Suspend writes the pair the subscription reconcile writes for a suspending
// status: the subscription's status, then the suspension of the provisions
// sub confers and the settlement of the pool. status is past_due, unpaid or
// paused, and the row keeps its period, cancellation, cancelled and ended
// fields.
func (w *World) Suspend(sub Subscription, status string) {
w.TB.Helper()
switch status {
case "past_due", "unpaid", "paused":
default:
w.TB.Fatalf("suspend subscription %s: status %q is not past_due, unpaid or paused", sub.ID, status)
}
w.inMaterializingTx("suspend subscription "+sub.ID, func(tx *sql.Tx) error {
row, err := billing.New(tx).GetSubscriptionByID(w.Ctx, sub.ID)
if err != nil {
return err
}
if _, err := billing.New(tx).UpdateSubscriptionStatus(w.Ctx, billing.UpdateSubscriptionStatusParams{
SubscriptionID: row.SubscriptionID, Status: status,
CurrentPeriodStart: row.CurrentPeriodStart, CurrentPeriodEnd: row.CurrentPeriodEnd,
CancelAtPeriodEnd: row.CancelAtPeriodEnd, CanceledAt: row.CanceledAt, EndedAt: row.EndedAt,
}); err != nil {
return err
}
q := entitlements.New(tx)
if _, err := q.SyncSourceStatus(w.Ctx, entitlements.SyncSourceStatusParams{
Status: "suspended", SubscriptionID: uuid.NullUUID{UUID: uuid.MustParse(sub.ID), Valid: true},
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package world_test
import (
"database/sql"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
const suspendKey = "world_suspend_sites"
// TestSuspendWritesTheSubscriptionStatus: Suspend writes the status the
// subscription reconcile writes for each suspending status, keeps the row's
// other fields, suspends the provisions and settles the pool at 0.
func TestSuspendWritesTheSubscriptionStatus(t *testing.T) {
d := dbtest.Open(t)
if _, err := d.ExecContext(t.Context(), `INSERT INTO core.resource_keys (resource_key, display_name, unit, kind)
VALUES ($1, 'Suspend Sites', 'site', 'numeric') ON CONFLICT DO NOTHING`, suspendKey); err != nil {
t.Fatalf("register %s: %v", suspendKey, err)
}
for _, status := range []string{"past_due", "unpaid", "paused"} {
t.Run(status, func(t *testing.T) { checkSuspend(t, d, status) })
}
}
func checkSuspend(t *testing.T, d *sql.DB, status string) {
t.Helper()
w := world.New(t, d)
p := w.Product("Suspendable")
w.Rule(p, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: suspendKey, Valid: true},
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
})
w.Price(p, 1200)
o := w.Org("Suspended", w.OrgType("suspend"), w.Person("Ann"))
periodEnd := time.Date(2099, time.January, 15, 12, 0, 0, 0, time.UTC)
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, p)
w.CancelAtPeriodEnd(sub)
w.Suspend(sub, status)
q := dbtest.On(t, d)
if got := q.String(`SELECT status FROM core.subscriptions WHERE subscription_id = $1`, sub.ID); got != status {
t.Errorf("subscription status = %q, want %q", got, status)
}
if got := q.Int(`SELECT count(*) FROM core.subscriptions WHERE subscription_id = $1 AND current_period_end = $2`, sub.ID, periodEnd); got != 1 {
t.Errorf("Suspend changed the subscription's current_period_end from %s", periodEnd)
}
if got := q.Int(`SELECT count(*) FROM core.subscriptions WHERE subscription_id = $1 AND cancel_at_period_end`, sub.ID); got != 1 {
t.Errorf("Suspend changed the subscription's cancel_at_period_end from true")
}
if got := q.Strings(`SELECT status FROM core.pool_provisions WHERE subscription_id = $1`, sub.ID); len(got) != 1 || got[0] != "suspended" {
t.Errorf("provision statuses = %v, want [suspended]", got)
}
if got := q.Int(`SELECT resource_limit FROM core.numeric_entitlements WHERE pool_id = $1 AND resource_key = $2`, o.PoolID, suspendKey); got != 0 {
t.Errorf("the pool's limit = %d, want 0", got)
}
}
@@ -0,0 +1,289 @@
## Context
A Stripe subscription has three statuses in which it still exists, keeps its period and is still being collected, but has not been paid for the current period: `past_due` (a renewal payment failed and Stripe retries it), `unpaid` (the retries ran out and the account's settings keep the subscription instead of cancelling it) and `paused` (a trial ended without a payment method). `classifyStatus` (`internal/fulfillment/reconcile.go:777-788`) sends all three to `reconcileSuspended` (`reconcile.go:271-285`), which calls `core.sync_source_status('suspended')` on the subscription's provisions and rematerializes the pool. The status trigger `core.sync_pool_provision_ladders()` copies the provision's status to its ladder rows, so the position's rows read `suspended`. The subscription row itself carries Stripe's status, which `upsertSubscription` writes before the branch runs.
What a suspended position means is already settled below the surfaces:
- It delivers nothing. Materialization reads only `pool_provisions.status = 'active'` (`GetActivePoolProvisionsByPoolID`, `internal/entitlements/queries/pool_provisions.sql:5-8`, read by `computePoolFold`, `internal/entitlements/materialize.go:117`); a numeric key no active rule feeds is written to limit 0 with its row kept, and a boolean key lapses. The integrations read the materialized rows, never positions.
- It still occupies its rung. `core.confer` supersedes a suspended incumbent as it supersedes an active one (`internal/db/migrations/00023_grant_resumption.sql:578-591`), the floor counts it as live (`docs/models/plan-ladders-transitions.md`, invariant 11; `internal/entitlements/reapply_defaults.go`), and every plan-act population query reads `l.status IN ('active', 'suspended')`.
- It recovers in place. When the subscription returns to `active`, the reconcile resumes the provision and the trigger resumes its rows, unless something superseded it meanwhile (#212).
The surfaces read the position through one query, `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql:187-240`), whose `l.status = 'active'` (line 239) is the only place "held" is decided. It has seven readers:
| # | Reader | What it decides |
|---|---|---|
| R1 | `positionLoader.positions` (`internal/server/plan_offer.go:59-72`): the Products page, Checkout (`internal/server/billing.go:274`), the switch and its preview (`plan_offer.go:294-330`) | the rung held on each ladder, which `onLadder` (`plan_offer.go:101-121`) turns into the decision's `Current` and `Subscribed` |
| R2 | `currentTierName` (`internal/server/member_plan_moves.go:170-177`), through R1 | the tier named in the switch confirmation and the decline |
| R3 | `ladderRows` (`internal/server/member_entitlements.go:281-317`) | the entitlements panel's plan lines, or "No active plan" |
| R4 | `orgHasEntitlement` (`internal/server/server.go:310-329`) | whether the dashboard shows its empty state |
| R5 | `resolveLadderSubscription` (`internal/fulfillment/plan_change.go:702-780`) under `SwitchPlan`, `KeepCurrentPlan`, `PreviewSwitch`, `CancelSubscription` and `HasActiveSubscriptionOnLadder` | the subscription and Stripe item a plan act changes, or `ErrNoActivePaidSubscription` |
| R6 | the organization page's Pools panel (`internal/server/operator_enrollment.go:1347-1369`, `poolDeliveries` at 1392-1415) | one delivery per provision, or "No plan is delivering into this pool" |
| R7 | `subscriptionHeldLadders` (`operator_enrollment.go:1792-1806`) | which products the Issue grant form marks as superseding a subscription |
R6 and R7 are wrong today in the committed golden `internal/server/testdata/org_enrollment/suspended-subscription.html`: an organization whose subscription is suspended reads "No plan is delivering into this pool", and the Issue grant form offers its product with no warning.
## Goals / Non-Goals
**Goals:**
- A suspended position reads as the organization's current plan, with its state, on the member's Products page and entitlements panel and on the operator's organization page.
- No Checkout on the suspended plan's ladder, from the page or the endpoint.
- No switch while suspended; Keep plan and the period-end cancellation work on a plan whose payment is due, with no act that abandons the unpaid invoice or undoes a downgrade Stripe already applied; a paused plan is shown and not changed.
- A member can reach Stripe's page for the unpaid invoice.
- Nothing a suspended position does delivers an entitlement.
**Non-Goals:**
- A grace period for `past_due` (the code suspends at once; Out of scope in the proposal).
- Changing the card on file, or a customer portal.
- What a recovered subscription does to whatever superseded it while it was suspended (#212), and the suspension's lock (#193).
- The rest of #150: the invoice number as a link, payments, cards and the subscription on the invoice pages.
## Decisions
### D1. A suspended position is held, and delivers nothing
`ListLadderPositionsByPool` reads `l.status IN ('active', 'suspended')`, the predicate every plan-act population query already uses, and selects `l.status AS position_status` and `s.status AS subscription_status` (the subscription is joined LEFT, so the second is nullable). The rows are ordered `(l.status = 'active') DESC, l.activated_at DESC, l.plan_ladder_id`, the active-first form the cancel prediction's live read uses (`internal/entitlements/queries/grants.sql:667`), so `entitlements.PositionsByLadder`, which keeps the first row of each ladder, keeps an active row over a suspended one. The exclusion constraint covers only active rows (`internal/db/migrations/00001_init.sql:817-821`), so the schema does not forbid one active and one suspended row on a ladder; `core.confer` keeps them from coexisting, and the order keeps the readers right if they ever do.
Every reader then holds a suspended position as held: R1 to R7 show it, and R5 acts on it. Materialization, the reduction policy, the rematerialization population and the grants' delivery states keep reading only active provisions; none of them reads ladder rows.
Reason: the position is the organization's in every rule below the surfaces (the floor, supersession, recovery), so the surfaces were the only place it was not, and the result is the defect: a member offered a second subscription for a plan Stripe is still collecting. Reading it as live in the one query and leaving materialization alone keeps the line between "held" and "delivering" where the model draws it, on the provision's status.
### D2. The ladder row's status decides; the subscription's status chooses the words
A position is suspended when its ladder row's status is `suspended`. The subscription's status chooses what the member reads: `paused` reads as paused, and every other status of a suspended position, `past_due`, `unpaid`, a subscription row not projected yet, or any status Stripe adds later, reads as payment due.
Reason: the ladder row is the one place "held" is decided, and what the subscription status says can lag or lead it by one reconcile; deciding on one and naming with the other means the page never shows a suspended plan as active or an active one as suspended. Payment due is the safe default for the words: it is the state a console-made subscription reaches (the console creates no trial, so it cannot reach `paused`), and it is the state that has an invoice to pay. The words are "payment due" and not "payment failed" because `past_due` also covers a renewal that Stripe holds "awaiting additional user actions" (3-D Secure approval, `subscription.go:260`): no payment has failed then, the invoice is still open, and Pay invoice still works.
### D3. The offer decision refuses the switch, with its own reason
`plans.Position` gains `Suspended bool` and `Paused bool`, set by `onLadder` from the row's `position_status` and, for `Paused`, from a `subscription_status` of `paused` (`Paused` implies `Suspended`). `plans.Offer` gains a rule, numbered 14, before the switch: when the rules before it would route the target to the switch (rule 15, today's rule 14) and the position is suspended, the answer is none, with the new reason `Suspended` ("the position is suspended"). The rules that answer the cancellation, rule 7 (the held rung) and rule 13 (the unpriced rank-0 tier), stay above it, so the held rung's and the free rung's Downgrade and the current card's Cancel plan are offered as today. No Checkout needs a rule: a suspended position is current and subscribed, and rule 12 offers Checkout only when the organization holds nothing on the ladder or holds a rung no subscription pays for, so no target on a suspended position's ladder reaches Checkout. A paused position is offered no move at all: rule 14 also turns the Cancel answers of rules 7 and 13 for a paused position into none, and its switch answer is none, both with the new reason `Paused` ("the position is paused"), so the held rung's and the free rung's Downgrade are not drawn on a paused plan.
The answer is kind None, not a disabled switch. A disabled control renders "Not available for purchase yet" (`moveDisabledReason`, `internal/server/member_plans.go:456`), which is false here, and the current card already states why nothing can change.
Reason: the switch would interleave invoices. An upgrade with `always_invoice` issues a second invoice while the failed renewal is still open, and a downgrade schedule bills the next renewal at the lower price with the current invoice still unpaid; how Stripe sets the status when the newer invoice is paid and the older one is not is not documented. Refusing both directions needs no Stripe path that is unverified, and it is reversible later. The cancellation buys nothing and charges nothing, so it stays on a plan whose payment is due. A paused plan has no invoice, and what a cancellation does to a paused subscription is unverified: `CancelSubscription` records the row at `current_period_end` (`plan_change.go:599-611`), a trial's end can be in the past, and the sweeper cancels a due cancellation at once (`sweep.go:162-178`). The console cannot make a paused subscription (it creates no trial), so offering nothing on one needs no Stripe path that is unverified.
### D4. Checkout is refused on the suspended plan's ladder, and allowed on another
Checkout decides through `plans.Offer` and `plans.Across` with R1's positions (`billing.go:274-290`), so once the position reads as held, Checkout of any tier of its ladder, or of a product one of whose ladders is that ladder, is refused with the existing 422 and the plan sentence ("That plan is no longer available. Refresh the page and choose a current plan.", `refuseCheckout`, `billing.go:189-194`) before any Stripe call. A tier of another ladder, on which the organization holds nothing, keeps its Checkout.
Reason: the ladders are independent, and nothing in Stripe or in the model forbids a second subscription on another ladder; refusing every purchase while any invoice is unpaid would be a store policy the console does not have. The sentence is the one a tab rendered before the subscription existed already gets ("Checkout refuses a ladder a subscription already pays for"); a refresh shows the plan and its state.
### D5. The switch and its preview are refused before any Stripe call
`switchOffer` (`plan_offer.go:294-330`) answers a target the member gate refuses, and a price id that is malformed or unknown, with the unavailable plan before anything below (`plan_offer.go:268-276`, `295`; `member_plan_moves.go:284-287`). For a target that clears the gate it maps the decision's refusal in this order, first match wins: a target that is not a tier of the named ladder is an unavailable plan; then a suspended position answers `fulfillment.ErrSubscriptionPaused` when the subscription is paused, and `fulfillment.ErrSubscriptionSuspended` otherwise; then a ladder no subscription pays for; then the current tier; then the unavailable plan. The second answer is chosen from the position, as the third already is, so every switch posted on a suspended ladder, whatever its target that clears the member gate, gets the sentence that names why. `renderPlansAfterMove` (`member_plan_moves.go:327-366`) renders `ErrSubscriptionSuspended` as copy row 7 and `ErrSubscriptionPaused` as copy row 8. The preview runs `switchOfferFor` first (`member_plan_moves.go:79`), so it gives the same answer and reads nothing from Stripe.
`resolveLadderSubscription` carries the position's status in `ladderSubscription` (`plan_change.go:116-131`), and `SwitchPlan` returns `ErrSubscriptionPaused` for a paused subscription and `ErrSubscriptionSuspended` for any other suspended one, directly after resolving it (after `plan_change.go:162-165`), before it reads the target price and before the commitment branch, so neither a Stripe call nor a deferred scheduled change is made.
Reason: the endpoint and fulfillment each refuse, as the existing gates do (the unreachable mapping is refused in both), so a caller that reaches `SwitchPlan` without the server's gate cannot switch a suspended subscription either.
### D6. Keep plan and the period-end cancellation proceed; the body names the end and says the invoice stays due
`KeepCurrentPlan` and `CancelSubscription` (`plan_change.go:424-459`, `554-618`) resolve a suspended position's subscription as they resolve an active one's and run as for an active one, with three refusals, each before it changes anything:
- **Keep plan refuses a downgrade that Stripe has already applied.** The console's schedule enters its second phase at the renewal, and the failed renewal invoice is the invoice for the lower price, so a payment-due plan with a scheduled downgrade has almost always been downgraded already. `KeepCurrentPlan` reads the subscription and applies the test `findSpentSchedule` applies (`reconcile.go:891-911`): when the subscription no longer carries the price that the schedule's `scheduled_from_price_id` names, it releases nothing, supersedes nothing, and returns `ErrScheduledMoveApplied`, which `renderPlansAfterMove` renders as copy row 17. The refusal applies to a suspended subscription only: on an active one the reconcile has already converged a landed downgrade. Without it, Keep plan would release the schedule, so the subscription would stay on the lower price, supersede the `plan_switch` row that Stripe has applied, and reconcile into the suspended branch; the card would read the higher plan again, Stripe would bill the lower one, and on recovery `reconcileItems` would move the plan with no applied row in the ledger.
- **A posted `timing=immediate` is refused on a suspended subscription.** `CancelSubscription` returns `ErrImmediateCancelSuspended` before it releases a schedule or makes a Stripe call, and `renderPlansAfterMove` renders it as copy row 18. Stripe's immediate cancellation "stops automatic collection of all finalized invoices for the customer" (`subscription/client.go:114`), which abandons the unpaid invoice and reaches the customer's other subscriptions; the page has never offered it, and an endpoint that accepts what the page hides would let a crafted request do it.
- **A paused subscription is refused all three acts.** `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` return `ErrSubscriptionPaused` for a position whose subscription is `paused`, before any Stripe call, rendered as copy row 8.
Otherwise Keep releases the console's schedule and clears `cancel_at_period_end` when something is scheduled, and the cancellation sets `cancel_at_period_end` and records the scheduled cancellation. The reconcile after each takes the suspended branch, which writes the subscription row (its cancellation flag included) and keeps the provisions suspended.
The cancel confirmation's body on a ladder whose plan's payment is due names the end, because "You keep {current} until {date}" says access continues and the entitlements are suspended. The body is copy row 19 when nothing follows the plan ("Pro ends on Jan 15, 2099."), row 20 when the prediction puts one lower plan after it ("Pro ends on Jan 15, 2099, then you move to Plus."), row 21 for every other prediction (a kept plan, a leave, several plans: the existing clause words after "then"), and row 22 when the page has no prediction or date. Copy row 6, "The unpaid invoice stays due.", follows each. Every control that carries the body on that ladder (Cancel plan, the held rung's and the free rung's Downgrade) shows the same body. A paused plan offers no cancellation, so it has no body; a later change that makes one reachable names the end in the same words.
The page offers no immediate cancellation, as today, and the endpoint now refuses a posted one on a suspended subscription (above).
Reason: a member must be able to stop a plan they will not pay for; refusing the cancellation until the invoice is paid, which Stripe's documentation names as an option, leaves a member who wants to leave no way out. The period-end cancellation lets Stripe keep collecting the period it already invoiced, until the cancellation takes effect: then Stripe stops automatic collection of the customer's finalized invoices, so the unpaid invoice stays due and is paid through Pay invoice (S9). An immediate cancellation stops that collection now, which is a decision about the money owed, so it is refused. Keep plan refuses a landed downgrade because the schedule it would release no longer holds anything to take back.
### D7. The current card of a suspended plan
The card keeps the "Current plan" badge and the primary border. Its price line replaces only the renewal line: "{price}, payment due" or "{price}, paused" (copy rows 1 and 2) where it would read "{price}, renews {date}". A scheduled cancellation's "{price}, ends {date}" stays as it is. A scheduled downgrade's "{price}, moves to {tier} on {date}" stays only while its date is after now: `onLadder` drops the scheduled downgrade of a suspended position whose `DowngradeAt` is not after the handler's now, because Stripe has already applied it (D6), so the card draws no "moves to" line and no Keep plan for it and reads "{price}, payment due"; Cancel plan stays. Under the price line, one fact line (copy row 3 or 4). The controls of a plan whose payment is due, top to bottom: Pay invoice (D8), then Keep plan when a move is scheduled, then Cancel plan where the card offers it today (`setCurrent`, `member_plans.go:430-453`). A paused plan's card has no control: no Pay invoice, no Keep plan, no Cancel plan, and the held rung's and the free rung's Downgrade are not offered (D3, D9). The ladder heading's cancellation badge keeps its label and drops its tooltip on a suspended plan (proposal, quick fixes).
The view model carries the state as one string on `TierViewModel`, `Suspension` (`""`, `payment_due` or `paused`), set on the current tier, with `setCurrent` leaving `CanKeep` and `CanCancel` false for `paused`; the price line's date stays in `BillingRenewsOn`, because the cancel body reads it (`setCancelBodies`, `member_plans.go:521-536`).
Reason: a scheduled cancellation or downgrade is still a fact the member needs, and the Keep plan control that takes it back would otherwise drop something the card does not show; the fact line carries the suspension in every case. A downgrade Stripe has applied is no longer a move to take back, so the card does not offer to. One string field rather than flags keeps the JSON goldens' change to one key per tier.
### D8. Pay invoice: Stripe's hosted page, read when it is opened
Two member GET routes answer a redirect, registered beside `GET /billing` behind the same sign-in requirement, in a new `internal/server/member_hosted_invoice.go` on `MemberInvoicesHandler`, which gains the entitlements querier:
- **By invoice**, `GET /billing/invoices/{invoiceID}/hosted`, built to #150's description of the member link. It applies the gate the invoice detail applies (`member_invoices.go:293-312`, an unknown invoice or one whose billing account is not the organization's answers 404), through one function both handlers call. It reads the invoice's mapping; with no Stripe querier, no mapping, or a mapping whose recorded environment the boot key's mode cannot reach (`MappingReachable`, `internal/integrations/stripe/store/mapping_environment.go:91-99`), it answers 303 to `/billing` and calls nothing. Otherwise it reads the invoice from Stripe (`GET /v1/invoices/{id}`) and answers 303 to its `hosted_invoice_url` when Stripe returns one, whatever the invoice's status, because a paid invoice's hosted page offers the invoice and the receipt (#150); when Stripe returns none (an invoice not finalized) or the read fails, it answers 303 to `/billing`.
- **By plan**, `GET /billing/plans/{ladderID}/unpaid-invoice`, for the current card, which knows the plan and not the invoice, and which must work before the failed invoice is projected. It reads the organization's position on the ladder through `ListLadderPositionsByPool` on the default pool and picks its row through `entitlements.PositionsByLadder`, so a ladder holding an active and a suspended row yields the active one; a malformed ladder id, no position, a position no subscription backs, or a subscription whose billing account is not the organization's answers 404, the gate's answer. With no Stripe querier, no mapping row for the subscription, or a mapping whose recorded environment the key's mode cannot reach (`MappingReachable`), it answers 303 to `/billing` and calls nothing, as the first route does. Otherwise it reads the subscription with its latest invoice expanded (`GET /v1/subscriptions/{id}`, `expand[]=latest_invoice`), and answers 303 to the latest invoice's `hosted_invoice_url` when that invoice is `open`, and 303 to `/billing` otherwise (paid, void, none, or a failed read).
Both answers carry `Cache-Control: no-store` (RFC 9111, section 5.2.2.5). The middleware already sets it on every response (`internal/middleware/security.go:21`, applied at `server.go:783`); the handlers set it themselves as well, because the header is what keeps the bearer link out of caches and must not depend on the stack around the handler, and their tests call the handler directly through `authtest`, so that removing the handler's line is caught. Neither route logs the URL: a failed read logs the organization, the invoice or subscription id and the error. The URL is never stored. 303 is the redirect for a GET whose answer is another resource (RFC 9110, section 15.4.4).
The card's control and the invoice detail's control are links that open a new tab (`target="_blank" rel="noopener"`, and `hx-boost="false"` as the shell's other external links carry), as "Approve payment" does (`internal/embeds/templates/partials/ui_form.html:256`). The invoice detail shows Pay invoice for an invoice whose status is `open` and whose mapping the key reaches, so there is no link when the invoice's recorded environment differs from the key's (#150, #134). The card shows Pay invoice on every plan whose payment is due and lets the route judge the environment at the click: the page reads no subscription mapping today, and a member's own current plan recorded in another environment is a deployment that changed keys (#134).
Pay invoice leads to a page that charges a card, so it counts as an enabled control that costs money for the Products page's billing line (`plansOfferPaidMove`, `member_plans.go:543-552`): it makes the line render and references it with `aria-describedby`, as "Approve payment" does.
Reason: the hosted page is the one way to pay that needs nothing configured in each deployment's Stripe account, and the console already sends members there for an upgrade that needs approval (`member_plan_moves.go:160`). A customer portal would need a portal configuration in every deployment's account, with cancellation and switching disabled, or it would become a second door to plan moves that bypasses the offer decision. The URL is read on demand because it is a bearer link anyone holding it can open and pay, it changes on every read, and it expires (#150); a stored copy would need withholding from the reader role and would go stale. Building the by-invoice route to #150's description now means #150's number link uses it without a second route. The by-plan route redirects only to an open invoice because its one purpose is to pay; a recovered plan's member lands on the Billing page, which lists the invoice paid.
### D9. A paused plan shows its state and offers nothing
A paused subscription generates no invoice, and resuming it is an API call the console does not make, so a paused plan's card has no Pay invoice, no Keep plan and no Cancel plan, its cancel body does not exist, and the held rung's and the free rung's Downgrade are not offered (D3). Fulfillment refuses a switch, Keep and Cancel on a paused subscription before any Stripe call (D6). The switch refusal reads copy row 8, which is now exact: a paused plan cannot be changed in any way the console offers.
Reason: no control should lead to a page with nothing on it, and no act should reach a Stripe path nobody has verified (what a cancellation does to a paused subscription, whose trial's period end can be in the past). The state is reachable only by a subscription made outside the console, in Stripe's Dashboard on the same customer, so showing the state and refusing every act is the least the console can do truthfully and the most it can do without an unverified call.
### D10. The entitlements panel and the dashboard follow the wider read
`ladderRows` lists one line per live position, keyed through `PositionsByLadder` so a ladder never yields two lines, and a suspended position's line carries a badge after the tier name, "Payment due" (danger) or "Paused" (secondary), from `PoolTierViewModel.State`. The panel offers no control, as "Tier display is a read-only extension" requires. `orgHasEntitlement` counts a suspended position as a plan the organization holds.
Reason: "No active plan" is false while the organization holds a plan Stripe is collecting, and so is the dashboard's sentence "once your organization holds a plan". The panel's numeric rows already read 0 for a suspended plan's keys, so the badge is what explains them.
### D11. The operator's organization page
The Pools panel lists a suspended delivery with its rung pills and the existing Suspended badge (`internal/server/anatomy.go:194`) after the product's name; the badge belongs to the delivery, because the status is the provision's and the trigger copies it to every rung. `subscriptionHeldLadders` marks a ladder a suspended subscription holds, so the Issue grant form marks its products and shows `issueGrantSupersedeWarning` (`internal/server/operator_enrollment_forms.go:132`), whose text ("the subscription keeps billing until it is changed in the billing view") holds for a subscription that keeps retrying.
The Billing card (quick fix): `subscriptionStatePrecedence` (`operator_enrollment.go:353-359`) becomes `unpaid`, `past_due`, `paused`, `canceling`, `active`, `trialing`, and `subscriptionPeriodNote` (`operator_enrollment.go:415-424`) returns no note for `past_due`, `unpaid` or `paused`. A suspending status with `cancel_at_period_end` stays its status, as today (`subscriptionState`). The subscription's `unpaid` badge is drawn through a badge key of its own, `subscription_unpaid` (label "Unpaid", danger tone), so it is as loud as `past_due`'s, which it now outranks; the invoice and balance "Unpaid" badges keep the `unpaid` key and the warning tone. `TestStatusBadgeMapIsComplete` covers the new key.
The ladder page's active counts, the topology overview's and the ladder delete guard's are unchanged: they are labelled active, tier removal already counts live holders, and the foreign key refuses deleting a ladder a suspended position stands on.
Reason: the operator is the one who resolves a suspended subscription, and a page that says nothing is delivering hides the subscription the grant would supersede. The precedence ranks what the operator must act on first, and an unpaid subscription is further along that road than a past-due one.
### D12. The test kit writes the pair the reconcile writes
`world.Suspend(sub, status)` takes `past_due`, `unpaid` or `paused` and fails the test on any other status. In one transaction it writes the subscription row's status through `billing.UpdateSubscriptionStatus`, the query the reconcile's upsert writes it with, keeping the row's period and cancellation fields, then suspends the provisions and materializes the pool as it does today.
Reason: today's `Suspend` leaves the subscription `active`, "a state the app does not produce" (`internal/testkit/world/plans.go:346-349`), which is why the committed organization golden reads Active on its Billing card. The words D2 chooses come from the subscription's status, so a fixture that cannot set it cannot test them.
### D13. One reader per section
The query is widened once, with every reader filtering the suspended rows out through `entitlements.ExcludeSuspended(rows)`, so that section changes nothing and every golden stays as it is; each later section deletes the filter at the readers it releases, and the last one deletes the function. The filter drops a row whose `PositionStatus` is `suspended` and keeps every other row, an empty status included, as a defensive default: a row built by hand carries no status. No test feeds a filtered reader such a row today (`internal/server/member_products_billing_test.go:76` calls `resolveCurrentPlanBilling`, and `internal/entitlements/positions_test.go:9` calls `PositionsByLadder`), so the rule rests on the default and not on a test. Before the query changes, the member plans and member entitlements goldens gain the suspended states with today's answers, so each release shows its change as a golden diff (`docs/testing.md`, "Characterize before a refactor").
Fulfillment is released before the Products page, so the page never draws a Cancel plan or Keep plan whose endpoint still answers "There's no active subscription to change on this plan.", and the Pay invoice routes and controls come after the page, so the card never links to a route that does not exist yet.
Reason: seven readers changing at once would make one golden diff of seven behaviours; one reader per section lets each be reviewed and tested on its own, and the widened section with no diff proves the filter is the only thing between the query and the behaviour.
### D14. What does not change
- The reconcile's suspended branch mirrors no item and converges no scheduled change, as today. Conferring while suspended would deliver an unpaid plan; the recovery converges both. A period-end downgrade that lands during `past_due` keeps its row scheduled until the recovery, and the sweeper logs "sweep: period-end downgrade still scheduled a day after it was due" for it each run after a day (`internal/fulfillment/sweep.go:146-155`). Accepted: the row closes on recovery or with the subscription's end. The same branch is why a payment-due card can name a plan the subscription has already left: a downgrade that landed is not converged until recovery, so the card keeps the plan the position holds (D7 drops its "moves to" line and Keep plan, D6 refuses Keep plan), and the price line reads the subscription as the card does today.
- The sweeper fires a term-boundary cancellation or switch whatever the subscription's status. A term-boundary switch needs a commitment, which no console path writes. Note only.
- Every active-only read listed in Context besides `ListLadderPositionsByPool`: materialization, the reduction policy, the rematerialization population, the grants' delivery states and the operator counts.
- The add-ons page: products on no ladder stack by design and the page reads no holdings, so a subscriber of an add-on whose subscription is suspended is offered it again and sees no state, and on recovery both subscriptions deliver and bill, as today (F31; candidate issue in the proposal).
- The code's classification of `past_due` as suspending. The design documents describe a grace period; changing the code is the candidate issue the proposal names, and `design/` is not edited.
### D15. Docs
Each doc moves with the section whose code it describes (`CONTRIBUTING.md`, "Docs move with the code"). `docs/models/plan-ladders-transitions.md`'s "Member position" row says that the query returns every live position with the position's and the subscription's status, active rows first, and, from the section that releases the page, that the member surfaces hold a suspended position as the current plan while it delivers nothing. `docs/models/payments-billing.md`'s "Status classification" says what the member sees of a suspended plan. `docs/stripe.md`'s "Plan changes" gains a bullet for a plan whose payment is due (it stays on its card with its state; no switch; Keep plan and the period-end cancellation work; the unpaid invoice stays due) and, with the routes, a sentence on Pay invoice (Stripe's hosted page, read when the member opens it, never stored; no new key permission, since the console already reads subscriptions and invoices).
### D16. The changes in flight beside this one
`serve-before-dependencies` adds a requirement to `plan-enrollment-administration` ("A grant with an end date needs Temporal before it is written") and modifies none of the requirements this change modifies; `runtime-database-user` touches no capability this change touches. The changes archive in any order without one dropping the other's text.
## Failure table
| # | Path | Today | After |
|---|---|---|---|
| F1 | Products page, the suspended plan's ladder | Not held; every priced tier offers Subscribe through Checkout | Held as current with its state (D7); no Checkout and no switch; Cancel plan, Keep plan and the cancellation Downgrades as for an active plan; Pay invoice. A paused plan: its state only, no control |
| F2 | Products page, a product that is a tier of the suspended ladder and of another | Checkout when the other ladder offers it | `plans.Across` answers no Checkout (another ladder forbids it), so no control |
| F3 | Checkout posted for a tier of the suspended ladder | A second Stripe subscription; its conferral supersedes the suspended provision; the first one's recovery confers again over the second (#212); both bill | 422 with the plan sentence, no Stripe call (D4) |
| F4 | Checkout posted for a tier of another ladder | Allowed | Allowed |
| F5 | Switch posted, either direction | "There's no active subscription to change on this plan." | "Pay the unpaid invoice before changing this plan." (paused: copy row 8), no Stripe call (D5) |
| F6 | Switch preview | Same as F5 | Same as F5, no Stripe call |
| F7 | `SwitchPlan` called without the server's gate | `ErrNoActivePaidSubscription` | `ErrSubscriptionSuspended` before any read of the target or Stripe call |
| F8 | Keep plan | `ErrNoActivePaidSubscription` | Releases the schedule or clears the flag, supersedes the rows, reconciles (suspended branch); refuses a downgrade Stripe already applied (F30); a paused plan is refused with copy row 8 before any Stripe call (F32) |
| F9 | Cancel plan (period end) | `ErrNoActivePaidSubscription` | Sets `cancel_at_period_end`, records the scheduled cancellation, reconciles; the body names the end and says the invoice stays due (D6). A paused plan is refused with copy row 8 before any Stripe call (F32) |
| F10 | A posted `timing=immediate` | `ErrNoActivePaidSubscription` | Refused, no Stripe call, copy row 18 (D6). Not offered by the page |
| F11 | Entitlements | Suspended provisions contribute nothing | Unchanged, proved by a test |
| F12 | Entitlements panel | "No active plan", or the other ladders' lines only | The plan's line with a "Payment due" or "Paused" badge |
| F13 | Dashboard, an organization whose only plan grants on/off capabilities | The empty state, which says the organization holds no plan | Not empty |
| F14 | The member pays on Stripe's page | No way to pay | Pay invoice on the card and on an open invoice's detail (D8) |
| F15 | Pay invoice for another organization's invoice or plan | n/a | 404, as the invoice detail answers |
| F16 | Pay invoice, mapping recorded in the other environment or stale | n/a | 303 to `/billing`, no Stripe call; the invoice detail shows no control |
| F17 | Pay invoice, Stripe read fails or returns no URL | n/a | 303 to `/billing`; the log line carries no URL |
| F18 | Pay invoice after the plan recovered | n/a | By plan: the latest invoice is paid, 303 to `/billing`. By invoice: 303 to the paid invoice's hosted page |
| F19 | The hosted URL in the console's logs, database or a cache | n/a | Never logged or stored; `Cache-Control: no-store` on the redirect |
| F20 | An `unpaid` subscription with several open invoices | n/a | The card's link pays the latest when it is open; each invoice's detail links to its own |
| F21 | Operator Pools panel | "No plan is delivering into this pool" | The delivery with its rungs and a Suspended badge |
| F22 | Operator Issue grant advisory | No warning | The warning, as for an active subscription. A grant issued under it over a suspended subscription is ended when the subscription recovers (#212 covers an active subscription at any reconcile too) |
| F23 | Operator Billing card | `unpaid` and `paused` rank below `active`; a single suspended subscription reads "renews {date}" | Worst first; no note for a suspending state |
| F24 | The cancellation badge's tooltip on a suspended plan | "access continues until this date" | No tooltip |
| F25 | Floor (`ReapplyDefaultsIfVacant`) | Counts a suspended position as occupied | Unchanged |
| F26 | Reconcile, suspended branch, and a downgrade due during suspension | Suspends only; the row stays scheduled and the sweeper warns daily | Unchanged (D14) |
| F27 | A ladder holding an active and a suspended row at once | Not producible through `core.confer`; the schema does not forbid it | The active row is the one every reader keeps (D1) |
| F28 | Tests that hand-build position rows without a status | n/a | Kept by `ExcludeSuspended` while it exists, as a defensive default; no test feeds a filtered reader such a row today (D13) |
| F29 | A second Checkout while the first subscription is `incomplete` | Allowed: no position exists yet | Unchanged; note only (proposal). The same holds for an earlier Checkout Session that has not expired: it can still complete a second subscription on a suspended plan's ladder, because the console sets no `expires_at` (`billing.go:137-153`); this holds for active plans too |
| F30 | A payment-due plan whose scheduled downgrade Stripe already applied: Keep plan, and the page | Keep: `ErrNoActivePaidSubscription`. Page: not held | Keep: refused with `ErrScheduledMoveApplied`, copy row 17, nothing released, nothing superseded. Page: no "moves to" line and no Keep plan; Cancel plan stays (D6, D7) |
| F31 | An add-on whose subscription is suspended | The add-ons page reads no holdings: it offers Add again and shows no state | Unchanged (D14); on recovery both subscriptions deliver and bill; candidate issue (proposal) |
| F32 | A paused plan: Keep plan, Cancel plan, a held rung's or free rung's Downgrade | `ErrNoActivePaidSubscription` | Not drawn; a posted request is refused with copy row 8 before any Stripe call (D6, D9) |
## Copy
Every sentence a person reads that this change adds or changes. The UI copy rules (`AGENTS.md`, "UI copy"; `openspec/specs/ui-vocabulary/spec.md`, "Copy earns its place") apply: no filler phrase, no em dash, facts only, and member copy speaks of the plan, never of a ladder, tier, rung, position, pool or provision. Angle brackets mark a value filled in at run time. Every entry waits for the maintainer's approval.
### Member
| # | Where | Text |
|---|---|---|
| 1 | Current card's price line, payment due (any status of a suspended position but `paused`), in place of `<price>/<interval>, renews <date>` | `<price>/<interval>, payment due`, for example `$12.00/month, payment due` |
| 2 | Current card's price line, paused, in the same place | `<price>/<interval>, paused` |
| 3 | Current card's fact line under the price line, payment due | `Entitlements are suspended until the invoice is paid.` |
| 4 | Current card's fact line, paused | `Entitlements are suspended.` |
| 5 | Current card's control, payment due only, first of the card's controls; a link that opens a new tab | `Pay invoice` |
| 6 | Cancel confirmation, plan whose payment is due: the sentence after the body (rows 19 to 22), on every control that carries the body | ` The unpaid invoice stays due.`; for example `Pro ends on Jan 15, 2099. The unpaid invoice stays due.` |
| 7 | Switch or preview refused, payment due (the form-level error slot the other refusals use) | `Pay the unpaid invoice before changing this plan.` |
| 8 | Switch, preview, Keep plan or Cancel plan refused, paused (added: copy row 7 names an invoice a paused plan does not have; exact, because a paused plan is refused every act) | `A paused plan cannot be changed.` |
| 9 | Entitlements panel, the badge after `<plan line>: you are on <tier>`, payment due | `Payment due` (danger tone) |
| 10 | Entitlements panel, the same badge, paused | `Paused` (secondary tone) |
| 11 | Invoice detail of an open invoice whose mapping the key reaches; a link that opens a new tab | `Pay invoice` |
| 12 | The ladder heading's cancellation badge on a suspended plan (added) | label unchanged, `Cancels on <date>`; the tooltip `The plan does not renew; access continues until this date.` is not rendered |
| 17 | Keep plan refused, the scheduled downgrade already took effect (the form-level error slot) | `That plan change has already taken effect.` |
| 18 | Cancel plan refused, a posted immediate cancellation on a plan whose payment is due (the same slot) | `A plan with an unpaid invoice cannot end immediately.` |
| 19 | Cancel confirmation body, plan whose payment is due, nothing follows the plan | `<plan> ends on <date>.`, for example `Pro ends on Jan 15, 2099.` |
| 20 | The same body, one lower plan follows | `<plan> ends on <date>, then you move to <lower plan>.`, for example `Pro ends on Jan 15, 2099, then you move to Plus.` |
| 21 | The same body, any other prediction (a kept plan, a leave, several plans), the clauses being the ones the existing body uses | `<plan> ends on <date>; then <clauses>.`, for example `Pro ends on Jan 15, 2099; then keep Plus on Hosting and leave Support.` |
| 22 | The same body, no prediction or no date | `<plan> ends at the end of this billing period.` |
### Operator
| # | Where | Text |
|---|---|---|
| 13 | Pools panel, a suspended delivery, after the product's name | the existing badge `Suspended` (danger tone) |
| 14 | Billing card, a single subscription in `past_due`, `unpaid` or `paused` | no note, in place of `renews <date>` |
| 23 | Billing card, the Subscriptions row's badge when the worst state is `unpaid` | the existing label `Unpaid` in the danger tone, through its own badge key, in place of the warning tone |
Reused unchanged: Checkout's refusal (`That plan is no longer available. Refresh the page and choose a current plan.`), the Issue grant warning (`issueGrantSupersedeWarning`), the cancellation's success banner (`Your cancellation is scheduled.`), the current card's `Current plan` badge, and the existing state badges `Past due` and `Paused` on the Billing card.
### Log lines
| # | Where, level | Text |
|---|---|---|
| 15 | Either Pay invoice route, a Stripe read that fails (Warn) | `member hosted invoice: stripe read failed`, attributes `org_id`, `invoice_id` or `subscription_id`, `error`; never the URL |
| 16 | `switchOffer`, a switch refused on a suspended position (Info, the existing refusal log) | `switch refused: the plan is suspended`, attributes `ladder_id`, `subscription_status` |
## Tests
- **The decision** (`internal/plans/offer_test.go`, no database): a suspended position on Pro answers none with `Suspended` for a tier above and a paid tier below; Cancel for the held rung below and for the unpriced rank-0 tier when it follows; none with `Current` for Pro; the same targets with `Suspended` false answer as today; and a paused position answers none with `Paused` for a tier above, for the held rung below and for the free rung that follows. The table's runner moves out of its inline closure into a named function that prints `Offer(<row name>) = <got>, want <want>`, so each rule-14 break reports a text it can match.
- **The read and the proof** (`internal/entitlements`): the widened query returns a suspended position with both statuses and orders active rows first; `ExcludeSuspended` keeps every row but a suspended one; a suspended position yields a 0 numeric limit with its row kept and no granted boolean, and materializing again after the widened read returns its row changes neither.
- **Fulfillment** (`internal/fulfillment`, over a subscription the reconcile suspended from the Stripe mock's `past_due` subscription): `SwitchPlan` refuses both directions with `ErrSubscriptionSuspended` and the mock records no call after the reconcile; Cancel sends `cancel_at_period_end=true` and records the row; Keep sends `cancel_at_period_end=false` and supersedes it; `SubscriptionBacksLadder` answers true. A subscription with a downgrade scheduled that the mock's subscription already carries the lower price for: Keep returns `ErrScheduledMoveApplied`, the mock records no write, and the `plan_switch` row stays `scheduled`. Cancel with `CancelImmediate` returns `ErrImmediateCancelSuspended` and the mock records no call. A subscription the mock holds as `paused`: `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` return `ErrSubscriptionPaused` and the mock records no call.
- **The Products page** (`internal/server`): member plans golden states for `past_due`, `unpaid`, `paused` (no control on its card), `past_due` with a scheduled cancellation, `past_due` over a held rung below, and `past_due` with a downgrade that has landed (no "moves to" line, no Keep plan, Cancel plan kept); a database test that the suspended ladder is held, draws no enabled Checkout or switch, and offers Cancel plan; Checkout 422 with no Stripe call on that ladder and a pass on another; switch and preview refused with copy row 7 or 8 and no Stripe call; a paused plan's Keep and Cancel refused with copy row 8, a Keep on a landed downgrade with copy row 17 and a posted immediate cancellation with copy row 18, each with no Stripe write.
- **Pay invoice** (`internal/server`, the Stripe mock serving `GET /v1/invoices/{id}` and `GET /v1/subscriptions/{id}`): 404 for another organization's invoice and plan; 303 to the hosted URL for an open invoice and, by invoice, for a paid one; 303 to `/billing` by plan for a paid latest invoice, for a subscription with no mapping row or an unreachable mapping with no Stripe call, and for a failed read; the by-plan read carries the expand of `latest_invoice` (the test reads it in the form the mock records the call); `Cache-Control: no-store`; no captured log record carries the URL; the card's and the detail's controls; the route tables.
- **The entitlements panel and the dashboard**: member entitlements golden states for `past_due` and `paused`; a dashboard row for an organization whose only plan grants a boolean and is suspended.
- **The operator page**: the organization golden's suspended state (the delivery with its badge, the warning, the Billing card); `TestSummarizeSubscriptions` rows for the precedence and the note; a test that the Subscriptions row's `unpaid` badge key is `subscription_unpaid`, drawn in the danger tone, while the invoice's and the balance's stay warning.
- **The test kit**: `Suspend` writes the subscription's status with the suspension, and refuses a status that does not suspend.
Every page or refusal test compares the whole sentence with its entry in the Copy table.
## What is unverified about Stripe, and how the landing check settles it
The local sources are the stripe-go v81.4.0 doc comments (API version `2025-02-24.acacia`). What they do not state:
| # | Question | How the landing check settles it |
|---|---|---|
| S1 | A failed renewal on a Checkout-made subscription moves it to `past_due` at once (the account's default retry settings) | Attach `pm_card_chargeCustomerFail` to the sandbox customer, set it as the subscription's `default_payment_method`, and start a new period now (`stripe subscriptions update <id> --billing-cycle-anchor now --proration-behavior none --stripe-version 2025-02-24.acacia`); the subscription reads `past_due` and the console's page shows the state |
| S2 | `cancel_at_period_end=true` is accepted on a `past_due` subscription | Cancel plan on the suspended page; Stripe accepts, the card reads "ends <date>" with the fact line |
| S3 | Clearing `cancel_at_period_end` is accepted on a `past_due` subscription | Keep plan after S2; the card reads "payment due" again |
| S4 | Paying the open invoice on the hosted page returns the subscription to `active` | Pay invoice, pay with `4242 4242 4242 4242`; the subscription reads `active`, the reconcile resumes the provision, the card renews and the entitlements return |
| S5 | Paying on the hosted page saves the new card as the subscription's default | Read the subscription's `default_payment_method` after S4; the result decides whether the card-change candidate issue is needed |
| S6 | The hosted page of an `unpaid` subscription's invoice takes payment, and paying the latest invoice reactivates it | Not settled: `unpaid` needs the retries to run out. The by-plan route falls back to `/billing`, whose per-invoice links pay each open invoice |
| S7 | `hosted_invoice_url` after the URL's expiry (30 days after the due date, at most 120 days) | Not settled by a sandbox run. A route that receives a URL answers with it; an expired page is Stripe's to explain |
| S8 | Releasing a schedule attached to a `past_due` subscription, and what Keep plan does to one whose downgrade has landed | After S1, create a schedule with the CLI (`stripe subscription_schedules create --from-subscription <id>`, with the console's `scheduled_from_price_id` metadata set), press Keep plan, and record what Stripe answers. A schedule that has not landed releases and the card reads "payment due" again; for a landed one the console refuses before any release (D6), and the run confirms the refusal's test (that the subscription no longer carries the schedule's `scheduled_from_price_id` price) against a real answer |
| S9 | When a period-end cancellation of a `past_due` subscription takes effect, Stripe stops automatic collection of all the customer's finalized invoices, including another subscription's open invoice (`subscription/client.go:114`) | Not settled by the plain sandbox run, which cannot reach the period end. Settled only with a test-clock customer: two subscriptions, one `past_due` and cancelled at the period end, the clock advanced past the period end, and the other's open invoice read for `auto_advance` and its next payment attempt. Unsettled, the design relies on the documentation and the candidate issue stands |
| S10 | The hosted page of an `uncollectible` invoice still takes payment; the console keeps such an invoice `open` (`webhook_invoice.go:24-27`), so its detail offers Pay invoice | Not settled by the plain sandbox run. Settled only with a test-clock customer whose account settings mark an invoice uncollectible when the retries run out, then opening Pay invoice on that invoice. Unsettled, a member may reach a page that does not take payment |
A subscription schedule made from a `past_due` subscription, and `pending_if_incomplete` on one, are not exercised: D3 refuses the switch before either. Keep plan and Cancel plan do release a schedule, which S8 exercises. Keeping or cancelling a `paused` subscription is not exercised: fulfillment refuses both (D6, D9).
## Risks / Trade-offs
- **A member cannot move to a cheaper plan until they pay.** A member who cannot afford the current plan can cancel at the period end, or pay and then downgrade. Allowing a downgrade at renewal while past due needs a schedule from a past-due subscription, which is unverified.
- **The hosted page may not keep the new card.** If S5 shows it does not, a member who pays with a new card can fail again at the next renewal; the candidate issue for changing the card covers it.
- **A payment-due card can name a plan the subscription has already left.** A downgrade that landed is not converged until recovery (D14), so the card names the plan the position holds and reads the price line from the subscription. The card draws no "moves to" line and no Keep plan for it, and Keep plan refuses it, so the member cannot act on the difference; paying converges the plan.
- **A period-end cancellation of a payment-due plan can stop collection of the customer's other open invoices.** Stripe stops automatic collection when the cancellation takes effect (S9). The member pays through Pay invoice, which does not depend on automatic collection.
- **The card's Pay invoice can lead to the Billing page.** When the plan's latest invoice is not the open one, or the read fails, the new tab shows `/billing`, where the open invoice's own link pays it.
- **A golden diff in every JSON golden of the member plans, the member entitlements and the organization page.** Each new view-model field adds a key to every row of those files; the sections name the files, and the HTML goldens change only for the states that show the new state.
@@ -0,0 +1,82 @@
## Why
When a subscription's renewal payment fails, Stripe moves the subscription to `past_due`, and later to `unpaid` or `canceled` once its retries run out; a subscription can also be `paused`. For any of the three suspending statuses the subscription reconcile suspends the provisions the subscription delivers (`classifyStatus`, `internal/fulfillment/reconcile.go`), the pool's entitlements drop to nothing, and the status trigger copies the suspension to the position's ladder rows. The position is still the organization's: the floor and the conferral functions already count a suspended position as occupying its rung (`docs/models/plan-ladders-transitions.md`, invariant 11).
The member and operator surfaces do not. They read positions through one query, `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql`), which keeps only `active` rows, so a suspended plan disappears from every surface that reads it:
- The Products page draws the ladder as not held and offers Subscribe through Checkout on every priced tier.
- Checkout accepts a second subscription on that ladder while the first is still being retried. The second subscription's conferral supersedes the suspended provision; when the first subscription later recovers, its reconcile confers the tier again and ends the second one's provision (#212). Both subscriptions keep billing.
- The plan's card offers neither Cancel plan nor Keep plan, and a posted cancellation, keep or switch answers "There's no active subscription to change on this plan."
- The entitlements panel reads "No active plan", and the dashboard of an organization whose only plan grants on/off capabilities shows its empty state.
- Nothing tells the member that a payment failed, and the console offers no way to pay: the Billing page lists the failed renewal invoice as Open with no pay link.
- On the organization page an operator sees "No plan is delivering into this pool", the Issue grant form does not warn that a grant would supersede the subscription that keeps retrying, and the Billing card ranks an `unpaid` or `paused` subscription below an `active` one.
The issue names `GetActiveAttachmentsByPool` as the read and `HasActiveSubscriptionOnLadder` as Checkout's guard. Both names are stale: the read is now `ListLadderPositionsByPool`, and Checkout decides through the offer decision (`plans.Offer`, `plans.Across`).
## What Changes
- **A suspended position is the organization's plan on every surface that shows plans.** `ListLadderPositionsByPool` returns active and suspended rows, the position's status and the subscription's status, active rows first. The ladder row's status decides whether a position is suspended; the subscription's status chooses the words. Entitlements are untouched: materialization reads only active provisions, and a test proves that a suspended position delivers nothing.
- **The offer decision refuses the switch on a suspended position.** `plans.Position` gains `Suspended`, and `plans.Offer` gains a rule with its own reason: a suspended position is offered no switch, in either direction. The cancellation moves (the current card's Cancel plan, the held rung's and the free rung's Downgrade) stay, except on a paused plan, which is offered no move at all. Because the position now reads as held and subscribed, the decision already offers no Checkout on its ladder, so the Products page shows no Subscribe or Upgrade there and Checkout refuses a second subscription on it. Checkout on another ladder stays allowed.
- **The switch and its preview are refused with their own sentence.** "Pay the unpaid invoice before changing this plan." (for a paused plan, "A paused plan cannot be changed."), before any Stripe call, at the endpoint and again in `fulfillment.SwitchPlan`.
- **Keep plan and Cancel plan work on a plan whose payment is due.** Fulfillment resolves the subscription behind a suspended position as it does behind an active one. The cancel confirmation names the end ("Pro ends on Jan 15, 2099.") and then says "The unpaid invoice stays due." The page offers no immediate cancellation, and the endpoint refuses a posted one on a suspended subscription before any Stripe call, because Stripe would then stop collecting the customer's open invoices. Keep plan refuses a scheduled downgrade that Stripe has already applied, and the page draws no "moves to" line and no Keep plan for one. A paused plan shows its state only: no Keep plan, no Cancel plan, and fulfillment refuses a switch, Keep and Cancel on a paused subscription before any Stripe call.
- **The current card states the plan's state.** "$12.00/month, payment due" (or "paused") in place of the renewal line, and one fact line: "Entitlements are suspended until the invoice is paid." (paused: "Entitlements are suspended.").
- **Pay invoice.** A console route reads Stripe's hosted invoice page when the member opens it and redirects there; the URL is never stored or logged. The current card of a plan whose payment is due links to it through the plan (`GET /billing/plans/{ladderID}/unpaid-invoice`, the subscription's latest invoice when it is open, `/billing` otherwise), and an open invoice's detail links to it through the invoice (`GET /billing/invoices/{invoiceID}/hosted`). The by-invoice route is built to the description of the member link in #150, so #150's number link can use it unchanged; the rest of #150 (the number as a link, payments, cards, the subscription) stays #150's. Both links open a new tab, as "Approve payment" does. A paused plan has no invoice to pay and shows no Pay invoice.
- **The entitlements panel and the dashboard.** The panel lists the suspended plan's line with a "Payment due" or "Paused" badge, and the dashboard counts the plan as one the organization holds.
- **The operator's organization page.** The Pools panel shows the suspended delivery with a Suspended badge, and the Issue grant form warns about a grant that would supersede a suspended subscription, as it warns for an active one.
- **The test kit writes the state the app writes.** `world.Suspend` takes the subscription status and writes it with the suspension; today it leaves the subscription `active`, a pair the reconcile never produces.
## Quick fixes inside this change
Listed here because each is small and was found while investigating #148.
- **The operator Billing card ranks a suspended subscription worst.** The card's aggregate badge ranks `past_due` first and `unpaid` and `paused` below `active` and `trialing`, so an account with one active and one unpaid subscription reads Active. The precedence becomes `unpaid`, `past_due`, `paused`, canceling, `active`, `trialing`, then the rest. A single subscription in one of the three suspending states carries no "renews {date}" note, because Stripe advances the period whether or not the invoice is paid and the date names a period nobody has paid for.
- **The operator Billing card draws an unpaid subscription in the danger tone.** The subscription's `unpaid` badge ranks worst but is drawn in the warning tone, below `past_due`'s danger tone. The subscription's badge gets its own badge key in the danger tone; the invoice and balance "Unpaid" badges keep theirs.
- **The cancellation badge's tooltip on a suspended plan.** The ladder heading's "Cancels on {date}" badge carries the tooltip "The plan does not renew; access continues until this date.", which is false while the plan's entitlements are suspended. On a suspended plan the badge renders without it.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `member-offer`: modified "One decision says which move a member is offered on a ladder" (the suspended position and its rule).
- `member-product-discovery`: added "A plan whose payment is due stays the current plan, with its state".
- `member-plan-upgrade`: renamed "Current plan is determined from active ladder attachments" to "Current plan is determined from live ladder attachments", and modified it.
- `member-entitlement-view`: modified "Member sees current plan tier" (every live position, with the state of a suspended one).
- `member-dashboard`: modified "The dashboard has one empty state" (a suspended plan is a plan the organization holds).
- `member-invoice-history`: added "Stripe's page of an invoice is read when the member opens it" and "An open invoice offers Pay invoice".
- `stripe-subscription-creation`: added "Checkout refuses a ladder whose plan is suspended".
- `stripe-subscription-management`: added "A suspended subscription is kept or cancelled, never switched"; modified "Member-initiated paid-to-paid plan switch" (the suspended answer in the refusal order, after the member gate) and "Member-initiated cancellation with selectable timing" (a live paid subscription, access persisting while it is not suspended, immediate cancellation refused on a suspended one).
- `plan-enrollment-administration`: modified "Advisory warning when a grant would supersede a subscription-sourced position" (a suspended position too) and "The composite's billing card summarizes the money" (the precedence, the note and the unpaid badge's tone) and "Per-org enrollment view" (live positions, active or suspended); added "The Pools panel shows a suspended delivery with its state".
## Impact
- **Code:** `internal/entitlements` (the query, `PositionsByLadder`'s comment, a temporary filter that the last section deletes), `internal/plans/offer.go` (the fact, the rule, the reason), `internal/fulfillment/plan_change.go` (the resolver carries the position's status; `SwitchPlan` refuses; `HasActiveSubscriptionOnLadder` is renamed `SubscriptionBacksLadder`), `internal/server` (`plan_offer.go`, `member_plans.go`, `member_plan_moves.go`, `member_entitlements.go`, `server.go`, `member_invoices.go`, a new `member_hosted_invoice.go`, `operator_enrollment.go`, `anatomy.go`), the templates `member_plans.html`, `member_entitlements.html`, `member_invoice_detail.html` and `operator_enrollment.html`, `internal/testkit/world/plans.go`, and `test/e2e/screens/manifest.go` (two Skip entries).
- **Routes:** two new member GET routes, `/billing/plans/{ladderID}/unpaid-invoice` and `/billing/invoices/{invoiceID}/hosted`, each answering a redirect.
- **Stripe calls:** the two routes read a subscription with its latest invoice expanded and an invoice by id, calls the console's key already needs for the subscription reconcile and the invoice reconcile. Nothing else calls Stripe that did not before.
- **Schema and configuration:** none. The hosted invoice URL is not stored.
- **Behaviour a deployment sees:** a member whose payment is due sees the plan with its state and can pay, keep or cancel it, and can no longer start a second subscription on the plan's ladder; an operator sees the suspended delivery, the supersession warning and the worst subscription state.
- **Tests:** offer decision rows; fulfillment tests over a subscription the reconcile suspended from the Stripe mock; member plans, member entitlements and organization page goldens; the two routes against the Stripe mock; the route tables; a dashboard row; a test that a suspended position delivers nothing.
- **Docs:** `docs/models/plan-ladders-transitions.md` (the member position row), `docs/models/payments-billing.md` (status classification), `docs/stripe.md` (plan changes while a payment has failed, and Pay invoice).
## Related issues
- **#150** specifies the member's link to Stripe's invoice page; the by-invoice route here is that route, and #150 keeps the invoice number link, the payments and the cards.
- **#212** covers a recovered subscription conferring over whatever superseded it while it was suspended, and also an active subscription at any reconcile. This change closes the second-Checkout route into that state; the state itself is #212's. A grant issued under the Issue grant warning over a suspended subscription is therefore ended when that subscription recovers.
- **#134** is what Pay invoice's environment check leans on (design D8): a plan or an invoice whose mapping the key cannot reach goes to `/billing`.
- **#40**: the FedWiki sites card's "upgrade your plan to make them editable again" (`internal/integrations/fedwiki/templates/fedwiki_sites.html:69`) still shows beside a refused switch on a suspended plan. Unchanged here.
- **#193** covers the suspension's provision write taken without the pool lock. Unchanged here.
## Out of scope
Each is a candidate for the maintainer to decide on; none is filed.
- **A grace period for `past_due`.** The design documents describe a `past_due` subscription that keeps delivering for a configurable grace period; the code suspends at once, and the specs and the model card follow the code. This change keeps the code's behaviour. Candidate issue.
- **Changing the card on file.** A member cannot replace the card that fails; paying on Stripe's page may not make the new card the subscription's default (unverified until the landing check). Stripe's customer portal has a `payment_method_update` flow that could serve it. Candidate issue.
- **`invoice.marked_uncollectible` is not handled.** An invoice Stripe marks uncollectible keeps reading Open until another of its events arrives. The Pay invoice routes read Stripe when opened and do not depend on it. Candidate comment on #150.
- **A second Checkout while the first subscription is `incomplete`.** An incomplete subscription has no position yet, so Checkout on its ladder stays allowed; Stripe expires an incomplete subscription after 23 hours. Note only.
- **An earlier Checkout Session that has not expired** can still complete a second subscription on a suspended plan's ladder. The console sets no `expires_at` on a session (`internal/server/billing.go:137-153`), so this holds for active plans too. Note only.
- **An add-on whose subscription is suspended.** The add-ons page reads no holdings, offers Add again and shows no state; on recovery both subscriptions deliver and bill. Candidate issue.
- **Collection stops when a period-end cancellation takes effect.** When the cancellation of a `past_due` plan takes effect, Stripe stops automatic collection of all the customer's finalized invoices, which reaches the customer's other subscriptions; the member pays through Pay invoice. Candidate issue (design, S9).
@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: The dashboard has one empty state
When no integration declares a card and the person's organization holds no entitlement, the dashboard SHALL render one empty state in place of the cards and the entitlements section, reading "Nothing to show yet. Your services and entitlements appear here once your organization holds a plan or a grant.", through the shared empty-state part; it SHALL NOT render two empty sections. A plan the organization holds counts whether its position is active or suspended, because a suspended plan is still the organization's while Stripe collects its subscription, and the sentence would otherwise say it holds none.
#### Scenario: An empty dashboard says so once
- **WHEN** a member with no entitlements views the dashboard on a deployment with no configured integration
- **THEN** one empty state renders with the sentence above, and no empty card or empty entitlements list renders beside it
#### Scenario: A suspended plan is a plan the organization holds
- **WHEN** a member's organization holds one plan, which grants only an on/off capability, and its subscription is `past_due`, so the capability is not granted
- **THEN** the dashboard does not render the empty state
@@ -0,0 +1,43 @@
## MODIFIED Requirements
### Requirement: Member sees current plan tier
The member entitlement view SHALL surface the current plan tier **per enrolled ladder** — "current" is a set, one rung for each ladder the organization's default pool is attached to, not a single global value. For **every** live (active or suspended) `pool_provision_ladders` attachment in that pool (not merely the first), the view SHALL display one line, "`<ladder-display-name>`: you are on **`<tier-display-name>`**", naming the ladder by its operator-given display name and the tier by its product display name in bold, so each service axis the member is enrolled on is shown distinctly. A ladder SHALL yield one line, from its active attachment when it holds an active and a suspended one. The line of a suspended attachment SHALL be followed by a badge naming its state, chosen by the subscription's status: "Payment due" in the danger tone, or "Paused" in the secondary tone for a `paused` subscription; the line of an active attachment SHALL carry no badge. The line SHALL NOT carry the pool's name. The lines SHALL be ordered as the plans region orders its ladders. The current rung SHALL be resolved from the live attachment regardless of whether the underlying provision is grant-, subscription-, or purchase-linked (`chk_pool_provisions_source`).
The view SHALL list **only enrolled axes**. It SHALL NOT enumerate ladders the pool is not attached to (discovering unjoined ladders is the catalog's responsibility, per `member-product-discovery`). When the default pool has no live ladder attachment at all, the view SHALL display an appropriate off-plan state (e.g., "No active plan") consistent with the existing no-entitlement messaging.
#### Scenario: Member enrolled on multiple ladders sees each axis
- **WHEN** the default pool of a member's organization has active `pool_provision_ladders` attachments on both the `hosting` ladder (Standard) and the `support` ladder (Basic)
- **THEN** the view SHALL display both "Hosting: you are on Standard" and "Support: you are on Basic", in the order the plans region lists those ladders
- **AND** it SHALL NOT collapse to or display only one of them
#### Scenario: Member on a single ladder sees that tier label
- **WHEN** an authenticated member visits the Products page
- **AND** their organization's default pool has an active `pool_provision_ladders` row attaching it to ladder `hosting` at the Public Tier
- **THEN** the view SHALL display "Hosting: you are on Public Tier" (or the tier's configured display name)
#### Scenario: Enrolled axes only
- **WHEN** a member is attached to the `hosting` ladder but not the `support` ladder
- **THEN** the entitlement view SHALL show the hosting tier
- **AND** it SHALL NOT list the support ladder as "not subscribed" or any equivalent
#### Scenario: A plan whose payment is due is listed with its state
- **WHEN** the default pool's attachment on the `hosting` ladder at Pro is suspended because its subscription is `past_due`
- **THEN** the view SHALL display "Hosting: you are on Pro" followed by the badge "Payment due"
- **AND** it SHALL NOT display "No active plan"
#### Scenario: A paused plan is listed with its state
- **WHEN** the default pool's attachment on the `hosting` ladder at Pro is suspended because its subscription is `paused`
- **THEN** the view SHALL display "Hosting: you are on Pro" followed by the badge "Paused"
#### Scenario: Member with no live ladder attachment
- **WHEN** an authenticated member visits the Products page
- **AND** their organization's default pool has no active or suspended `pool_provision_ladders` row
- **THEN** the view SHALL display a "no active plan" state
- **AND** the existing entitlement and source-attribution sections SHALL still render as before
@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Stripe's page of an invoice is read when the member opens it
The console SHALL send a member to Stripe's hosted invoice page through two member routes, each behind the member session the other billing routes require, each answering a redirect and nothing else:
- `GET /billing/invoices/{invoiceID}/hosted` SHALL apply the gate the invoice detail applies, through the same function: an invoice that does not exist, or whose billing account does not belong to the member's organization, SHALL answer 404. It SHALL then read the invoice's Stripe mapping. With Stripe not configured, no mapping, or a mapping whose recorded environment the API key's mode cannot reach, or that the environment check marked stale (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids), it SHALL answer 303 to `/billing` and SHALL call Stripe for nothing. Otherwise it SHALL read the invoice from Stripe and SHALL answer 303 to its `hosted_invoice_url` whenever Stripe returns one, whatever the invoice's status, and 303 to `/billing` when Stripe returns none or the read fails.
- `GET /billing/plans/{ladderID}/unpaid-invoice` SHALL read the organization's position on the ladder in its default pool. A ladder id that is not a UUID, no position, a position no subscription backs, or a subscription whose billing account does not belong to the member's organization SHALL answer 404. It SHALL pick its row through `entitlements.PositionsByLadder`, so a ladder holding an active and a suspended row yields the active one. With Stripe not configured, no mapping for the subscription, or a mapping the environment check refuses, as the first route applies it to the invoice's, it SHALL answer 303 to `/billing` and SHALL call Stripe for nothing. Otherwise it SHALL read the subscription from Stripe with its latest invoice expanded, and SHALL answer 303 to the latest invoice's `hosted_invoice_url` when that invoice is `open`, and 303 to `/billing` otherwise, a failed read included.
The hosted invoice URL changes on every read, expires, and lets anyone holding it view and pay the invoice, so it SHALL be read when the member opens a route and SHALL NOT be stored or written to any log line; both routes' answers SHALL carry `Cache-Control: no-store`. A failed Stripe read SHALL log the organization, the invoice or subscription id and the error.
#### Scenario: An open invoice opens its hosted page
- **WHEN** a member opens the invoice route for an open invoice of their organization whose mapping the key reaches
- **THEN** the console reads the invoice from Stripe and answers 303 to its `hosted_invoice_url`
- **AND** no log line and no database row carries the URL
#### Scenario: A paid invoice opens its hosted page
- **WHEN** a member opens the invoice route for a paid invoice of their organization whose mapping the key reaches
- **THEN** the console answers 303 to its `hosted_invoice_url`
#### Scenario: Another organization's invoice is not found
- **WHEN** a member opens the invoice route for an invoice whose billing account belongs to another organization
- **THEN** the console answers 404 and calls Stripe for nothing
#### Scenario: An invoice in the other environment goes to the Billing page
- **WHEN** a member opens the invoice route for an invoice whose mapping records the environment the key is not in
- **THEN** the console answers 303 to `/billing` and calls Stripe for nothing
#### Scenario: The plan's unpaid invoice opens its hosted page
- **WHEN** a member whose subscription on a ladder is `past_due` opens the plan route for that ladder, and the subscription's latest invoice is open
- **THEN** the console answers 303 to that invoice's `hosted_invoice_url`
#### Scenario: A plan whose latest invoice is paid goes to the Billing page
- **WHEN** a member opens the plan route after the plan's latest invoice was paid
- **THEN** the console answers 303 to `/billing`
#### Scenario: A plan the organization does not hold is not found
- **WHEN** a member opens the plan route for a ladder on which the organization holds no position a subscription backs
- **THEN** the console answers 404 and calls Stripe for nothing
### Requirement: An open invoice offers Pay invoice
The member's invoice detail of an invoice whose status is `open` SHALL offer "Pay invoice", a link that opens a new tab on `GET /billing/invoices/{invoiceID}/hosted`, when the invoice has a Stripe mapping whose recorded environment the API key's mode can reach. An invoice in any other status, an invoice with no mapping, and an invoice whose mapping the key cannot reach SHALL offer no Pay invoice.
#### Scenario: An open invoice offers Pay invoice
- **WHEN** a member opens the detail of an open invoice whose mapping records the key's environment
- **THEN** the detail shows "Pay invoice", linking to the invoice's hosted route in a new tab
#### Scenario: A paid invoice offers no Pay invoice
- **WHEN** a member opens the detail of a paid invoice
- **THEN** the detail shows no "Pay invoice"
#### Scenario: An invoice in the other environment offers no Pay invoice
- **WHEN** a member opens the detail of an open invoice whose mapping records the environment the key is not in
- **THEN** the detail shows no "Pay invoice"
@@ -0,0 +1,70 @@
## MODIFIED Requirements
### Requirement: One decision says which move a member is offered on a ladder
The console SHALL decide which move an organization is offered toward a target price on a plan ladder in one function, `plans.Offer` in `internal/plans`, that reads no database row and calls no payment provider. Its inputs SHALL be the organization's position on the ladder and the target:
- the position: the ladder's tiers and their ranks; the product the organization holds on the ladder, if any, through a position that is active or suspended; whether a subscription pays for that rung, as opposed to a grant, the organization type's default or a purchase; whether the position is suspended, which its ladder row's status says (the subscription reconcile suspends it while the subscription is `past_due`, `unpaid` or `paused`); whether it is paused, which the subscription's status `paused` says; whether the subscription's cancellation is scheduled; the product an active scheduled downgrade moves to; the held rung (`member-product-discovery`, "Plan-move controls reflect built billing mechanics"); and, when the cancel prediction exists, the product it puts on the ladder, if any;
- the target: the product; whether it clears the member gate (published, active, public); and the target price, with whether it is the product's default price (price-management, "Each product has at most one default price, which every purchase surface tracks"), whether it is recurring, and whether its Stripe mapping carries an id the current key can reach. A product with no active price is a target with no price.
Its answer SHALL be one move: Checkout, a switch, a downgrade at the period end (the cancellation after which the held rung or the free rung follows), or none; whether an offered move is enabled; and the reason when no move is offered or the move is disabled. It SHALL route moves as `member-product-discovery`'s "Plan-move controls reflect built billing mechanics" states, and in addition:
- a price that is not the product's default price SHALL be offered no move;
- a Checkout or a switch toward a price that is one-time, or whose mapping has no Stripe id the key can reach, or toward a tier with no price, SHALL be offered disabled;
- a Checkout or a switch toward a product that fails the member gate SHALL be offered no move;
- a position whose current product is not a tier of the ladder SHALL be offered no move on that ladder;
- a suspended position SHALL be offered no switch, in either direction, with the reason that the position is suspended; the downgrades at the period end (the held rung's and the free rung's) SHALL be offered as for a position that is not suspended, and, because a subscription backs the rung, no tier of the ladder SHALL be offered Checkout;
- a paused position SHALL be offered no move at all, with the reason that the position is paused: no switch, and no downgrade at the period end either, because what a cancellation does to a paused subscription is unverified.
The Products page, Checkout, the plan switch and the switch preview SHALL all decide through this function, so no surface offers or accepts a move another refuses.
#### Scenario: A price other than the default is offered nothing
- **WHEN** a product on a ladder has an older active price and a newer default price, and the decision is asked about the older price for an organization that holds nothing on the ladder
- **THEN** it answers no move, with the reason that the price is not the default
#### Scenario: A one-time default price cannot be bought
- **WHEN** the decision is asked about a tier whose default price is one-time, for an organization that holds nothing on the ladder
- **THEN** it answers Checkout, disabled, with the reason that the price is one-time
#### Scenario: A tier below a granted rung is offered nothing
- **WHEN** an organization holds Plus by a grant and the decision is asked about Basic, ranked below Plus
- **THEN** it answers no move, with the reason that no subscription pays for the rung
#### Scenario: The granted tier itself is offered nothing
- **WHEN** an organization holds Plus by a grant and the decision is asked about Plus's default price
- **THEN** it answers no move, with the reason that the tier is current
#### Scenario: A higher tier over a grant is Checkout
- **WHEN** an organization holds Plus by a grant and the decision is asked about Pro's default price, recurring and reachable
- **THEN** it answers Checkout, enabled
#### Scenario: The tier a scheduled downgrade moves to is offered nothing
- **WHEN** an organization's subscription on Pro has a downgrade to Standard scheduled and the decision is asked about Standard's default price
- **THEN** it answers no move, with the reason that the move is scheduled
#### Scenario: A suspended position is offered no switch
- **WHEN** an organization's subscription on Standard is `past_due`, so its position is suspended, and the decision is asked about Pro, ranked above Standard, or about Basic, a priced tier ranked below it
- **THEN** it answers no move, with the reason that the position is suspended
- **AND** it answers no Checkout for either
#### Scenario: A suspended position keeps its downgrade at the period end
- **WHEN** the same organization's subscription bought over a grant for Basic that is marked to resume, and the decision is asked about Basic
- **THEN** it answers the downgrade at the period end, enabled, as it does for a position that is not suspended
#### Scenario: A paused position is offered nothing
- **WHEN** an organization's subscription on Standard is `paused`, and the decision is asked about Pro, about a held rung ranked below Standard, and about the free rung that follows a cancellation
- **THEN** it answers no move for each, with the reason that the position is paused
#### Scenario: The decision reads nothing
- **WHEN** the decision's package is built
- **THEN** it imports no database, query or payment-provider package, and its table test runs without a database
@@ -0,0 +1,27 @@
## RENAMED Requirements
- FROM: `### Requirement: Current plan is determined from active ladder attachments`
- TO: `### Requirement: Current plan is determined from live ladder attachments`
## MODIFIED Requirements
### Requirement: Current plan is determined from live ladder attachments
The set of the member's current plans SHALL be derived from the live ladder attachments of the organization's default pool (`core.pool_provision_ladders` with `status` `active` or `suspended`), NOT from delivering grants. A plan product SHALL be treated as current when a live attachment references it, regardless of whether the underlying provision is grant-, subscription-, or purchase-linked (`chk_pool_provisions_source`), and whether the attachment is active or suspended: a suspended attachment is a plan whose subscription Stripe is still collecting, and it delivers nothing while suspended (`member-product-discovery`, "A plan whose payment is due stays the current plan, with its state"). When one ladder holds an active and a suspended attachment, the active one SHALL be current. Because a pool holds at most one rung per ladder but may be enrolled on several ladders, "current" is a set — one product per enrolled ladder.
#### Scenario: Subscription-provisioned plan is recognized as current
- **WHEN** the organization's default pool holds an active ladder attachment for a product via a subscription-linked provision (no grant row)
- **THEN** that product's card SHALL show the current-plan badge
- **AND** the card SHALL NOT render an "Upgrade" control
#### Scenario: Current detection is source-agnostic
- **WHEN** an active attachment in the organization's default pool was created by any provision source (grant, subscription, or purchase)
- **THEN** the referenced product SHALL be treated as current on the plans page
#### Scenario: A suspended attachment is current
- **WHEN** the organization's default pool holds a suspended ladder attachment for Pro, because its subscription is `past_due`
- **THEN** Pro's card SHALL show the current-plan badge
- **AND** no tier of that ladder SHALL render a "Subscribe" or "Upgrade" control routed to Checkout
@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: A plan whose payment is due stays the current plan, with its state
A position whose ladder row is `suspended` (the subscription reconcile suspends it while the subscription is `past_due`, `unpaid` or `paused`) SHALL be the organization's current plan on its ladder: the ladder renders as held, and the tier's card renders as the current card, with the "Current plan" badge. The ladder row's status SHALL decide that the plan is suspended, and the subscription's status SHALL choose the words: `paused` reads as paused, and every other status reads as payment due.
The current card of a suspended plan SHALL differ from the card "The member's current plan shows its cost and renewal" describes in these ways, and in no other:
- in place of the renewal line, "{price}, renews {date}", the price line SHALL read "{price}, payment due", or "{price}, paused" for a paused plan; a scheduled cancellation's "{price}, ends {date}" SHALL stay as it is, and so SHALL a scheduled downgrade's "{price}, moves to {tier} on {date}" while its date is after now; a scheduled downgrade whose date is not after now has already been applied by Stripe, so the card SHALL draw no "moves to" line and no "Keep plan" for it;
- under the price line, one line SHALL read "Entitlements are suspended until the invoice is paid.", or "Entitlements are suspended." for a paused plan;
- a plan whose payment is due SHALL offer "Pay invoice" as the first of the card's controls, a link that opens a new tab on the console's route for the plan's unpaid invoice (`member-invoice-history`, "Stripe's page of an invoice is read when the member opens it");
- "Keep plan" and "Cancel plan" SHALL be offered, after Pay invoice, as for a plan that is not suspended, except that "Keep plan" SHALL NOT be offered for a scheduled downgrade Stripe has already applied;
- a paused plan SHALL show its state only: no "Pay invoice", because a paused subscription generates no invoice, no "Keep plan", no "Cancel plan", and no "Downgrade" on the held rung or the free rung;
- on a plan whose payment is due, the cancel confirmation's body SHALL name the end in place of the keep that requirement defines: "{plan} ends on {date}." when nothing follows the plan; "{plan} ends on {date}, then you move to {lower plan}." when one lower plan follows; "{plan} ends on {date}; then {clauses}." for any other prediction, the clauses being the ones that requirement uses; and "{plan} ends at the end of this billing period." when there is no prediction or no date. It SHALL be followed by "The unpaid invoice stays due.", on every control on the ladder that carries the body (Cancel plan, and the held rung's and the free rung's "Downgrade");
- the ladder heading's cancellation badge SHALL keep its label and SHALL carry no tooltip, because the tooltip says access continues.
The offer decision answers every move on the ladder (`member-offer`): no tier SHALL show a Checkout or switch control, and the held rung's and the free rung's "Downgrade" SHALL render as for a plan that is not suspended. Pay invoice SHALL count as an enabled control that costs money for "The Products page names who bills the member when the name would not be recognised": it makes the Plans paragraph render and references it with `aria-describedby`.
This narrows "The member's current plan shows its cost and renewal" and "Plan-move controls reflect built billing mechanics" for a suspended plan.
#### Scenario: A plan whose payment is due is current, with its state
- **WHEN** a member's subscription to Pro at $30.00 a month is `past_due`
- **THEN** the ladder renders as held and Pro's card shows "Current plan", "$30.00/month, payment due" and "Entitlements are suspended until the invoice is paid."
- **AND** the card offers "Pay invoice", then "Cancel plan"
- **AND** no tier of the ladder shows "Subscribe", "Upgrade" or a "Downgrade" routed to the switch
#### Scenario: An unpaid plan reads as payment due
- **WHEN** a member's subscription to Pro is `unpaid`
- **THEN** Pro's card reads "$30.00/month, payment due" and offers "Pay invoice"
#### Scenario: A paused plan shows its state and offers nothing
- **WHEN** a member's subscription to Pro is `paused`
- **THEN** Pro's card reads "$30.00/month, paused" and "Entitlements are suspended."
- **AND** the card offers no "Pay invoice", no "Keep plan" and no "Cancel plan"
#### Scenario: Cancelling a plan whose payment is due says the invoice stays due
- **WHEN** a member on Pro, whose period ends Jan 15, 2099 and whose payment is due, with no grant waiting and no default to restore, opens "Cancel plan"
- **THEN** the confirmation reads "Pro ends on Jan 15, 2099. The unpaid invoice stays due."
#### Scenario: The held rung's downgrade carries the same body
- **WHEN** the same member's subscription bought over a grant for Plus that is marked to resume, and Plus ranks below Pro
- **THEN** Plus's card shows "Granted" and its "Downgrade", whose confirmation reads "Pro ends on Jan 15, 2099, then you move to Plus. The unpaid invoice stays due."
#### Scenario: A cancelled plan whose payment is due keeps both facts
- **WHEN** a member whose payment is due on Pro has cancelled it at the period end
- **THEN** Pro's card reads "$30.00/month, ends Jan 15, 2099" and "Entitlements are suspended until the invoice is paid.", and offers "Pay invoice" and "Keep plan"
- **AND** the ladder heading's "Cancels on Jan 15, 2099" badge carries no tooltip
#### Scenario: A downgrade that Stripe already applied is not offered back
- **WHEN** a member whose payment is due on Pro had a downgrade to Plus scheduled for Jan 1, 2020, which is past
- **THEN** Pro's card reads "$30.00/month, payment due" with no "moves to" line, offers "Pay invoice" and "Cancel plan", and offers no "Keep plan"
#### Scenario: Pay invoice brings the billing line
- **WHEN** the plans region's only enabled control that costs money is a suspended plan's "Pay invoice", and the billing name is not recognised
- **THEN** the paragraph renders under the Plans heading and "Pay invoice" references it with `aria-describedby`
@@ -0,0 +1,120 @@
## MODIFIED Requirements
### Requirement: Per-org enrollment view
The system SHALL provide an operator UI that shows, for a given organization, the plan-position state of each of its resource pools. The page SHALL title itself with the organization's display name (it is the organization's detail page; the word "Enrollment" SHALL NOT appear as its title or navigation label), with the breadcrumb Organizations → {organization name}, and its in-page section headings SHALL name their content in canonical vocabulary (`ui-vocabulary`) — plan and grants, members, pools, billing — with position history labeled "position history". For each pool, the view SHALL display the pool's live positions, active or suspended, with the Suspended badge on a suspended one (ladder display name, current tier product name, rank, `activated_at`) and a chronological list of `pool_provision_transitions` rows with `transition_type`, `from_rank`, `to_rank`, `actor_type`, `actor_id` (resolved to a display name where available), `reason`, and `effective_at`.
#### Scenario: Operator views org with an active position
- **WHEN** an operator opens the organization detail page for an organization whose default pool has an active `pool_provision_ladders` row on ladder `core` at rank `1`
- **THEN** the UI SHALL display the pool's current tier as the product at rank 1 on ladder `core`
- **AND** the position history SHALL include the `initiate` or `upgrade` row that established the current position
#### Scenario: Operator views org with no live positions
- **WHEN** an operator opens the organization detail page for an organization whose pools have no live (active or suspended) positions
- **THEN** the UI SHALL display a "no active plan" state per pool
- **AND** any past `pool_provision_transitions` rows SHALL still be listed in the history
#### Scenario: The page is titled by the organization, not "Enrollment"
- **WHEN** an operator opens the per-organization view
- **THEN** the page title is the organization's display name and the breadcrumb reads Organizations → {name}
- **AND** neither the title, the breadcrumb, nor the sidebar uses the word "Enrollment"
### Requirement: Advisory warning when a grant would supersede a subscription-sourced position
When an operator's grant submission would cause `confer` to supersede a position currently held by a subscription-sourced provision, active or suspended, the UI SHALL display an advisory warning, before or alongside the confirmation of the submission, stating that the underlying subscription continues to bill even though its pool position is about to be superseded. A suspended position counts because `confer` supersedes a suspended incumbent as it supersedes an active one, and the subscription keeps being retried and billed. This is a visibility measure only: the system SHALL NOT block the submission, and SHALL NOT modify or cancel the subscription as a side effect of the grant (Doc 41 §11.2 names this as an open cross-layer question the operator UI makes visible, not one this capability resolves).
#### Scenario: Operator is warned before superseding a subscription-held position
- **WHEN** an operator submits a grant for a product that would supersede a ladder position currently delivered by a subscription-sourced provision
- **THEN** the UI SHALL display an advisory warning that the subscription will continue billing after the position is superseded
- **AND** the submission SHALL proceed to create the grant and invoke `confer` if the operator confirms
#### Scenario: Operator is warned about a suspended subscription's position
- **WHEN** an operator opens Issue grant for an organization whose subscription on a ladder is `past_due`, so its position there is suspended
- **THEN** the form SHALL mark the products that are tiers of that ladder as superseding a subscription and SHALL show the advisory warning
### Requirement: The composite's billing card summarizes the money
On the organization composite, the billing card SHALL render three rows in this order, Outstanding balance, Subscriptions, Latest invoice, each as a muted label above its value line, with a badge leading the value only where there is a state to read; and it SHALL render a fourth, full-width Account row above them, carrying the billing account's status badge, only when that status is not the normal active one. The card SHALL name no product, and SHALL be built from the shared status-badge part and Bootstrap utilities alone.
The **outstanding balance** SHALL always render as a formatted amount and never as a bare digit: at zero it is the plain amount, with no badge and no link; when money is owed it leads with the Unpaid badge and renders the amount as a plain link to this organization's open invoices (the invoices list's own search and status parameters).
The **Subscriptions** row SHALL aggregate every subscription on the billing account. Its badge is the worst state across them, in the precedence unpaid, past due, paused, canceling (cancel at period end), active, trialing, then any other state; its value is the count, as a plain link to this organization's subscriptions; the badge of an unpaid subscription is drawn in the danger tone, through a badge key of its own, while the Unpaid badge of the outstanding balance and of an invoice keeps the warning tone. A single subscription SHALL carry a muted period clause, "renews {date}", or "ends {date}" when it cancels at the period end; a single subscription that is unpaid, past due or paused SHALL carry no period clause, because Stripe advances its period whether or not the invoice is paid. Several subscriptions SHALL carry a muted note counting those in the worst state, and only when they are not all of them. An account with no subscriptions SHALL read a muted "No subscriptions" and SHALL say nothing about grants.
The **Latest invoice** row SHALL show the invoice issued last, by the order stripe-invoice-payment-projection sets for invoices (Requirement: Invoices and payments are listed by when they happened), and SHALL read as one line: the invoice's state badge, then the invoice number, linking to the invoice, then a muted middot and the date the invoice was issued (its `issued_at`, never the time the console recorded it); the state SHALL be the one the invoices list derives for the same invoice, Overdue included, so the two surfaces can never disagree. The invoice's own amount SHALL NOT render on the card; the outstanding balance row already carries money owed, and the invoice's own page carries its amount. Where no invoice number has been assigned yet, the unavailable-value marker with its tooltip SHALL stand in the number's place. A draft has not been issued and SHALL NOT be the latest invoice; an account with no issued invoice, none at all or only a draft, SHALL read "No invoices issued".
The Billing section header's "Full billing" action SHALL remain a navigation button and SHALL point at the accounts list searched for this organization, not at the unscoped list.
#### Scenario: A zero balance reads as money
- **WHEN** an operator views the billing card of an organization that owes nothing
- **THEN** the outstanding balance renders as the formatted amount (for example "USD 0.00"), with no badge, no link, and no caption
#### Scenario: An owed balance reaches its invoices
- **WHEN** an operator views the billing card of an organization with money outstanding
- **THEN** the Unpaid badge leads the row and the amount links to that organization's open invoices
#### Scenario: Subscriptions read as a count and a worst state
- **WHEN** an operator views the billing card of an organization whose account holds two subscriptions, one of them past due
- **THEN** the row reads the Past due badge, "2 subscriptions" as a link to that organization's subscriptions, and a muted note counting the one past due
#### Scenario: An unpaid subscription outranks an active one
- **WHEN** an operator views the billing card of an organization whose account holds one active and one unpaid subscription
- **THEN** the row reads the Unpaid badge in the danger tone, "2 subscriptions", and a muted note counting the one unpaid
#### Scenario: A single subscription names its period
- **WHEN** an operator views the billing card of an organization with one subscription
- **THEN** the row reads its state badge, "1 subscription" as a link, and a muted "renews {date}", or "ends {date}" when the subscription cancels at the period end
#### Scenario: A single suspended subscription names no period
- **WHEN** an operator views the billing card of an organization with one subscription, which is past due
- **THEN** the row reads the Past due badge and "1 subscription" as a link, and no "renews {date}"
#### Scenario: An account with no subscriptions says so and no more
- **WHEN** an operator views the billing card of an organization whose account holds no subscriptions
- **THEN** the row reads a muted "No subscriptions" and the card says nothing about grants
#### Scenario: The latest invoice reads as one line, no amount
- **WHEN** an operator views the billing card of an organization with an invoice
- **THEN** the row reads one line: the invoice's derived state badge, the invoice number linking to the invoice, then a muted middot and the date it was issued
- **AND** the invoice's own amount does not render on the card
- **AND** an invoice with no number yet renders the unavailable-value marker with its tooltip in the number's place
#### Scenario: The latest invoice is the one issued last
- **WHEN** an operator views the billing card of an organization whose invoice issued in April was projected before its invoice issued in March
- **THEN** the Latest invoice row shows the April invoice, dated in April
#### Scenario: A draft is not the latest invoice
- **WHEN** an operator views the billing card of an organization whose account holds only a draft invoice
- **THEN** the Latest invoice row reads "No invoices issued"
#### Scenario: An abnormal account status gets its own row
- **WHEN** an operator views the billing card of an organization whose billing account is not active
- **THEN** a full-width Account row above the three rows carries the account's status badge
- **AND** an active account renders no Account row
## ADDED Requirements
### Requirement: The Pools panel shows a suspended delivery with its state
The organization composite's Pools panel SHALL list, for each pool, every live (active or suspended) position, so a delivery whose provision is suspended, because its subscription is `past_due`, `unpaid` or `paused`, renders as a delivery block with its product and rung pills, followed after the product's name by the Suspended badge, and the panel SHALL NOT say that no plan is delivering into the pool. The badge belongs to the delivery, because the status is the provision's and every rung of it carries the same. An active delivery SHALL carry no badge.
#### Scenario: A suspended subscription's delivery is shown
- **WHEN** an operator opens the organization page of an organization whose only delivery is a subscription that is `past_due`
- **THEN** the Pools panel shows the delivery's product, its rung pill and the Suspended badge
- **AND** the panel does not read "No plan is delivering into this pool"
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Checkout refuses a ladder whose plan is suspended
A suspended position (its ladder row's status is `suspended`, which the subscription reconcile writes while the subscription is `past_due`, `unpaid` or `paused`) SHALL count, for Checkout's offer decision, as a tier on a ladder a subscription already pays for (Requirement: Checkout endpoint creates a Stripe Checkout Session), because the subscription still exists and Stripe is still collecting it, and a second subscription would supersede the first one's position while both bill. So Checkout SHALL refuse every tier of a ladder on which the organization's position is suspended, and every product one of whose ladders is such a ladder, with 422 and the unavailable-plan sentence, before any Stripe call and without creating a session. A tier of a ladder on which the organization holds nothing SHALL be offered Checkout as before.
#### Scenario: Checkout refuses a second subscription on a suspended plan's ladder
- **WHEN** a member whose subscription to Standard is `past_due` requests checkout for Pro or for Basic on the same ladder, from a tab rendered before the payment failed
- **THEN** the system answers 422 with "That plan is no longer available. Refresh the page and choose a current plan.", makes no Stripe API call and creates no Checkout Session
#### Scenario: Checkout on another ladder stays allowed
- **WHEN** the same member requests checkout for a tier of a second ladder on which the organization holds nothing
- **THEN** the system creates the Checkout Session
#### Scenario: A product on the suspended plan's ladder and another is refused
- **WHEN** the same member requests checkout for a product that is a tier of the suspended plan's ladder and of a second ladder the organization holds nothing on
- **THEN** the system answers 422 with the unavailable-plan sentence and makes no Stripe API call
@@ -0,0 +1,199 @@
## MODIFIED Requirements
### Requirement: Member-initiated paid-to-paid plan switch
The system SHALL provide a member-initiated endpoint that moves an active paid subscription from its current tier to another paid tier on the same ladder by changing the **existing subscription item's price** to the target tier's default `price_id`. It SHALL NOT create a second subscription, nor cancel-and-recreate. The move's direction is decided by the tiers' ranks on the ladder, and the direction decides its timing:
- **Upgrade** (target of higher rank): the system SHALL update the item with the preview's `proration_date` (see "Live proration preview before a switch"), `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so Stripe invoices the prorated difference at once and changes the subscription only when that invoice is paid. When the update applies, the system SHALL release a schedule a scheduled downgrade attached, supersede the subscription's active scheduled changes, and reconcile. When the invoice's payment needs the cardholder's approval, the system SHALL answer with the invoice's hosted page and change nothing else. When the payment fails otherwise, the system SHALL void that invoice, so the pending update can never apply, and SHALL answer that the card was declined, changing nothing else. Before it sends an upgrade, the system SHALL void the invoice of any pending update an earlier attempt left.
- **Downgrade** (target of lower rank) on an evergreen subscription or under the `allow` policy: the system SHALL leave the current price in force until `current_period_end` and SHALL attach a Stripe subscription schedule whose next phase carries the target price with `proration_behavior=none` and whose end behavior is `release`, reusing the schedule already attached by an earlier downgrade, and SHALL record the current tier's console price id in the schedule's metadata as `scheduled_from_price_id`. A cancellation armed at period end SHALL be cleared first. The system SHALL supersede the subscription's active scheduled changes and record a `subscription_scheduled_changes` row with `change_type='plan_switch'`, `effective_trigger='period_end'`, `effective_at=current_period_end` and the target `price_id`. No proration and no credit SHALL arise.
A request whose target is the current tier SHALL be refused as a move to the current tier; taking a scheduled move back is the keep endpoint's job (see "Member keeps the current plan").
When the item's price changes, `fulfillment.ReconcileSubscription`'s product-keyed diff (see "Subscription reconcile computes a genuine diff, grouped by product") SHALL converge it: the prior tier's product becomes surplus and SHALL be ended via `entitlements.end_conferral`, and the target tier's product becomes missing and SHALL be conferred via `entitlements.confer`, sourced by the subscription — so that exactly one tier's position is held on that ladder. The recorded position transitions and `subscription_changes` SHALL attribute the member as initiator for a change the member's request applied, and Stripe's event for a change a schedule applied. The console SHALL NOT store the schedule's id; it SHALL read the subscription's `schedule` from Stripe when a move needs it.
The target tier's product SHALL clear the member gate that checkout applies (published, active, public) before any subscription is read. A price can outlive its product's publication state, so the gate is checked on the product, independently of the listing queries that hide unpublished tiers from the catalog.
The switch SHALL also refuse, before any Stripe call, when a Stripe id it would send cannot be reached under the current API key: the target price mapping, the subscription mapping or the item mapping records an environment that disagrees with the key's mode, or carries `sync_status = 'stale'` (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). The refusal SHALL carry the unavailable-plan message the unpublished-tier refusal carries and SHALL change nothing. A target price the key cannot reach is not purchasable, so the offer decision refuses it before the switch reads the subscription (below); for an unreachable subscription or item mapping the switch SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`, naming `stale` in place of the recorded mode for a stale row. An unverified mapping is not a disagreement and SHALL switch as it does today.
On a ladder with a held rung (`member-product-discovery`, "Plan-move controls reflect built billing mechanics"; `grant-resumption`), the switch SHALL also refuse, before any Stripe call, the held rung and every rung ranked below both the held rung and the current rung, because the catalog reaches the held rung only by a cancellation at the period end (through the held rung's own "Downgrade" when it ranks below the current rung, and through the current card's "Cancel plan" when it ranks above) and offers no move below both. It SHALL read the held rung through the same position loader, and decide through the same offer decision, that the catalog's routing uses. The refusal SHALL carry the unavailable-plan message and SHALL change nothing, so a page rendered before the grant was marked cannot schedule a switch the catalog no longer offers.
The switch SHALL accept only a target that the offer decision (`member-offer`, "One decision says which move a member is offered on a ladder") offers as an enabled switch on the named ladder, asked with the organization's position on that ladder and the posted price. So it SHALL refuse, before any Stripe call and changing nothing: a price that is not the target product's default price, an inactive price, a one-time price, a target that is not a tier of the named ladder, the tier a scheduled downgrade already moves to, and every target the catalog reaches through Checkout, through the cancellation, or not at all. A target that fails the member gate, and a price id that is malformed or unknown, SHALL be refused with the unavailable-plan message before the order below applies. For every other target the refusal's answer SHALL be chosen in this order, first match wins: a target that is not a tier of the named ladder SHALL be refused with the unavailable-plan message, whatever holds the ladder; then a position that is suspended SHALL be refused with "Pay the unpaid invoice before changing this plan.", or with "A paused plan cannot be changed." when its subscription is `paused` ("A suspended subscription is kept or cancelled, never switched"); then a ladder no subscription pays for SHALL be refused with the answer for a ladder without an active paid subscription; then a target the decision answers as the current tier SHALL be refused as a move to the current tier; every other refusal SHALL carry the unavailable-plan message ("That plan is no longer available. Refresh the page and choose a current plan."). When any read the decision needs fails (the ladder's tiers, the organization's default pool, the positions in it with their provisions, subscriptions, scheduled changes and a scheduled downgrade's target product, the held grant, the cancel prediction, the target product's prices, or its price mapping for a reason other than its absence), the switch SHALL refuse, before any Stripe call, with the move-failed message ("We couldn't complete that change. Try again in a moment.") and SHALL change nothing.
#### Scenario: Switch modifies the existing subscription in place
- **WHEN** a member on a paid tier of a ladder moves to another paid tier on the same ladder, up or down
- **THEN** the price SHALL change on the existing subscription's item, at once for an upgrade and at `current_period_end` for a downgrade
- **AND** the system SHALL NOT create a new subscription or cancel the existing one
#### Scenario: An upgrade is charged now and applies once paid
- **WHEN** a member on Plus moves up to Pro mid-period and the card on file pays the invoice
- **THEN** Stripe SHALL issue an invoice for the prorated difference at once and change the item's price to Pro's
- **AND** reconcile's diff SHALL end Plus's conferral and confer Pro's, so exactly one tier's position is held on that ladder
#### Scenario: A declined upgrade changes nothing
- **WHEN** the card on file declines an upgrade's invoice
- **THEN** the system SHALL void that invoice and answer that the card was declined
- **AND** the subscription's price, the member's tier, a pending cancellation and a scheduled downgrade SHALL be as they were before the request
#### Scenario: An upgrade awaiting the bank's approval links to Stripe
- **WHEN** an upgrade's invoice needs the cardholder's approval
- **THEN** the system SHALL answer with the amount and the invoice's hosted page, and SHALL change nothing else
- **AND** when the member approves the payment there, the tier SHALL change through the resulting subscription event and reconcile
#### Scenario: A downgrade takes effect at renewal
- **WHEN** a member on Pro moves down to Plus on an evergreen subscription mid-period
- **THEN** the member SHALL keep Pro until `current_period_end`, no proration or credit SHALL arise, and a `plan_switch` row with `effective_trigger='period_end'` SHALL be scheduled
- **AND** the renewal invoice SHALL bill Plus's price, and reconcile SHALL move the tier when Stripe enters the schedule's next phase
#### Scenario: A second downgrade replaces the first
- **WHEN** a member with a downgrade to Plus scheduled chooses Basic before renewal
- **THEN** the schedule's next phase SHALL carry Basic's price, the Plus row SHALL be superseded and a Basic row scheduled
#### Scenario: An upgrade drops a scheduled downgrade once it lands
- **WHEN** a member with a downgrade to Plus scheduled moves up to Premium and the invoice is paid
- **THEN** the schedule SHALL be released and the scheduled row superseded, and the renewal SHALL bill Premium
#### Scenario: Switch is rejected when there is no active paid subscription on the ladder
- **WHEN** a member requests a paid-to-paid switch but holds no active paid subscription on the target ladder
- **THEN** the system SHALL reject the request rather than create a subscription (the free/default→paid path is owned by `member-plan-upgrade` / Checkout)
#### Scenario: A tier of another ladder is unavailable whatever holds the named ladder
- **WHEN** a member whose organization holds nothing on the named ladder posts a switch to a published tier of another ladder
- **THEN** the request SHALL be refused with the unavailable-plan message, not with the answer for a ladder without an active paid subscription, and nothing SHALL change
#### Scenario: Switch onto an unpublished tier is refused
- **WHEN** a member posts a switch whose target price belongs to a product that is draft, retired, inactive or not public, even one with an active, Stripe-mapped price
- **THEN** the request SHALL be refused with the unavailable-plan message before any subscription is read, and nothing SHALL change
#### Scenario: Switch onto a price the key cannot reach is refused
- **WHEN** a member posts a switch whose target price mapping, subscription mapping or item mapping is recorded in the environment the key is not in, or carries `sync_status = 'stale'`
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
- **AND** for an unreachable subscription or item mapping the system SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`
#### Scenario: A switch to a held rung is refused
- **WHEN** a member on Pro, whose subscription bought over a grant for Plus that is marked to resume and holds Plus as the held rung, posts a switch to Plus, or to Basic ranked below Plus
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL be scheduled or changed
#### Scenario: A switch to a price other than the default is refused
- **WHEN** a member on Pro by subscription posts a switch to an active, mapped price of Plus that is not Plus's default price
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
#### Scenario: A switch to a one-time price is refused
- **WHEN** a member on Pro by subscription posts a switch to Plus, whose default price is one-time
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
#### Scenario: A switch to the tier a scheduled downgrade moves to is refused
- **WHEN** a member on Pro with a downgrade to Standard scheduled posts a switch to Standard's default price
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and the scheduled downgrade SHALL stay as it was
#### Scenario: A switch refuses when a read fails
- **WHEN** a read the offer decision needs fails while a member's switch is checked, such as the organization's default pool or the positions in it, the held grant or the target product's prices
- **THEN** the request SHALL be refused with the move-failed message, no Stripe call SHALL be made, and nothing SHALL change
### Requirement: Member-initiated cancellation with selectable timing
The system SHALL provide a member-initiated endpoint that cancels a live paid subscription, active or suspended, supporting **both** immediate cancellation and cancel-at-period-end, **defaulting to cancel-at-period-end**. A cancellation SHALL first release a schedule a scheduled downgrade attached. For **period-end** cancellation, the system SHALL set Stripe `cancel_at_period_end` and then record a `billing.subscription_scheduled_changes` row (`change_type='cancellation'`, `effective_trigger='period_end'`, `status='scheduled'`), in that order, so no reconcile can find the row before Stripe carries the flag; access SHALL persist until `current_period_end` while the subscription is not suspended, and on a suspended subscription, whose entitlements are already suspended, the cancellation SHALL change only when the plan ends. For **immediate** cancellation, the system SHALL cancel the subscription now, end the provision immediately, and write **no** scheduled-change row. The endpoint SHALL refuse an immediate cancellation of a suspended subscription before any Stripe call, because Stripe's immediate cancellation stops automatic collection of all the customer's finalized invoices and so abandons the unpaid invoice, and SHALL refuse a paused subscription whatever the timing ("A suspended subscription is kept or cancelled, never switched").
#### Scenario: Default cancellation is at period end
- **WHEN** a member whose subscription is active cancels without specifying a timing
- **THEN** the system SHALL schedule the cancellation for period end
- **AND** the member SHALL retain access until `current_period_end`
#### Scenario: Period-end cancellation records intent
- **WHEN** a member cancels at period end
- **THEN** the system SHALL set `cancel_at_period_end` on the Stripe subscription
- **AND** it SHALL then record a `subscription_scheduled_changes` row with `change_type='cancellation'`, `effective_trigger='period_end'`, `status='scheduled'`
#### Scenario: A cancellation replaces a scheduled downgrade
- **WHEN** a member with a downgrade scheduled cancels at period end
- **THEN** the system SHALL release the downgrade's schedule, supersede its row, and schedule the cancellation
#### Scenario: Immediate cancellation ends access now
- **WHEN** a member whose subscription is active cancels immediately
- **THEN** the system SHALL cancel the Stripe subscription now and end the provision
- **AND** it SHALL NOT write a scheduled-change row
#### Scenario: Immediate cancellation of a suspended subscription is refused
- **WHEN** a member whose subscription is `past_due` posts a cancellation with `timing=immediate`
- **THEN** the request is refused with "A plan with an unpaid invoice cannot end immediately.", no Stripe call is made, and no row is written
## ADDED Requirements
### Requirement: A suspended subscription is kept or cancelled, never switched
A subscription whose position on a ladder is suspended (its ladder row's status is `suspended`, which the subscription reconcile writes while the subscription is `past_due`, `unpaid` or `paused`) SHALL be resolved by the switch, its preview, the keep endpoint and the cancellation endpoint as the subscription that backs the member's rung on that ladder, as an active one is.
The switch and its preview SHALL refuse it, in either direction, before any Stripe call and before any scheduled change is written, changing nothing. Among the answers "Member-initiated paid-to-paid plan switch" orders, this answer comes directly after the answer for a target that is not a tier of the named ladder, so every target that clears the member gate gets it: "Pay the unpaid invoice before changing this plan.", or "A paused plan cannot be changed." when the subscription is `paused`. The answer SHALL be chosen from the organization's position, which the offer decision reads (`member-offer`). `fulfillment.SwitchPlan` SHALL also refuse a suspended subscription on its own, directly after resolving it, so a caller that reaches it without the endpoint's gate switches nothing.
The keep endpoint and the period-end cancellation SHALL proceed as for an active subscription: keeping releases the console's schedule and clears `cancel_at_period_end`, cancelling sets `cancel_at_period_end` and records the scheduled cancellation, and each ends with a reconcile. Three refusals apply, each before anything changes:
- the keep endpoint SHALL refuse a scheduled downgrade that Stripe has already applied, that is, when the subscription no longer carries the price that the schedule's `scheduled_from_price_id` names, with "That plan change has already taken effect.", releasing nothing and superseding nothing;
- the cancellation endpoint SHALL refuse a posted immediate cancellation of a suspended subscription, with "A plan with an unpaid invoice cannot end immediately.", before any Stripe call;
- the switch, keep and cancellation endpoints SHALL refuse a paused subscription with "A paused plan cannot be changed.", before any Stripe call.
The member surfaces SHALL offer no immediate cancellation, as for an active subscription.
#### Scenario: An upgrade is refused while the payment is due
- **WHEN** a member whose subscription to Plus is `past_due` posts a switch to Pro
- **THEN** the request is refused with "Pay the unpaid invoice before changing this plan.", no Stripe call is made, and nothing changes
#### Scenario: A downgrade is refused while the payment is due
- **WHEN** the same member posts a switch to Basic, a priced tier below Plus
- **THEN** the request is refused with the same sentence, no subscription schedule is made and no scheduled change is recorded
#### Scenario: The preview is refused while the payment is due
- **WHEN** the same member requests the switch preview for Pro
- **THEN** the system answers with the same sentence and does not call Stripe
#### Scenario: A paused plan's switch is refused
- **WHEN** a member whose subscription is `paused` posts a switch
- **THEN** the request is refused with "A paused plan cannot be changed." and no Stripe call is made
#### Scenario: A paused plan is refused keep and cancel
- **WHEN** a member whose subscription is `paused` posts the keep or the cancellation
- **THEN** the request is refused with "A paused plan cannot be changed." and no Stripe call is made
#### Scenario: The plan whose payment is due can be cancelled at the period end
- **WHEN** a member whose subscription is `past_due` cancels the plan
- **THEN** the system sets `cancel_at_period_end` on the Stripe subscription and records the scheduled cancellation, and the subscription's provisions stay suspended
#### Scenario: A scheduled cancellation of a plan whose payment is due can be taken back
- **WHEN** that member keeps the plan
- **THEN** Stripe's `cancel_at_period_end` is false and the cancellation row is superseded
#### Scenario: Keeping refuses a downgrade that already took effect
- **WHEN** a member whose subscription is `past_due` had a downgrade to Plus scheduled, the renewal has since moved the subscription to Plus's price, and the member posts the keep
- **THEN** the request is refused with "That plan change has already taken effect.", the schedule is not released, and the scheduled row is not superseded
#### Scenario: An immediate cancellation is refused while the payment is due
- **WHEN** a member whose subscription is `past_due` posts a cancellation with `timing=immediate`
- **THEN** the request is refused with "A plan with an unpaid invoice cannot end immediately.", no Stripe call is made, and the subscription is as it was
@@ -0,0 +1,154 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit (`internal/testkit/world`), filter to the rows their own fixture made, and pass twice in a row without a reset; tests meet the same complexity limit as production code, so a table runs its rows through a named function. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`. A test that asserts that no Stripe call was made compares the mock's recorded calls before and after the request. A test that asserts a log line reads the records of a captured `slog` handler and compares the level, the message and the named attributes, never the formatted text. A page's or a refusal's test compares the whole sentence with its entry in the design's Copy table.
Sections 1 to 7 are one commit each, and their items are that commit's parts; section 8 is not a commit. The sections are ordered so that each passes alone and each moves one reader of the position query (design D13): the test kit first, then the characterization and the widened read with every reader filtering, then fulfillment, so that the Products page never draws a Keep plan or Cancel plan its endpoint refuses, then the page, then the Pay invoice routes, so that the card never links to a route that does not exist, then the entitlements panel and the dashboard, then the operator page. Every commit passes `make test` with the test stack up, which runs `make lint` first (including `filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode`) and then every package one at a time (`-p 1`). A query edit is followed by `make sqlc-generate`. No golden, route table or test result changes except where a section's items name it; any other change is a stop. `internal/server/testdata/routes.golden`, `routes-not-ready.golden` and `route_pages.golden` change only in section 5. The copy is the design's Copy table, approved by the maintainer before section 1 starts; a string that differs from its approved entry is a stop. Sections 4 to 7 change templates, so each runs `make screens` after its tests pass; the expected list is no changed screen, because the demo seed holds no suspended subscription and maps none of its invoices to Stripe, and a changed screen is a stop to report, never a baseline this change accepts. No section adds a goroutine, a lock or state shared between requests, so no section runs `go test -race`. A script's usage is learned from its header or source, never by running it. Between sections 4 and 6 the Products page shows the plan as current with "payment due" while its own entitlements region still reads "No active plan"; the commits are local, so this is known and needs no action.
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, from the repository root, after `test/reset-test-db.sh` and with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run; the tests are new or rewritten in the section, so there is no old side. Every record's `want_new` is a text the failing test prints only when it fails, the message of the assertion that the break trips, never a test or subtest name, which `go test` prints on a pass as well; for a golden it is `<path> differs from what the code produced`, which `internal/testkit/golden/golden.go:42` prints only on a mismatch, with the path as the test passes it (`testdata/member_plans/paid-pro-past-due.html`). A break of a sqlc query edits the generated `.sql.go` file beside the querier (`internal/entitlements/pool_provision_ladders.sql.go`, `pool_provisions.sql.go`), whose constant the test runs, and not the `.sql` file. Every record must be CAUGHT. An `old` text below is the code as the section's tasks write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped. A break that no test of its own section can see is named with the reason, and the commit message says so. The commit message lists every break with its result.
## 1. The test kit writes the subscription's status with the suspension
- [x] 1.1 `internal/testkit/world/plans.go` (design D12): `Suspend(sub Subscription, status string)` fails the test through `w.TB.Fatalf` unless `status` is `past_due`, `unpaid` or `paused`. Inside the transaction it already opens, before `SyncSourceStatus`, it reads the subscription row and writes it back through `billing.UpdateSubscriptionStatus` with `status` and the row's own period, cancellation, cancelled and ended fields. Its doc comment says that it writes the pair the subscription reconcile writes for a suspending status (the subscription's status, then the provisions' suspension and the pool's materialization), and no longer that it writes a state the app does not produce.
- [x] 1.2 The three callers pass `"past_due"`: `internal/server/operator_enrollment_golden_db_test.go:216`, `internal/server/plan_acts_golden_db_test.go:871` and `internal/server/operator_plan_ladder_delete_guard_test.go:242`. The last two read no subscription status and keep their results. `TestOrgEnrollmentGolden`'s `suspended-subscription` state changes on purpose: `testdata/org_enrollment/suspended-subscription.json` reads `"SubscriptionState": "past_due"` in place of `"active"` (its note stays `renews Jan 15, 2099` until item 7.3), and `suspended-subscription.html`'s Billing card reads the Past due badge in place of Active. Nothing else changes; the commit message names both files and why.
- [x] 1.3 Test. `TestSuspendWritesTheSubscriptionStatus` (a new `internal/testkit/world/plans_db_test.go`, a database test), one row per status through a named function: for `past_due`, `unpaid` and `paused`, a subscription to a product with a numeric rule, suspended with that status, reads that status on its row, keeps its `current_period_end` and `cancel_at_period_end`, its provisions read `suspended`, and the pool's limit for the rule's key is 0. The refusal of another status goes through `w.TB.Fatalf`, which no test exercises, because the kit has no fake `testing.TB`; the commit message says so.
- [x] 1.4 Breaks:
- `Suspend` without the status write (the `UpdateSubscriptionStatus` call removed): caught by `TestSuspendWritesTheSubscriptionStatus`, `want_new` `subscription status = "active", want "past_due"`;
- the status written with an empty period end (`CurrentPeriodEnd: sub.CurrentPeriodEnd` replaced by `CurrentPeriodEnd: sql.NullTime{}`): caught by the same test, `want_new` `Suspend changed the subscription's current_period_end`.
## 2. Characterize the suspended plan, then widen the read and filter at every reader
- [ ] 2.1 Characterization first (design D13; `docs/testing.md`, "Characterize before a refactor"), on a tree without items 2.2 to 2.5: `TestMemberPlansGolden`'s fixture (`internal/server/member_plans_golden_db_test.go`) gains the states `paid-pro-past-due` (Pro by subscription, period end as the other paid states use, then `Suspend(sub, "past_due")`) and `paid-pro-past-due-held-plus-below` (built as `paid-pro-held-plus-below`, with Plus issued marked to resume and Pro subscribed over it, then suspended `past_due`); `TestMemberEntitlementsGolden`'s fixture gains `past-due-plan` (a plan with a numeric and a boolean rule, subscribed, then suspended `past_due`); `TestOrgHasEntitlement` (`internal/server/dashboard_entitlement_db_test.go`) gains the row "a boolean-only plan whose payment is due", whose plan carries only a boolean rule, wanting `false`. Their goldens are written with `-update` on that tree and read as the defect does today: the ladder not held, Subscribe through Checkout on every priced tier, "No active plan" on the entitlements panel with the numeric row at 0. Then items 2.2 to 2.5 are applied and none of the goldens and no row changes. The commit message names the four as the characterization of the defect #148 reports.
- [ ] 2.2 `internal/entitlements/queries/pool_provision_ladders.sql` (design D1): `ListLadderPositionsByPool` reads `l.status IN ('active', 'suspended')`, selects `l.status AS position_status` and `s.status AS subscription_status`, and orders `(l.status = 'active') DESC, l.activated_at DESC, l.plan_ladder_id`. Its comment says that it returns every live position with the position's and the subscription's status, active rows first so that `PositionsByLadder` keeps an active row over a suspended one on a ladder (the exclusion constraint covers only active rows), and that each reader decides what a suspended position means to it; the sentence that `l.status = 'active'` is the only place "held" is decided goes. `make sqlc-generate`.
- [ ] 2.3 `internal/entitlements/positions.go`: `ExcludeSuspended(rows []ListLadderPositionsByPoolRow) []ListLadderPositionsByPoolRow` returns the rows whose `PositionStatus` is not `suspended`, in their order, keeping a row with an empty status as a defensive default (design D13; no test feeds a filtered reader such a row today). Its doc comment says that a reader that does not show a suspended position calls it. `PositionsByLadder`'s doc comment says that the query orders active rows first, then the newest attachment, so the row kept is the active one, and the newest among equals.
- [ ] 2.4 Every reader filters, so nothing changes: `positionLoader.positions` (`internal/server/plan_offer.go:67-71`, which R2 reads through), `ladderRows` (`internal/server/member_entitlements.go:299`), `orgHasEntitlement` (`internal/server/server.go:325`), `resolveLadderSubscription` (`internal/fulfillment/plan_change.go:719-723`), the Pools panel's loop (`internal/server/operator_enrollment.go:1350`) and `subscriptionHeldLadders` (`operator_enrollment.go:1795`) each pass the rows through `ExcludeSuspended` before they use them.
- [ ] 2.5 `docs/models/plan-ladders-transitions.md`, the "Member position" row: the query returns every live position with the position's and the subscription's status, active rows first, and "held" is what each reader makes of it, in place of "'held' in it means an active attachment".
- [ ] 2.6 Tests. `TestListLadderPositionsByPoolReadsLivePositions` (`internal/entitlements`, an external-package database test through the kit): an organization's default pool holds a grant-backed position on ladder A, conferred first, and a subscription's position on ladder B, conferred later and suspended with `Suspend(sub, "unpaid")`; the query returns both rows, A's first though it is older, A's with `position_status` `active` and a NULL `subscription_status`, B's with `suspended` and `unpaid`. `TestExcludeSuspendedKeepsEverythingElse` (`internal/entitlements/positions_test.go`): rows with the statuses `active`, `suspended` and `""` come back as the first and the third, in order. `TestASuspendedPositionDeliversNothing` (`internal/entitlements`, an external-package database test): a product with a numeric rule (limit 3) and a boolean rule, subscribed and suspended `past_due`: the pool's numeric entitlement row exists with limit 0 and the boolean is not granted; the widened query returns the position with `position_status` `suspended`; materializing the pool again leaves the limit at 0 and the boolean not granted.
- [ ] 2.7 Breaks:
- the query keeps `l.status = 'active'`: caught by `TestListLadderPositionsByPoolReadsLivePositions`, `want_new` `the suspended position on ladder B is missing`;
- the order without its active-first key: caught by the same test, `want_new` `the first row is ladder B's suspended position, want ladder A's active one`;
- `s.status AS subscription_status` replaced by `NULL::varchar AS subscription_status`: caught by the same test, `want_new` `subscription_status = NULL, want "unpaid"`;
- `ExcludeSuspended` keeping every row (its condition removed): caught by `TestExcludeSuspendedKeepsEverythingElse`, `want_new` `ExcludeSuspended kept the suspended row`;
- `ExcludeSuspended` keeping only `active` rows: caught by the same test, `want_new` `ExcludeSuspended dropped the row without a status`;
- `positionLoader.positions` without `ExcludeSuspended`: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due.html differs from what the code produced`;
- `ladderRows` without it: caught by `TestMemberEntitlementsGolden`, `want_new` `testdata/member_entitlements/past-due-plan.html differs from what the code produced`;
- `orgHasEntitlement` without it: caught by `TestOrgHasEntitlement`'s new row, `want_new` `orgHasEntitlement = true, want false`;
- the Pools panel's loop without it: caught by `TestOrgEnrollmentGolden`, `want_new` `testdata/org_enrollment/suspended-subscription.html differs from what the code produced`;
- `subscriptionHeldLadders` without it: caught by the same golden (the Issue grant form marks Small2);
- `resolveLadderSubscription` without it: caught by no test of this section, because no existing test acts on a suspended subscription; section 3 adds the tests of the behaviour that its removal gives, and the commit message says so;
- `TestASuspendedPositionDeliversNothing` proves a fact of code this change does not touch (materialization reads only active provisions); its break is `GetActivePoolProvisionsByPoolID`'s `status = 'active'` replaced by `status IN ('active', 'suspended')` in the generated `internal/entitlements/pool_provisions.sql.go`, caught by `want_new` `the suspended plan's limit is 3, want 0`.
## 3. Fulfillment acts on a suspended subscription, and refuses to switch it
- [ ] 3.1 `internal/fulfillment/plan_change.go` (design D5, D6): `ladderSubscription` gains `positionStatus string` and `subscriptionStatus string`, the row's `position_status` and `subscription_status`; `resolveLadderSubscription` no longer calls `ExcludeSuspended`, and its doc comment says that it resolves the subscription behind a live position, active or suspended.
- [ ] 3.2 `ErrSubscriptionSuspended`, with the text `switch: the subscription's position is suspended`, and a doc comment that says a suspended subscription is kept or cancelled and never switched, because a switch would invoice while the failed invoice is still open; `ErrSubscriptionPaused`, with the text `the subscription is paused`, whose doc comment says that a paused subscription is refused a switch, Keep and Cancel because what a cancellation does to one is unverified; `ErrScheduledMoveApplied`, with the text `keep: the scheduled move has already been applied`; and `ErrImmediateCancelSuspended`, with the text `cancel: a suspended subscription cannot be cancelled immediately`, whose doc comment says that Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices. `SwitchPlan` returns `ErrSubscriptionPaused` when the resolved `subscriptionStatus` is `paused` and `ErrSubscriptionSuspended` for any other suspended position, directly after `resolveLadderSubscription` (after `plan_change.go:162-165`), before it reads the target price, the mappings or the commitment, and its doc comment says so.
- [ ] 3.3 `HasActiveSubscriptionOnLadder` becomes `SubscriptionBacksLadder`, whose doc comment says it reports whether a subscription backs the organization's position on the ladder, active or suspended, and that the preview reads it after the offer decision, which refuses a suspended position first. The caller at `internal/server/member_plan_moves.go:87` and its comment change with it.
- [ ] 3.4 `KeepCurrentPlan` and `CancelSubscription` (design D6): both return `ErrSubscriptionPaused` for a paused subscription, directly after resolving it. `KeepCurrentPlan` on a suspended subscription reads the subscription and, when it no longer carries the price that the schedule's `scheduled_from_price_id` names (the `findSpentSchedule` test, `reconcile.go:891-911`, called through a function both share), returns `ErrScheduledMoveApplied` before it releases a schedule or supersedes a row. `CancelSubscription` returns `ErrImmediateCancelSuspended` for `CancelImmediate` on a suspended subscription, before it releases a schedule or makes a Stripe call. Otherwise both are unchanged; their doc comments say that they act on a suspended subscription as on an active one, that the reconcile after them keeps its provisions suspended, and name the three refusals.
- [ ] 3.5 Tests (`internal/fulfillment`, Stripe-mocked database tests over the reconcile fixture of `plan_switch_timing_test.go`): a helper builds `onTier2`'s state, installs `stripeLike(mock)`, sets the mock's subscription to `Status: past_due` (or `paused` where a row says so), reconciles again, and checks that the subscription row reads that status and its provisions `suspended`, the pair the reconcile writes. `TestSwitchRefusesASuspendedSubscription`, one row per direction through a named function (an upgrade to a third tier `addTier` adds; a downgrade to tier 1): `SwitchPlan` returns an error that `errors.Is` `ErrSubscriptionSuspended`, the mock records no call during it, and no `subscription_scheduled_changes` row is written. `TestCancelASuspendedSubscriptionAtPeriodEnd`: `CancelSubscription` at the period end returns nil, the mock received `POST /v1/subscriptions/{id}` with `cancel_at_period_end=true`, one scheduled cancellation row reads `scheduled`, and the provisions still read `suspended`. `TestKeepASuspendedSubscription`: after that cancellation, `KeepCurrentPlan` returns nil, the mock received `cancel_at_period_end=false`, and the cancellation row reads `superseded`; a failure reads `KeepCurrentPlan on a suspended subscription returned <err>, want nil`. `TestKeepRefusesADowngradeThatLanded`: a suspended subscription with a downgrade scheduled whose schedule carries `scheduled_from_price_id` and whose mock subscription now carries the lower tier's price: `KeepCurrentPlan` returns an error that `errors.Is` `ErrScheduledMoveApplied`, the mock records no write (the read of the subscription is the only call), and the `plan_switch` row still reads `scheduled`; a failure reads `KeepCurrentPlan on a landed downgrade returned <err>, want ErrScheduledMoveApplied`. `TestCancelImmediatelyRefusesASuspendedSubscription`: `CancelSubscription` with `CancelImmediate` returns an error that `errors.Is` `ErrImmediateCancelSuspended`, the mock records no call and no row is written; a failure reads `CancelSubscription(immediate) on a suspended subscription returned <err>, want ErrImmediateCancelSuspended`. `TestPausedSubscriptionRefusesEveryAct`, one row per act through a named function (`SwitchPlan`, `KeepCurrentPlan`, `CancelSubscription`), over a subscription the mock holds as `paused`: each returns an error that `errors.Is` `ErrSubscriptionPaused` and the mock records no call; a failure reads `<act> on a paused subscription returned <err>, want ErrSubscriptionPaused`. `TestSubscriptionBacksASuspendedLadder`: true for the suspended fixture's organization and ladder.
- [ ] 3.6 Breaks:
- `resolveLadderSubscription` keeps `ExcludeSuspended` (section 2's unseen break): caught by `TestCancelASuspendedSubscriptionAtPeriodEnd`, `want_new` `CancelSubscription on a suspended subscription returned no active paid subscription on this ladder, want nil`, and by `TestSubscriptionBacksASuspendedLadder`, `want_new` `SubscriptionBacksLadder = false for a suspended position`;
- `SwitchPlan` without its refusal: caught by `TestSwitchRefusesASuspendedSubscription`, `want_new` `SwitchPlan on a suspended subscription returned <nil>, want ErrSubscriptionSuspended`;
- the refusal moved into `downgradeAtRenewal`, after its first Stripe call: caught by the same test's downgrade row, `want_new` `the switch called Stripe:`;
- `positionStatus` never set (the assignment removed): caught by the same test, `want_new` `want ErrSubscriptionSuspended`;
- the suspended refusal placed in `resolveLadderSubscription` instead of `SwitchPlan`: caught by `TestKeepASuspendedSubscription`, `want_new` `KeepCurrentPlan on a suspended subscription returned`;
- `KeepCurrentPlan` without its landed-downgrade check: caught by `TestKeepRefusesADowngradeThatLanded`, `want_new` `KeepCurrentPlan on a landed downgrade returned <nil>, want ErrScheduledMoveApplied`;
- the immediate branch without the check: caught by `TestCancelImmediatelyRefusesASuspendedSubscription`, `want_new` `CancelSubscription(immediate) on a suspended subscription returned <nil>, want ErrImmediateCancelSuspended`;
- the immediate check placed after the schedule release: caught by the same test, `want_new` `the cancellation called Stripe:`;
- `SwitchPlan`, `KeepCurrentPlan` or `CancelSubscription` without the paused refusal (three records): caught by `TestPausedSubscriptionRefusesEveryAct`'s row for that act, `want_new` `on a paused subscription returned`;
- `ErrSubscriptionSuspended` returned for a paused subscription in `SwitchPlan`: caught by the same test's switch row, `want_new` `want ErrSubscriptionPaused`.
## 4. The decision and the Products page hold the suspended plan
- [ ] 4.1 `internal/plans/offer.go` (design D3): `Position` gains `Suspended bool` and `Paused bool` (`Paused` implies `Suspended`), with a doc comment that says what sets them; the reasons `Suspended Reason = "the position is suspended"` and `Paused Reason = "the position is paused"`; the new rule 14, applied in `route` before today's switch branch (now rule 15): a suspended position's switch is `Move{Reason: Suspended}` with its relation, and a paused position's switch is `Move{Reason: Paused}`; for a paused position rule 14 also turns the Cancel answers of rules 7 and 13 into `Move{Reason: Paused}`. `Offer`'s doc comment lists the fifteen rules and says why rules 7 and 13 stay above rule 14 for a suspended position and are overridden by it for a paused one.
- [ ] 4.2 `internal/server/plan_offer.go` (design D2, D7): `onLadder` sets `pos.Suspended` from the row's `position_status` and `pos.Paused` when the row's `subscription_status` is `paused`; for a suspended position whose scheduled downgrade has a `DowngradeAt` that is not after the handler's now, it leaves the downgrade unset (`ScheduledTo` and the facts' name and date), because Stripe has already applied it; `ladderFacts` gains `Suspension`, `paused` when the subscription's status is `paused` and `payment_due` for every other status of a suspended position; `positions` no longer calls `ExcludeSuspended`.
- [ ] 4.3 `switchOffer` (design D5): after the `NotOnLadder` case and before `!pos.Subscribed`, a suspended position returns `fulfillment.ErrSubscriptionPaused` when its facts read paused, and `fulfillment.ErrSubscriptionSuspended` otherwise, logging copy row 16 at Info with `ladder_id` and `subscription_status`; its doc comment lists the order, with the member gate and the malformed or unknown price id answering before it. The server's own `errPlanPaused` does not exist. `renderPlansAfterMove` renders `ErrSubscriptionSuspended` as copy row 7, `ErrSubscriptionPaused` as copy row 8, `ErrScheduledMoveApplied` as copy row 17 and `ErrImmediateCancelSuspended` as copy row 18.
- [ ] 4.4 `internal/server/member_plans.go` (design D6, D7): `TierViewModel` gains `Suspension string`, which `setCurrent` copies from the facts, and `setCurrent` sets neither `CanKeep` nor `CanCancel` when it reads `paused`; `setCancelBodies`, when the ladder's current tier reads `payment_due`, builds the body of every control on the ladder from copy rows 19 to 22 in place of "You keep {current} until {date}" (row 19 when no tier follows, row 20 when one lower plan follows, row 21 for every other prediction with the existing clause words after "then", row 22 when there is no prediction or no date) and appends copy row 6 after a space; a paused ladder gets no body; `LadderViewModel.CancelBadge` (`internal/server/anatomy.go:1490-1492`) leaves `Title` empty when the ladder's current tier carries a `Suspension`.
- [ ] 4.5 `internal/embeds/templates/partials/member_plans.html`: on the current card, when `Suspension` is set and neither `MovesTo` nor `EndsOn` is, the price line reads copy row 1 or 2 in place of the renewal line; under the price line, when `Suspension` is set, the fact line (copy row 3 or 4) renders as `<p class="small text-danger mb-2">`; a paused card draws no control (the view model sets none). The template comment above the price lines says so.
- [ ] 4.6 Docs (design D15): `docs/stripe.md`, "Plan changes", a bullet for a plan whose payment is due (it stays on its card with its state; no switch; Keep plan and Cancel plan at the period end work, a posted immediate cancellation and a Keep plan over a downgrade Stripe already applied are refused; the unpaid invoice stays due; a paused plan shows its state and is refused every act); `docs/models/payments-billing.md`, "Status classification", says that the member surfaces show a suspended plan as the current plan with its state; `docs/models/plan-ladders-transitions.md`, the "Member position" row, says that the Products page, Checkout and the switch hold a suspended position as the current plan.
- [ ] 4.7 Tests. `internal/plans/offer_test.go`: the table's runner moves out of its inline closure (`offer_test.go:183-190`, which prints `Offer = %+v, want %+v`) into a named function that prints `Offer(%s) = %+v, want %+v` with the row's name, so that the rule-14 breaks below can match their text (an existing test changes, and the commit message names it); rows through it, over a ladder Free (rank 0, no price), Basic, Plus, Pro and Max, with Pro current, subscribed and suspended: "suspended, a tier above" (Max) and "suspended, a priced tier below" (Plus) answer none with `Suspended`; "suspended, the free rung that follows" (Free, with a prediction that puts Free on the ladder) answers Cancel, enabled; "suspended, the current tier" (Pro) answers none with `Current`; on the same position with Plus as the held rung, "suspended, the held rung below" (Plus) answers Cancel, enabled, held; and each of these rows with `Suspended` false answers as the decision answers today (a switch for Max and Plus); on the same ladder with Pro current, subscribed and paused, "paused, a tier above" (Max), "paused, the held rung below" (Plus held) and "paused, the free rung that follows" (Free) each answer none with `Paused`. `TestMemberPlansGolden`: `paid-pro-past-due` and `paid-pro-past-due-held-plus-below` change on purpose (the ladder held, Pro's card with copy rows 1 and 3 and Cancel plan whose body is copy row 19 followed by copy row 6, no Upgrade, and in the second Plus's "Granted" with its Downgrade, and Pro's Cancel plan, carrying the body of copy row 20 followed by copy row 6); new states `paid-pro-unpaid`, `paid-pro-paused` (copy rows 2 and 4, and no Pay invoice, no Keep plan and no Cancel plan), `paid-pro-past-due-cancellation-scheduled` (`Subscribe`, `CancelAtPeriodEnd`, `ScheduleCancellation`, then `Suspend`: "ends" with the fact line, Keep plan alone, and the heading's badge without a `title`) and `paid-pro-past-due-downgrade-landed` (`Subscribe`, a downgrade to Plus scheduled with its date in the past, then `Suspend`: the price line of copy row 1, the fact line, no "moves to" line, no Keep plan, and Cancel plan whose body is copy row 19 followed by copy row 6). Every JSON golden under `internal/server/testdata/member_plans/` gains `"Suspension"` on every tier: the thirteen existing states (`granted-plus`, `nothing-held`, `paid-basic-held-standard-above`, `paid-basic-plus-on-two-ladders`, `paid-plus-on-default-ladder`, `paid-plus-period-unknown`, `paid-pro-block-commitment`, `paid-pro-cancellation-flag-and-row`, `paid-pro-cancellation-row`, `paid-pro-downgrade-scheduled`, `paid-pro-held-plus-below`, `retired-current-rung`, `unprojected-subscription`) with `""` and no other change, which the commit message says. `TestPlansHoldASuspendedPlan` (`member_plans_offer_db_test.go`): through `buildPlansData`, Pro's tier is current with `Suspension` `payment_due` and `CanCancel`, and no tier of the ladder carries `MoveKind` `checkout` or `switch` (a failure names the tier: `tier <name> offers a switch on a suspended plan`, or `offers Checkout`); for a paused subscription, Pro's tier carries neither `CanKeep` nor `CanCancel` and no tier carries `MoveKind` `cancel` (`tier <name> offers a move on a paused plan`). `TestCheckoutRefusesALadderWhosePlanIsSuspended` (`billing_checkout_offer_db_test.go`), one row per target through a named function, the organization built through the kit and suspended `past_due`: a tier above and a tier below answer 422 with the plan sentence and the mock records no call (a failure reads `Checkout of a tier on a suspended plan's ladder was not refused`); a tier of a second ladder the organization holds nothing on passes; a product that is a tier of both ladders answers 422. `TestSwitchRefusesASuspendedPlan` (`member_switch_offer_db_test.go`), one row per request through a named function: a switch up, a switch down, a switch to the current tier, and the preview of the switch up each render the plans partial with copy row 7 as its error, and the mock records no call; the same switch up on a `paused` subscription renders copy row 8; the refusal's captured log record is copy row 16 with `ladder_id` and `subscription_status`. `TestPlanActsRefuseWhatFulfillmentRefuses` (the same file), one row per request through a named function: Keep plan and Cancel plan on a paused plan render copy row 8; Keep plan on a payment-due plan whose downgrade has landed renders copy row 17; a posted `timing=immediate` on a payment-due plan renders copy row 18; each renders the plans partial with the sentence as its error and the mock records no write.
- [ ] 4.8 `make screens`: no changed screen expected (the preamble's reason).
- [ ] 4.9 Breaks:
- rule 14 removed: caught by `offer_test.go`, `want_new` `Offer(suspended, a tier above) =`, and by `TestPlansHoldASuspendedPlan`, `want_new` `offers a switch on a suspended plan`;
- rule 14 applied before rule 7 (the held rung): caught by `offer_test.go`, `want_new` `Offer(suspended, the held rung below) =`;
- rule 14 applied before rule 13 (the free rung): caught by `offer_test.go`, `want_new` `Offer(suspended, the free rung that follows) =`;
- `onLadder` without `pos.Suspended`: caught by `TestPlansHoldASuspendedPlan`, `want_new` `offers a switch on a suspended plan`;
- `positions` keeps `ExcludeSuspended`: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due.html differs from what the code produced`, and by `TestCheckoutRefusesALadderWhosePlanIsSuspended`, `want_new` `Checkout of a tier on a suspended plan's ladder was not refused`;
- `switchOffer` without the suspended case: caught by `TestSwitchRefusesASuspendedPlan`, `want_new` `error = "That plan is no longer available. Refresh the page and choose a current plan.", want "Pay the unpaid invoice before changing this plan."`;
- the suspended case placed after the current-tier case: caught by the current-tier row, `want_new` `error = "You're already on that plan.", want "Pay the unpaid invoice before changing this plan."`;
- `ErrSubscriptionPaused` never returned by `switchOffer`: caught by the paused row, `want_new` `want "A paused plan cannot be changed."`;
- `renderPlansAfterMove` without its four cases (rows 7, 8, 17 and 18): caught by the switch-up row, `want_new` `error = "We couldn't complete that change. Try again in a moment."`;
- `Suspension` always `payment_due` (the paused branch removed): caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-paused.html differs from what the code produced`;
- the payment-due price line in place of "ends": caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-cancellation-scheduled.html differs from what the code produced`;
- `setCancelBodies` without the appended sentence: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due.html differs from what the code produced`;
- the sentence appended on the held rung's control only when it is the current card's (the loop's condition narrowed to `CanCancel`): caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-held-plus-below.html differs from what the code produced`;
- Cancel plan drawn on a paused plan (`CanCancel` set for `paused`): caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-paused.html differs from what the code produced`;
- rule 14 without its `Paused` answer for the cancellation moves: caught by `offer_test.go`, `want_new` `Offer(paused, the held rung below) =`;
- `Suspension` set to `payment_due` only for `past_due` (the `unpaid` status read as an unknown one): caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-unpaid.html differs from what the code produced`;
- `onLadder` keeping a downgrade whose date is not after now (the date check removed): caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-downgrade-landed.html differs from what the code produced`;
- `setCancelBodies` keeping "You keep {current} until {date}" on a payment-due plan: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due.html differs from what the code produced`;
- `setCancelBodies` choosing the plain end when one lower plan follows: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-held-plus-below.html differs from what the code produced`;
- `CancelBadge` keeps its `Title` on a suspended plan: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-cancellation-scheduled.html differs from what the code produced`;
- the refusal's log line without `subscription_status`: caught by `TestSwitchRefusesASuspendedPlan`'s log assertion, `want_new` `the refusal's log record has no subscription_status`.
## 5. Pay invoice
- [ ] 5.1 `internal/server/member_hosted_invoice.go` (new, design D8), methods on `MemberInvoicesHandler`, which gains `EntitlementsQ` (and `MemberInvoicesConfig` with it): `memberInvoice(ctx, orgID, invoiceID)` is the gate `GetInvoiceDetail` applies today (`member_invoices.go:293-312`), and `GetInvoiceDetail` calls it, its answers unchanged; `GetHostedInvoice` answers `GET /billing/invoices/{invoiceID}/hosted` and `GetPlanUnpaidInvoice` answers `GET /billing/plans/{ladderID}/unpaid-invoice`, each exactly as design D8 says (the by-plan route picks its row through `entitlements.PositionsByLadder`, and answers 303 to `/billing` with no Stripe call for no Stripe querier, no mapping row for the subscription, or a mapping the key cannot reach), with `Cache-Control: no-store` set by the handler on every answer, though the middleware sets it on every response too (design D8), the Stripe read through `invoice.Get` and `subscription.Get` with `expand[]=latest_invoice`, the environment check through `MappingReachable` under `StripeMode`, and copy row 15 at Warn for a failed read. Neither writes the URL to a log line, and their doc comments say why (the URL is a bearer link).
- [ ] 5.2 `internal/server/server.go`: the two routes are registered beside `GET /billing`, behind the same sign-in requirement; the handler's configuration carries `cfg.EntitlementsQ`.
- [ ] 5.3 `member_plans.html` and `member_plans.go` (design D7, D8): on the current card whose `Suspension` is `payment_due`, the first control is `<a href="/billing/plans/{{ .LadderID }}/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>` (copy row 5), carrying `aria-describedby` for the billing line when it is shown; Keep plan and Cancel plan follow it with `mt-2`. `plansOfferPaidMove` (`member_plans.go:543-552`) counts a current tier whose `Suspension` is `payment_due`, and its doc comment says why.
- [ ] 5.4 The invoice detail: `MemberInvoiceDetailData` gains `PayPath`, set to `/billing/invoices/{id}/hosted` when the invoice's status is `open` and the mapping read at `member_invoices.go:331-335` exists and is reachable under `StripeMode`; `member_invoice_detail.html` renders it under the amounts as the link of copy row 11 with the same `target`, `rel`, `hx-boost` and classes.
- [ ] 5.5 `test/e2e/screens/manifest.go`: a Skip entry for each route, reason `redirects to Stripe's hosted invoice page, or to /billing; not a page`, so `screen-coverage` passes.
- [ ] 5.6 `docs/stripe.md`, "Plan changes": one sentence that Pay invoice opens Stripe's hosted invoice page, which the console reads when the member opens the link and never stores, and that it needs no key permission the console does not already use (it reads subscriptions and invoices).
- [ ] 5.7 Tests. `TestHostedInvoiceRoutes` (`internal/server`, a Stripe-mocked database test, built through the kit: an organization with a billing account, a subscription mapped with `MapSubscriptionIn`, invoices mapped with `MapInvoiceIn`, the mock's `Invoices` holding each Stripe invoice with its `status` and a `hosted_invoice_url` unique to the test, and the mock's subscription carrying its `latest_invoice`), one row per request through a named function, signed in through `authtest`: by invoice, an open invoice answers 303 with `Location` its URL and `Cache-Control: no-store`; a paid invoice answers 303 to its URL; another organization's invoice and an unknown id answer 404 with no Stripe call; an invoice mapped in the other environment and an unmapped one answer 303 to `/billing` with no Stripe call; an invoice the mock answers with an error (`ObjectErrs`) answers 303 to `/billing` and logs copy row 15; an invoice with no URL answers 303 to `/billing`. By plan, for an organization whose subscription is suspended `past_due`: an open latest invoice answers 303 to its URL; a paid latest invoice answers 303 to `/billing`; a ladder on which the organization holds no subscription-backed position, and an id that is not a UUID, answer 404 with no Stripe call; a subscription with no mapping row and a subscription mapped in the other environment each answer 303 to `/billing` with no Stripe call; a failed read answers 303 to `/billing`. The open-latest-invoice row also checks that the recorded `GET /v1/subscriptions/{id}` call carries the expand of `latest_invoice`, in the form the mock records the call, because the mock answers the whole subscription for any path and live Stripe would return the bare invoice id without a URL (a failure reads `the subscription read carried no expand of latest_invoice`). The tests call the handlers directly through `authtest`, not through the middleware stack, so that removing a handler's own `Cache-Control` line is caught. Every row checks that no captured log record carries the URL, in its message or any attribute. `TestInvoiceDetailOffersPayInvoice` (a database test), one row per invoice: open and mapped in the key's environment shows the link of copy row 11 to its hosted route; paid, open and mapped in the other environment, and open and unmapped show none. `TestMemberPlansGolden`'s HTML goldens `paid-pro-past-due`, `paid-pro-past-due-held-plus-below`, `paid-pro-unpaid` and `paid-pro-past-due-cancellation-scheduled` change on purpose (the Pay invoice link first); `paid-pro-paused` and every other state do not, nor does any JSON golden. The billing line's test in `internal/server/member_products_billing_test.go` gains a row: a plans region whose only control that costs money is a suspended plan's Pay invoice renders the paragraph, and Pay invoice references it. The route tables change on purpose: `routes.golden` and `routes-not-ready.golden` gain a row per route (anonymous: 302 to `/login`), and `route_pages.golden` a row per route (the member's probe with the zero UUID answers 404); the commit message lists the rows.
- [ ] 5.8 `make screens`: no changed screen expected; the invoice detail is a partial no screen captures, and the demo seed's invoices carry no Stripe mapping.
- [ ] 5.9 Breaks:
- the by-invoice route without the ownership gate: caught by `TestHostedInvoiceRoutes`'s other-organization row, `want_new` `status = 303, want 404`;
- `GetInvoiceDetail` and the route calling one gate function is a fact of the code's structure that no request can tell from two copies answering alike; it is read in review, the commit message says so, and no break is listed;
- the environment check removed from the by-invoice route: caught by its other-environment row, `want_new` `the route called Stripe for an invoice the key cannot reach`;
- the by-invoice route redirecting only an open invoice: caught by the paid row, `want_new` `Location = "/billing", want the paid invoice's hosted URL`;
- the by-plan route redirecting a paid latest invoice: caught by its paid row, `want_new` `Location is the paid invoice's hosted URL, want "/billing"`;
- the by-plan route without its environment check: caught by its other-environment row, `want_new` `the route called Stripe for a subscription the key cannot reach`;
- the by-plan read without `expand[]=latest_invoice`: caught by the open-latest-invoice row, `want_new` `the subscription read carried no expand of latest_invoice`;
- the by-plan route without its ownership answer (a missing position answered 303 to `/billing`): caught by its no-position row, `want_new` `status = 303, want 404`;
- the handler's `Cache-Control: no-store` removed (caught only because the tests call the handler directly, not through the middleware that sets it too): caught by the open-invoice row, `want_new` `Cache-Control = "", want "no-store"`;
- the URL logged on success (an Info line with `slog.String("url", inv.HostedInvoiceURL)` added): caught by the open-invoice row, `want_new` `a log record carries the hosted URL`;
- the failed read without its log line: caught by the error row, `want_new` `no record of copy row 15 was logged`;
- the card without Pay invoice: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due.html differs from what the code produced`;
- Pay invoice on a paused plan: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-paused.html differs from what the code produced`;
- `plansOfferPaidMove` not counting Pay invoice: caught by the billing line's new row, `want_new` `the billing paragraph did not render`;
- `PayPath` set for an invoice in the other environment: caught by `TestInvoiceDetailOffersPayInvoice`'s other-environment row, `want_new` `the detail offers Pay invoice for an invoice the key cannot reach`;
- `PayPath` set for a paid invoice: caught by its paid row, `want_new` `the detail offers Pay invoice for a paid invoice`;
- a route not registered: caught by `TestNewHandlerRouteTable`, `want_new` `testdata/routes.golden differs from what the code produced`.
## 6. The entitlements panel and the dashboard count the suspended plan
- [ ] 6.1 `internal/server/member_entitlements.go` (design D10): `ladderRows` no longer calls `ExcludeSuspended` and yields one line per ladder by iterating the rows in query order and skipping a ladder already seen (the first row of a ladder is the active one, design D1), never by ranging the map `PositionsByLadder` returns, so that ladders sharing a `pos` (an unknown ladder, `member_entitlements.go:305-308`) keep a stable order, still ordered as the plans region orders its ladders; `PoolTierViewModel` gains `State` (`""`, `payment_due` or `paused`, chosen from the row's statuses as design D2 says) and a `StateBadge()` method returning copy row 9 in the danger tone or copy row 10 in the secondary tone. Its doc comment says so.
- [ ] 6.2 `internal/embeds/templates/partials/member_entitlements.html`: after `<strong>{{ .TierName }}</strong>`, `{{ if .State }} {{ template "statusBadge" .StateBadge }}{{ end }}`; the template comment names the badge.
- [ ] 6.3 `orgHasEntitlement` (`internal/server/server.go:310-329`) no longer calls `ExcludeSuspended`; its doc comment says that a suspended plan counts.
- [ ] 6.4 Tests. `TestMemberEntitlementsGolden`: `past-due-plan` changes on purpose (the plan's line with the Payment due badge, no "No active plan"); a new state `paused-plan` (the Paused badge); the JSON goldens `rung-without-entitlements`, `nothing-held` and `limits-booleans-sources-two-ladders` gain `"State": ""` on each line and no other change. `TestOrgHasEntitlement`'s row "a boolean-only plan whose payment is due" now wants `true`, on purpose.
- [ ] 6.5 `make screens`: no changed screen expected.
- [ ] 6.6 Breaks:
- `ladderRows` keeps `ExcludeSuspended`: caught by `TestMemberEntitlementsGolden`, `want_new` `testdata/member_entitlements/past-due-plan.html differs from what the code produced`;
- `State` always `payment_due`: caught by `TestMemberEntitlementsGolden`, `want_new` `testdata/member_entitlements/paused-plan.html differs from what the code produced`;
- the template without the badge: caught by the same golden as the first;
- `orgHasEntitlement` keeps `ExcludeSuspended`: caught by `TestOrgHasEntitlement`, `want_new` `orgHasEntitlement = false, want true`.
## 7. The operator's organization page, the Billing card, and the sandbox check
- [ ] 7.1 `internal/server/operator_enrollment.go` (design D11): the Pools panel's loop and `subscriptionHeldLadders` no longer call `ExcludeSuspended`; `PoolDeliveryViewModel` gains `Suspended bool`, set by `poolDeliveries` from the first row of the delivery, and a `StateBadge()` method returning the `suspended` badge; `operator_enrollment.html` renders it after `<strong>{{ .ProductName }}</strong>` when set. The doc comments of `poolDeliveries` and `subscriptionHeldLadders` say that a suspended position is listed and held.
- [ ] 7.2 `ExcludeSuspended` and `TestExcludeSuspendedKeepsEverythingElse` are deleted, since no reader calls it (`deadcode` would refuse it).
- [ ] 7.3 Quick fixes (proposal): `subscriptionStatePrecedence` becomes `unpaid`, `past_due`, `paused`, `canceling`, `active`, `trialing`, with its comment rewritten to say why; `subscriptionPeriodNote` returns `""` for `past_due`, `unpaid` and `paused`, and its doc comment says why. The Subscriptions row's badge for `unpaid` is drawn through a new badge key, `subscription_unpaid` (`internal/server/anatomy.go`, label `Unpaid`, tone `danger`), through a method on the billing summary that maps the state to its badge key, while the invoice's and the balance's `unpaid` keep their key and the warning tone; `TestStatusBadgeMapIsComplete` lists the new key.
- [ ] 7.4 Tests. `TestOrgEnrollmentGolden`: `suspended-subscription` changes on purpose (`.html`: the Pools panel shows Small2 with its rung pill and the Suspended badge and no "No plan is delivering into this pool", the Issue grant select marks Small2 and the form shows the supersession warning, and the Billing card has no "renews" note; `.json`: the delivery, `HasAttachment` true, Small2's `SupersedesSubscription` true and `SubscriptionNote` `""`); the JSON goldens with deliveries, `rich`, `default-only`, `second-pool` and `rich-history-search`, gain `"Suspended": false` on each delivery and no other change. `TestSummarizeSubscriptions` (`internal/server/operator_enrollment_render_test.go:343-383`): the row "cancel at period end on a past-due subscription reads past due" now wants the note `""`, on purpose; new rows: unpaid outranks past due (`1 unpaid`), past due outranks paused (`1 past due`), paused outranks canceling (`1 paused`), one unpaid and one paused subscription each carry no note; the test's doc comment states the new precedence. A test on the summary's badge method: the state `unpaid` draws the key `subscription_unpaid` and the `danger` tone, every other state its own key, and the `unpaid` badge map entry keeps the `warning` tone.
- [ ] 7.5 `make screens`: no changed screen expected; the demo organization the operator sheet shows holds an active, canceling subscription, whose badge and note are unchanged.
- [ ] 7.6 Sandbox check (design, "What is unverified about Stripe"), on this worktree's stack with `stripe listen` forwarding to the console, the sandbox calls made with `--stripe-version 2025-02-24.acacia`: subscribe a member through Checkout with `4242 4242 4242 4242`; attach `pm_card_chargeCustomerFail` to the customer and set it as the subscription's `default_payment_method`; start a new period now (`--billing-cycle-anchor now --proration-behavior none`). Check S1 (the subscription reads `past_due`) and the Products page: the plan current with copy rows 1 and 3, Pay invoice and Cancel plan, no Subscribe, Upgrade or switch; the entitlements panel's badge; the operator's Pools panel badge and Billing card. Cancel plan and check S2 and copy rows 19 and 6 in its confirmation; Keep plan and check S3. Pay invoice: the new tab opens Stripe's page for the open invoice; pay with `4242 4242 4242 4242` and check S4 (the subscription `active`, the card renewing, the entitlements back) and S5 (the subscription's `default_payment_method` after paying). Check S8: create a schedule on the `past_due` subscription (`stripe subscription_schedules create --from-subscription <id>`, with the console's `scheduled_from_price_id` metadata set), press Keep plan, and record what Stripe answers. S9 and S10 need a test-clock customer; when none is used they are recorded as not settled. Capture the Products page and the open invoice's detail at desktop width and at 390-pixel mobile width (the emulated viewport, checked through `innerWidth`) for the maintainer. Record each answer in the commit message; a result that contradicts the design is a stop.
- [ ] 7.7 Breaks:
- the Pools panel's loop keeps `ExcludeSuspended`: caught by `TestOrgEnrollmentGolden`, `want_new` `testdata/org_enrollment/suspended-subscription.html differs from what the code produced`;
- `subscriptionHeldLadders` keeps it: caught by the same golden (no warning);
- `Suspended` never set: caught by the same golden (no badge);
- the precedence without `unpaid` ahead of `past_due`: caught by `TestSummarizeSubscriptions`'s new row, `want_new` `summarizeSubscriptions = (2, "past_due", "1 past due"), want (2, "unpaid", "1 unpaid")`;
- `paused` left out of the precedence: caught by the paused row, `want_new` `want (2, "paused", "1 paused")`;
- the Subscriptions row's `unpaid` badge drawn through the shared `unpaid` key: caught by the badge method's test, `want_new` `badge key = "unpaid", want "subscription_unpaid"`;
- the note kept for a suspending state: caught by the past-due row, `want_new` `summarizeSubscriptions = (1, "past_due", "renews Mar 1, 2026"), want (1, "past_due", "")`.
## 8. Landing
- [ ] 8.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate suspended-plans-stay-held --strict` passes.
- [ ] 8.2 Post a comment on #148 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 it. The comment states that a plan whose subscription's payment is due, or that is paused, now stays the organization's current plan on the Products page and the entitlements panel, with its state, while it delivers no entitlement; that no Subscribe or Upgrade is offered on its plan and Checkout refuses a second subscription there; that a plan change is refused until the invoice is paid, while Keep plan and Cancel plan at the period end work; that Pay invoice opens Stripe's page for the unpaid invoice, read when the member opens it and never stored, from the plan's card and from an open invoice's detail, and that the invoice route is the one #150 describes for its invoice number link; that the organization page now shows the suspended delivery, warns before a grant supersedes it, and ranks an unpaid subscription worst on the Billing card; and what the sandbox check found. It names the commits of sections 1 to 7.
- [ ] 8.3 Handed to the maintainer, none filed or posted without his go: the candidate issues in the proposal's "Out of scope" (a grace period for `past_due`, changing the card on file, with the sandbox's S5 answer), the candidate comment on #150 (`invoice.marked_uncollectible` is not handled; the by-invoice route exists for its number link), the add-on whose subscription is suspended, the collection stop when a period-end cancellation takes effect (with S9's and S10's results, or the statement that they are not settled), and the notes on a second Checkout while the first subscription is `incomplete` and on an earlier Checkout Session that has not expired.
- [ ] 8.4 The archive waits for the maintainer's go; the deltas carry every spec change, and no Purpose needs an edit a delta cannot make. Nothing in this change touches a production host.