Read suspended plan positions, and keep every reader's answer unchanged

Second section of #148. Goldens first pin what the member sees today for
a plan whose payment is due: the Products page treats the plan as not
held and offers Subscribe on every priced plan, the entitlements panel
reads "No active plan", and a plan of boolean entitlements reads as
none. Those are the wrong answers the later sections fix.

ListLadderPositionsByPool now reads live positions, active and
suspended, with the position's and the subscription's status, and puts
an active row before a suspended one on the same ladder so a suspended
row can never be chosen over an active one. Every reader filters
suspended rows out through one helper, entitlements.ExcludeSuspended:
the Products page's position loader, the entitlements panel, the
dashboard's entitlement check, the plan moves' subscription lookup, and
the operator's Pools panel and grant advisory. No existing answer,
golden or test result changes; each later section removes the filter
from the surfaces it releases. A test proves that a suspended position
confers nothing, because entitlements come only from active provisions.

The plan moves' lookup filters too, and no test of this section can see
it; its break runs with the section that releases those moves.

Also regenerated every sqlc package through make sqlc-generate, which
restores the license headers a bare sqlc generate drops: BRIDGEFILES
generated files that had lost their headers gain them back, with no
other change.

Full suite on reset databases before the review fixes: 4524 pass, 15 skip, none failed. The review fixes add a test arrangement and change comments and license headers; after them internal/entitlements, plans, fulfillment, server and the test kit pass. make lint passes. Breaks,
each caught:
- the position query keeps only active rows
- the position query without its active-first order
- the subscription status is NULL
- ExcludeSuspended keeps every row
- ExcludeSuspended keeps only active rows
- positionLoader.positions without ExcludeSuspended
- ladderRows without ExcludeSuspended
- orgHasEntitlement without ExcludeSuspended
- the Pools panel's loop without ExcludeSuspended
- subscriptionHeldLadders without ExcludeSuspended
- materialization reads suspended provisions
- the position query orders oldest first
This commit is contained in:
2026-10-24 02:17:28 -04:00
parent 979e38b985
commit 10057f8e3a
45 changed files with 2253 additions and 175 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Default restoration | Resolves the org type's default ladder to its rank-0 product and confers it — plus the floor guard that restores only when the pool has no live occupancy on any ladder | `internal/entitlements/reapply_defaults.go` |
| Stripe-driven convergence | Translates a subscription's remote state into confer / end / suspend-resume calls (see the Payments and Billing card) | `internal/fulfillment/reconcile.go` |
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it, and "held" in it means an active attachment | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it. 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 | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
| Operator ladder management | Ladder and tier CRUD, tier add, tier delete, tier removal and reorder. Each of the four tier changes is a plan act: one function in `internal/entitlements` runs it in one transaction under the exclusive materialization rendezvous and records it in the act record, a header and a detail row (the [Acts card](acts.md)). A reorder that gives a ladder a new rank-0 product also reaches every org type that defaults to the ladder: the act's listing then writes one obligation for each organization of those types that has a default pool and a record for each that has none, and the drain moves each pool onto the new rank-0 product (the Acts card describes both). The removal preview shows what happens to pools sitting on affected rank-0 tiers. Removing the last tier of a ladder an org type defaults to is refused on both paths, the delete of a tier no one holds (`DeleteTier`) and the removal of a held tier (`RemoveTier`), because both call `LastTierRefusal` and signups land on that ladder's entry tier | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go`, `internal/entitlements/tier_reorder.go`, `internal/entitlements/plan_act_runner.go` |
| Operator enrollment | Issue, revoke, and extend grants; revoke ends positions then restores the guarded default | `internal/server/operator_enrollment.go` |
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// The position read and the proof behind it (openspec change
// suspended-plans-stay-held): ListLadderPositionsByPool returns every live
// position with both statuses, active rows first, and a suspended position
// delivers nothing.
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 (
positionsSitesKey = "positions_suspend_sites"
positionsAccessKey = "positions_suspend_access"
)
func registerPositionKeys(t *testing.T, d *sql.DB) {
t.Helper()
for _, k := range []struct{ key, name, unit, kind string }{
{positionsSitesKey, "Position Sites", "site", "numeric"},
{positionsAccessKey, "Position Access", "access", "boolean"},
} {
if _, err := d.ExecContext(t.Context(), `INSERT INTO core.resource_keys (resource_key, display_name, unit, kind)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, k.key, k.name, k.unit, k.kind); err != nil {
t.Fatalf("register %s: %v", k.key, err)
}
}
}
func sitesLimit(n int64) entitlements.RuleFields {
return entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: positionsSitesKey, Valid: true},
ResourceValue: sql.NullInt64{Int64: n, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
}
}
func accessRule() entitlements.RuleFields {
return entitlements.RuleFields{RuleType: "boolean", ResourceKey: sql.NullString{String: positionsAccessKey, Valid: true}}
}
// nullText prints a NULL as NULL, so a failure names the column's state.
func nullText(s sql.NullString) string {
if !s.Valid {
return "NULL"
}
return `"` + s.String + `"`
}
// TestListLadderPositionsByPoolReadsLivePositions: an organization's pool
// holds a grant-backed position on ladder A, conferred first, and a
// subscription's position on ladder B, conferred later and suspended. The
// query returns both, A's first though it is older, with both statuses.
func TestListLadderPositionsByPoolReadsLivePositions(t *testing.T) {
d := dbtest.Open(t)
w := world.New(t, d)
granted, subscribed := w.Product("Granted"), w.Product("Subscribed")
w.Price(subscribed, 1200)
ladderA, ladderB := w.Ladder("A", granted), w.Ladder("B", subscribed)
o := w.Org("Positions", w.OrgType("pos"), w.Person("Ann"))
w.Issue(o, granted, world.IssueOpts{})
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: time.Date(2099, time.January, 15, 12, 0, 0, 0, time.UTC)}, subscribed)
w.Suspend(sub, "unpaid")
rows, err := entitlements.New(d).ListLadderPositionsByPool(t.Context(), o.PoolID)
if err != nil {
t.Fatalf("ListLadderPositionsByPool: %v", err)
}
byLadder := entitlements.PositionsByLadder(rows)
a, hasA := byLadder[ladderA.ID]
b, hasB := byLadder[ladderB.ID]
if !hasA {
t.Fatalf("the active position on ladder A is missing from %d rows", len(rows))
}
if !hasB {
t.Fatalf("the suspended position on ladder B is missing from %d rows", len(rows))
}
if rows[0].PlanLadderID != ladderA.ID {
t.Errorf("the first row is ladder B's suspended position, want ladder A's active one")
}
checkFirstRowActive(t, d, o.PoolID, ladderA.ID, ladderB.ID, "in the pool whose active position is older, the first row is ladder B's suspended position")
// The opposite arrangement: the suspended position is the older one, so
// the active-first key and oldest-first order disagree.
o2 := w.Org("PositionsReversed", w.OrgType("pos2"), w.Person("Bea"))
w.Suspend(w.Subscribe(o2, world.SubOpts{PeriodEnd: time.Date(2099, time.January, 15, 12, 0, 0, 0, time.UTC)}, subscribed), "unpaid")
w.Issue(o2, granted, world.IssueOpts{})
checkFirstRowActive(t, d, o2.PoolID, ladderA.ID, ladderB.ID, "in the pool whose active position is newer, the first row is ladder B's suspended position")
if a.PositionStatus != "active" {
t.Errorf("ladder A position_status = %q, want \"active\"", a.PositionStatus)
}
if a.SubscriptionStatus.Valid {
t.Errorf("ladder A subscription_status = %s, want NULL", nullText(a.SubscriptionStatus))
}
if b.PositionStatus != "suspended" {
t.Errorf("ladder B position_status = %q, want \"suspended\"", b.PositionStatus)
}
if !b.SubscriptionStatus.Valid || b.SubscriptionStatus.String != "unpaid" {
t.Errorf("subscription_status = %s, want \"unpaid\"", nullText(b.SubscriptionStatus))
}
}
// checkFirstRowActive fails with msg when the pool's first position row is not
// ladder A's active one.
func checkFirstRowActive(t *testing.T, d *sql.DB, poolID, ladderA, ladderB, msg string) {
t.Helper()
rows, err := entitlements.New(d).ListLadderPositionsByPool(t.Context(), poolID)
if err != nil {
t.Fatalf("ListLadderPositionsByPool: %v", err)
}
if len(rows) == 0 || rows[0].PlanLadderID != ladderA || rows[0].PositionStatus != "active" {
t.Errorf("%s, want ladder A's active one (ladder B is %s)", msg, ladderB)
}
}
// TestASuspendedPositionDeliversNothing: a plan with a numeric and a boolean
// rule, subscribed and suspended, keeps its numeric row at 0 and grants no
// boolean; the widened query returns the position, and materializing the pool
// again changes none of it.
func TestASuspendedPositionDeliversNothing(t *testing.T) {
d := dbtest.Open(t)
registerPositionKeys(t, d)
w := world.New(t, d)
p := w.Product("Delivering")
w.Rule(p, sitesLimit(3))
w.Rule(p, accessRule())
w.Price(p, 1200)
ladder := w.Ladder("Delivering", p)
o := w.Org("Suspended", w.OrgType("del"), w.Person("Ann"))
w.Suspend(w.Subscribe(o, world.SubOpts{}, p), "past_due")
checkNothingDelivered(t, d, o.PoolID, ladder.ID)
tx, err := entitlements.BeginMaterializing(t.Context(), d)
if err != nil {
t.Fatalf("begin materializing: %v", err)
}
defer func() { _ = tx.Rollback() }()
if err := entitlements.MaterializePoolEntitlements(t.Context(), entitlements.New(tx), o.PoolID); err != nil {
t.Fatalf("materialize the pool again: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
checkNothingDelivered(t, d, o.PoolID, ladder.ID)
}
func checkNothingDelivered(t *testing.T, d *sql.DB, poolID, ladderID string) {
t.Helper()
q := entitlements.New(d)
numeric, err := q.ListNumericEntitlementsByPoolID(t.Context(), poolID)
if err != nil {
t.Fatalf("ListNumericEntitlementsByPoolID: %v", err)
}
found := false
for _, n := range numeric {
if n.ResourceKey != positionsSitesKey {
continue
}
found = true
if n.ResourceLimit != 0 {
t.Errorf("the suspended plan's limit is %d, want 0", n.ResourceLimit)
}
}
if !found {
t.Errorf("the numeric entitlement row for %s is missing", positionsSitesKey)
}
booleans, err := q.ListBooleanEntitlementsByPoolID(t.Context(), poolID)
if err != nil {
t.Fatalf("ListBooleanEntitlementsByPoolID: %v", err)
}
for _, b := range booleans {
if b.ResourceKey == positionsAccessKey && b.Granted {
t.Errorf("the suspended plan grants %s, want it not granted", positionsAccessKey)
}
}
rows, err := q.ListLadderPositionsByPool(t.Context(), poolID)
if err != nil {
t.Fatalf("ListLadderPositionsByPool: %v", err)
}
got, ok := entitlements.PositionsByLadder(rows)[ladderID]
if !ok || got.PositionStatus != "suspended" {
t.Errorf("the suspended plan's position_status = %q (position present: %v), want \"suspended\"", got.PositionStatus, ok)
}
}
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -342,9 +345,11 @@ func (q *Queries) GetPlanPoolBucket(ctx context.Context, arg GetPlanPoolBucketPa
const listLadderPositionsByPool = `-- name: ListLadderPositionsByPool :many
SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at,
l.status AS position_status,
pd.name AS product_name,
p.subscription_id, p.grant_id, p.purchase_id,
s.subscription_id AS projected_subscription_id,
s.status AS subscription_status,
s.current_period_end, s.cancel_at_period_end,
s.commitment_end, s.early_termination_policy,
c.effective_at AS cancellation_at,
@@ -369,8 +374,8 @@ LEFT JOIN core.subscription_scheduled_changes sw
LEFT JOIN core.prices pr ON pr.price_id = sw.target_price_id
LEFT JOIN core.products tp ON tp.product_id = pr.product_id
WHERE l.pool_id = $1
AND l.status = 'active'
ORDER BY l.activated_at DESC, l.plan_ladder_id
AND l.status IN ('active', 'suspended')
ORDER BY (l.status = 'active') DESC, l.activated_at DESC, l.plan_ladder_id
`
type ListLadderPositionsByPoolRow struct {
@@ -379,11 +384,13 @@ type ListLadderPositionsByPoolRow struct {
ProductID string `json:"product_id"`
ProvisionID string `json:"provision_id"`
ActivatedAt time.Time `json:"activated_at"`
PositionStatus string `json:"position_status"`
ProductName string `json:"product_name"`
SubscriptionID uuid.NullUUID `json:"subscription_id"`
GrantID uuid.NullUUID `json:"grant_id"`
PurchaseID uuid.NullUUID `json:"purchase_id"`
ProjectedSubscriptionID uuid.NullUUID `json:"projected_subscription_id"`
SubscriptionStatus sql.NullString `json:"subscription_status"`
CurrentPeriodEnd sql.NullTime `json:"current_period_end"`
CancelAtPeriodEnd sql.NullBool `json:"cancel_at_period_end"`
CommitmentEnd sql.NullTime `json:"commitment_end"`
@@ -394,21 +401,22 @@ type ListLadderPositionsByPoolRow struct {
DowngradeProductName sql.NullString `json:"downgrade_product_name"`
}
// The position of a pool on every plan ladder it holds, newest attachment
// first, in one round trip. The Products page, Checkout and the switch read
// it through positionLoader (internal/server/plan_offer.go), and the switch
// confirmation's tier name through the same loader. The entitlements panel's
// ladder lines and the dashboard's entitlement check read it on the default
// pool they already hold, and fulfillment's plan changes find the rung they
// act on through it (resolveLadderSubscription,
// internal/fulfillment/plan_change.go). The organization page reads it for
// every pool of the organization, of any type and status: the Pools panel
// shows every row, and the Issue grant advisory keeps the rows a subscription
// holds.
// Every live position of a pool, in one round trip, with the position's status
// and the subscription's status. Active rows come first, then the newest
// attachment, so that PositionsByLadder keeps an active row over a suspended
// one on a ladder (the exclusion constraint covers only active rows). Each
// reader decides what a suspended position means to it. The Products page,
// Checkout and the switch read it through positionLoader
// (internal/server/plan_offer.go), and the switch confirmation's tier name
// through the same loader. The entitlements panel's ladder lines and the
// dashboard's entitlement check read it on the default pool they already hold,
// and fulfillment's plan changes find the rung they act on through it
// (resolveLadderSubscription, internal/fulfillment/plan_change.go). The
// organization page reads it for every pool of the organization, of any type
// and status: the Pools panel shows every row, and the Issue grant advisory
// keeps the rows a subscription holds.
//
// l.status = 'active' is the only place "held" is decided (#148 widens it to
// the live statuses). Nothing filters ended_at, the provision's status or the
// subscription's status, because no reader does.
// Nothing filters ended_at or the subscription's status, because no reader does.
//
// core.subscriptions is joined LEFT because pool_provisions.subscription_id
// carries no foreign key: a provision can name a subscription whose row is not
@@ -432,11 +440,13 @@ func (q *Queries) ListLadderPositionsByPool(ctx context.Context, poolID string)
&i.ProductID,
&i.ProvisionID,
&i.ActivatedAt,
&i.PositionStatus,
&i.ProductName,
&i.SubscriptionID,
&i.GrantID,
&i.PurchaseID,
&i.ProjectedSubscriptionID,
&i.SubscriptionStatus,
&i.CurrentPeriodEnd,
&i.CancelAtPeriodEnd,
&i.CommitmentEnd,
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
+20 -5
View File
@@ -4,11 +4,12 @@
package entitlements
// PositionsByLadder keys the rows of ListLadderPositionsByPool by plan ladder,
// keeping the first row of each. The query orders its rows newest attachment
// first, so the row kept is the newest. A pool holds one active attachment per
// ladder (the conferral functions supersede the one before), so the pick
// matters only to a pool that holds two, which the console cannot write; the
// newest is then the one fulfillment acts on.
// keeping the first row of each. The query orders active rows first, then the
// newest attachment, so the row kept is the active one, and the newest among
// equals. A pool holds one active attachment per ladder (the conferral
// functions supersede the one before), and core.confer never leaves an active
// and a suspended row on one ladder, so the pick matters only to a pool the
// console cannot write; fulfillment then acts on the row kept.
func PositionsByLadder(rows []ListLadderPositionsByPoolRow) map[string]ListLadderPositionsByPoolRow {
byLadder := make(map[string]ListLadderPositionsByPoolRow, len(rows))
for _, r := range rows {
@@ -19,3 +20,17 @@ func PositionsByLadder(rows []ListLadderPositionsByPoolRow) map[string]ListLadde
}
return byLadder
}
// ExcludeSuspended returns the rows whose PositionStatus is not "suspended",
// in their order. A row with an empty status stays, as a defensive default: a
// row built by hand carries no status. A reader that does not show a
// suspended position calls it before it uses the rows.
func ExcludeSuspended(rows []ListLadderPositionsByPoolRow) []ListLadderPositionsByPoolRow {
kept := make([]ListLadderPositionsByPoolRow, 0, len(rows))
for _, r := range rows {
if r.PositionStatus != "suspended" {
kept = append(kept, r)
}
}
return kept
}
+26 -1
View File
@@ -3,7 +3,10 @@
package entitlements
import "testing"
import (
"slices"
"testing"
)
func TestPositionsByLadderKeepsTheNewest(t *testing.T) {
rows := []ListLadderPositionsByPoolRow{
@@ -25,3 +28,25 @@ func TestPositionsByLadderKeepsTheNewest(t *testing.T) {
t.Error("no rows must hold nothing")
}
}
func TestExcludeSuspendedKeepsEverythingElse(t *testing.T) {
rows := []ListLadderPositionsByPoolRow{
{PlanLadderID: "wiki", PositionStatus: "active"},
{PlanLadderID: "duo", PositionStatus: "suspended"},
{PlanLadderID: "box", PositionStatus: ""},
}
got := ExcludeSuspended(rows)
var ladders []string
for _, r := range got {
if r.PositionStatus == "suspended" {
t.Error("ExcludeSuspended kept the suspended row")
}
ladders = append(ladders, r.PlanLadderID)
}
if !slices.Contains(ladders, "box") {
t.Error("ExcludeSuspended dropped the row without a status")
}
if !slices.Equal(ladders, []string{"wiki", "box"}) {
t.Errorf("ExcludeSuspended kept the ladders %v, want [wiki box]", ladders)
}
}
+18 -14
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -390,21 +393,22 @@ type Querier interface {
// One row per grant that passes the org and search filters, with its
// delivery state computed once; the outer SELECT filters on it.
ListGrantsWithDelivery(ctx context.Context, arg ListGrantsWithDeliveryParams) ([]ListGrantsWithDeliveryRow, error)
// The position of a pool on every plan ladder it holds, newest attachment
// first, in one round trip. The Products page, Checkout and the switch read
// it through positionLoader (internal/server/plan_offer.go), and the switch
// confirmation's tier name through the same loader. The entitlements panel's
// ladder lines and the dashboard's entitlement check read it on the default
// pool they already hold, and fulfillment's plan changes find the rung they
// act on through it (resolveLadderSubscription,
// internal/fulfillment/plan_change.go). The organization page reads it for
// every pool of the organization, of any type and status: the Pools panel
// shows every row, and the Issue grant advisory keeps the rows a subscription
// holds.
// Every live position of a pool, in one round trip, with the position's status
// and the subscription's status. Active rows come first, then the newest
// attachment, so that PositionsByLadder keeps an active row over a suspended
// one on a ladder (the exclusion constraint covers only active rows). Each
// reader decides what a suspended position means to it. The Products page,
// Checkout and the switch read it through positionLoader
// (internal/server/plan_offer.go), and the switch confirmation's tier name
// through the same loader. The entitlements panel's ladder lines and the
// dashboard's entitlement check read it on the default pool they already hold,
// and fulfillment's plan changes find the rung they act on through it
// (resolveLadderSubscription, internal/fulfillment/plan_change.go). The
// organization page reads it for every pool of the organization, of any type
// and status: the Pools panel shows every row, and the Issue grant advisory
// keeps the rows a subscription holds.
//
// l.status = 'active' is the only place "held" is decided (#148 widens it to
// the live statuses). Nothing filters ended_at, the provision's status or the
// subscription's status, because no reader does.
// Nothing filters ended_at or the subscription's status, because no reader does.
//
// core.subscriptions is joined LEFT because pool_provisions.subscription_id
// carries no foreign key: a provision can name a subscription whose row is not
@@ -185,21 +185,22 @@ WHERE plan_ladder_id = $1 AND status IN ('active', 'suspended')
GROUP BY product_id;
-- name: ListLadderPositionsByPool :many
-- The position of a pool on every plan ladder it holds, newest attachment
-- first, in one round trip. The Products page, Checkout and the switch read
-- it through positionLoader (internal/server/plan_offer.go), and the switch
-- confirmation's tier name through the same loader. The entitlements panel's
-- ladder lines and the dashboard's entitlement check read it on the default
-- pool they already hold, and fulfillment's plan changes find the rung they
-- act on through it (resolveLadderSubscription,
-- internal/fulfillment/plan_change.go). The organization page reads it for
-- every pool of the organization, of any type and status: the Pools panel
-- shows every row, and the Issue grant advisory keeps the rows a subscription
-- holds.
-- Every live position of a pool, in one round trip, with the position's status
-- and the subscription's status. Active rows come first, then the newest
-- attachment, so that PositionsByLadder keeps an active row over a suspended
-- one on a ladder (the exclusion constraint covers only active rows). Each
-- reader decides what a suspended position means to it. The Products page,
-- Checkout and the switch read it through positionLoader
-- (internal/server/plan_offer.go), and the switch confirmation's tier name
-- through the same loader. The entitlements panel's ladder lines and the
-- dashboard's entitlement check read it on the default pool they already hold,
-- and fulfillment's plan changes find the rung they act on through it
-- (resolveLadderSubscription, internal/fulfillment/plan_change.go). The
-- organization page reads it for every pool of the organization, of any type
-- and status: the Pools panel shows every row, and the Issue grant advisory
-- keeps the rows a subscription holds.
--
-- l.status = 'active' is the only place "held" is decided (#148 widens it to
-- the live statuses). Nothing filters ended_at, the provision's status or the
-- subscription's status, because no reader does.
-- Nothing filters ended_at or the subscription's status, because no reader does.
--
-- core.subscriptions is joined LEFT because pool_provisions.subscription_id
-- carries no foreign key: a provision can name a subscription whose row is not
@@ -209,9 +210,11 @@ GROUP BY product_id;
-- cancellation and the first scheduled plan switch with a target price are
-- the ones the page draws, with the change id as the tie-break.
SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at,
l.status AS position_status,
pd.name AS product_name,
p.subscription_id, p.grant_id, p.purchase_id,
s.subscription_id AS projected_subscription_id,
s.status AS subscription_status,
s.current_period_end, s.cancel_at_period_end,
s.commitment_end, s.early_termination_policy,
c.effective_at AS cancellation_at,
@@ -236,8 +239,8 @@ LEFT JOIN core.subscription_scheduled_changes sw
LEFT JOIN core.prices pr ON pr.price_id = sw.target_price_id
LEFT JOIN core.products tp ON tp.product_id = pr.product_id
WHERE l.pool_id = $1
AND l.status = 'active'
ORDER BY l.activated_at DESC, l.plan_ladder_id;
AND l.status IN ('active', 'suspended')
ORDER BY (l.status = 'active') DESC, l.activated_at DESC, l.plan_ladder_id;
-- name: ListTierAddCollisions :many
-- The pools among pool_ids (the pools the tier add has locked) that the add
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
+1 -1
View File
@@ -720,7 +720,7 @@ func resolveLadderSubscription(ctx context.Context, db *sql.DB, orgID, ladderID
if err != nil {
return ls, ErrNoActivePaidSubscription
}
pos, ok := entitlements.PositionsByLadder(rows)[ladderID]
pos, ok := entitlements.PositionsByLadder(entitlements.ExcludeSuspended(rows))[ladderID]
if !ok {
return ls, ErrNoActivePaidSubscription
}
@@ -25,7 +25,7 @@ func TestOrgHasEntitlement(t *testing.T) {
orgType := w.OrgType("dash")
owner := w.Person("Owner")
limit, access, rung := w.Product("Limit"), w.Product("Access"), w.Product("Rung")
limit, access, rung, due := w.Product("Limit"), w.Product("Access"), w.Product("Rung"), w.Product("Due")
w.Rule(limit, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: goldenSitesKey, Valid: true},
@@ -34,6 +34,9 @@ func TestOrgHasEntitlement(t *testing.T) {
})
w.Rule(access, entitlements.RuleFields{RuleType: "boolean", ResourceKey: sql.NullString{String: goldenForumKey, Valid: true}})
w.Ladder("Dash", rung)
w.Price(due, 1500)
w.Rule(due, entitlements.RuleFields{RuleType: "boolean", ResourceKey: sql.NullString{String: goldenPriorityKey, Valid: true}})
w.Ladder("Dash due", due)
q := entitlements.New(db)
for _, tc := range []struct {
@@ -71,6 +74,11 @@ func TestOrgHasEntitlement(t *testing.T) {
w.Issue(o, rung, world.IssueOpts{})
return o.ID
}, true},
{"a boolean-only plan whose payment is due", func(t *testing.T, w *world.World) string {
o := w.Org("Due", orgType, owner)
w.Suspend(w.Subscribe(o, world.SubOpts{}, due), "past_due")
return o.ID
}, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := orgHasEntitlement(ctx, q, tc.build(t, world.New(t, db))); got != tc.want {
+1
View File
@@ -297,6 +297,7 @@ func (h *MemberProductsHandler) ladderRows(ctx context.Context, poolID string) (
var rungs []rung
var offPlan []PoolTierViewModel
positions, err := h.EntitlementsQ.ListLadderPositionsByPool(ctx, poolID)
positions = entitlements.ExcludeSuspended(positions)
if err != nil || len(positions) == 0 {
offPlan = append(offPlan, PoolTierViewModel{})
}
@@ -3,7 +3,7 @@
package server
// The entitlements panel, characterized state by state: for each of four
// The entitlements panel, characterized state by state: for each of five
// organizations, the data GetEntitlements hands its template, printed as JSON
// by a template set that stands in for member_entitlements.html, and the
// panel's render through the member template set, compared with the files
@@ -50,10 +50,11 @@ func (f *entitlementsGoldenFixture) orgNamed(t *testing.T, state string) string
return ""
}
// newEntitlementsGoldenFixture builds the four organizations: one without a
// newEntitlementsGoldenFixture builds the five organizations: one without a
// default pool, one holding nothing, one holding Starter on two ladders and a
// sponsor's perks with a lapsed trial and some usage, and one on a default
// rung whose set has no rule. Every start a source prints is fixed, except
// sponsor's perks with a lapsed trial and some usage, one on a default
// rung whose set has no rule, and one subscribed to a plan whose payment is
// due. Every start a source prints is fixed, except
// the default rung's, which the clock writes.
func newEntitlementsGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *entitlementsGoldenFixture {
t.Helper()
@@ -78,6 +79,10 @@ func newEntitlementsGoldenFixture(ctx context.Context, t *testing.T, w *world.Wo
limit(maximum, goldenSitesKey, 20)
access(perks, goldenPriorityKey)
access(trial, goldenBetaKey)
due := w.Product("Due")
w.Price(due, 1500)
limit(due, goldenSitesKey, 4)
access(due, goldenForumKey)
// Zeta sorts before Alpha, against the alphabet.
zeta := w.Ladder("Zeta", starter, maximum)
@@ -86,6 +91,8 @@ func newEntitlementsGoldenFixture(ctx context.Context, t *testing.T, w *world.Wo
w.SortOrder(alpha, 2)
entry := w.Ladder("Entry", empty)
w.SortOrder(entry, 3)
dueLadder := w.Ladder("Due", due)
w.SortOrder(dueLadder, 4)
owner := w.Person("Owner")
plain, withDefault := w.OrgType("ents"), w.OrgType("entsdef")
@@ -115,6 +122,9 @@ func newEntitlementsGoldenFixture(ctx context.Context, t *testing.T, w *world.Wo
w.Use(ws, goldenSitesKey, 2)
w.Floor(org("rung-without-entitlements", withDefault))
o = org("past-due-plan", plain)
w.Suspend(w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, due), "past_due")
return f
}
+12 -3
View File
@@ -4,7 +4,7 @@
package server
// The Products page's plans region, characterized state by state: for each of
// thirteen organizations, the view model buildPlansData returns, cut to the
// fifteen organizations, the view model buildPlansData returns, cut to the
// fixture's ladders and without the billing paragraph and the preview, and the
// member_plans.html render of that data, compared byte for byte with the files
// under testdata/member_plans. A change to anything else a member sees on the
@@ -93,7 +93,7 @@ func registerGoldenKeys(t testing.TB, w *world.World) {
}
}
// plansGoldenFixture is the three ladders and thirteen organizations the
// plansGoldenFixture is the three ladders and fifteen organizations the
// plans golden reads, built once. Every date a state prints is fixed, far
// enough ahead that the clock never reaches it, and falls at 12:00 UTC.
type plansGoldenFixture struct {
@@ -188,9 +188,18 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
w.Issue(o, plus, world.IssueOpts{Marked: true})
w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
o = org("paid-pro-past-due")
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.Suspend(sub, "past_due")
o = org("paid-pro-past-due-held-plus-below")
w.Issue(o, plus, world.IssueOpts{Marked: true})
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.Suspend(sub, "past_due")
o = org("paid-pro-cancellation-flag-and-row")
w.Issue(o, plus, world.IssueOpts{Marked: true})
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.CancelAtPeriodEnd(sub)
w.ScheduleCancellation(sub, noon(2099, time.February, 10))
+5 -4
View File
@@ -1348,6 +1348,7 @@ func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollm
positionsErr := make([]error, len(allPools))
for i, pool := range allPools {
positions[i], positionsErr[i] = h.EntitlementsQ.ListLadderPositionsByPool(r.Context(), pool.PoolID)
positions[i] = entitlements.ExcludeSuspended(positions[i])
for _, att := range positions[i] {
names.WantProducts(att.ProductID)
names.WantLadders(att.PlanLadderID)
@@ -1783,12 +1784,12 @@ func (h *OperatorPartialsHandler) productsOnLadders(r *http.Request, ladders map
}
// subscriptionHeldLadders returns the set of plan ladders currently occupied by
// a live subscription-sourced provision on any of the org's pools. The issuance
// an active subscription-sourced provision on any of the org's pools. The issuance
// form uses it to advise (non-blocking) when a grant would supersede a
// subscription-held rung — the subscription keeps billing (§11.2 residue). Pure
// read; best-effort: a pool whose positions fail to read is skipped, not
// surfaced. A position is held when the query says so, the predicate the
// Pools panel reads too.
// surfaced. A position is held when ExcludeSuspended keeps it, the filter the
// Pools panel applies too.
func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools []entitlements.ResourcePool) map[string]bool {
held := map[string]bool{}
for _, pool := range pools {
@@ -1796,7 +1797,7 @@ func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools
if err != nil {
continue
}
for _, pos := range positions {
for _, pos := range entitlements.ExcludeSuspended(positions) {
if pos.SubscriptionID.Valid {
held[pos.PlanLadderID] = true
}
+1 -1
View File
@@ -68,7 +68,7 @@ func (l *positionLoader) positions(ctx context.Context, orgID string) (map[strin
if err != nil {
return nil, fmt.Errorf("read the positions in pool %s: %w", pool.PoolID, err)
}
return entitlements.PositionsByLadder(rows), nil
return entitlements.PositionsByLadder(entitlements.ExcludeSuspended(rows)), nil
}
// ladderFacts are what the Products page draws beside a position: the row
+1 -1
View File
@@ -322,7 +322,7 @@ func orgHasEntitlement(ctx context.Context, entitlementsQ entitlements.Querier,
}
}
}
if positions, err := entitlementsQ.ListLadderPositionsByPool(ctx, pool.PoolID); err == nil && len(positions) > 0 {
if positions, err := entitlementsQ.ListLadderPositionsByPool(ctx, pool.PoolID); err == nil && len(entitlements.ExcludeSuspended(positions)) > 0 {
return true
}
return false
@@ -0,0 +1,30 @@
<div class="mb-2">
<span class="text-muted">No active plan</span>
</div>
<hr>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="fw-semibold" title="plan_golden_sites">Plan Sites</span>
<span class="text-muted">0 / 0</span>
</div>
<meter value="0" min="0" max="0" class="w-100"></meter>
</div>
@@ -0,0 +1,23 @@
{
"Enrolled": false,
"HasEntitlements": true,
"Entitlements": [
{
"ResourceKey": "plan_golden_sites",
"Label": "Plan Sites",
"IntegrationName": "",
"ResourceLimit": 0,
"CurrentUsage": 0
}
],
"BooleanEntitlements": null,
"Sources": null,
"PoolTiers": [
{
"LadderName": "",
"TierName": "",
"HasTier": false
}
],
"Error": ""
}
@@ -0,0 +1,444 @@
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Basic>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Basic>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Basic>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Standard>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Standard>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Standard>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Pro>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Pro>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Pro>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,384 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -0,0 +1,444 @@
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Basic>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Basic>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Basic>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Standard>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Standard>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Standard>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Pro>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Pro>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Pro>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,384 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -49,13 +49,18 @@ Reason: the position is the organization's in every rule below the surfaces (the
### 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.
A position is suspended when its ladder row's status is `suspended`. The subscription's status is read only on a suspended row, and 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.
`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` on a suspended row (`Paused` implies `Suspended`). `plans.Offer` is a first-match order, and `Offer` returns the held rung's Cancel from `heldRules` before it calls `route`, so one rule cannot both let the held rung's Cancel stand for a suspended position and replace it for a paused one. It therefore gains two rules, which make sixteen:
- **Rule 4, the paused rule**, directly after rule 3 (the target is the current product) and before the rules that read the held rung: a paused position is answered none with the new reason `Paused` ("the position is paused") for every target that rules 1 to 3 did not answer, so the current product keeps its reason `Current`. Today's rules 4 to 13 become 5 to 14.
- **Rule 15, the suspended rule**, directly before the switch: when the rules before it would route the target to the switch (rule 16, 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 8 (the held rung) and rule 14 (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 13 (today's 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 toward any tier but its current one, with the reason `Paused`: the paused rule sits above the held rung's and the free rung's rules, so their Downgrade is not drawn on a paused plan, and it answers before rules 5 to 12 can give a paused position their own reasons, such as "ranks below both".
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.
@@ -79,17 +84,17 @@ Reason: the endpoint and fulfillment each refuse, as the existing gates do (the
`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.
- **Keep plan refuses a downgrade that Stripe has already applied.** The console's schedule enters its second phase at the renewal. Stripe finalizes and charges the renewal invoice "one hour after the last webhook was successfully sent" (stripe-go `invoice.go:3172-3176`), and in that hour `customer.subscription.updated` reconciles the subscription while it is still active (`webhook_subscription.go:74-76`): that reconcile converges the landed downgrade and releases the spent schedule (`reconcile.go:105-112`, `:126-128`). A payment-due plan whose scheduled downgrade has landed and has not been converged therefore arises only when no reconcile ran in that hour. The same window exists on an active subscription; this change leaves the active path as it is, and the check applies to a suspended subscription only. `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. A schedule whose downgrade has not landed is released and its row superseded, as on an active subscription. Without the refusal, 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 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 24 when every covered ladder keeps its plan ("Billing for Pro stops on Jan 15, 2099."), copy row 19 when nothing follows the plan on any covered ladder ("Pro ends on Jan 15, 2099."), row 20 when only the plan's own ladder is covered and another plan follows ("Pro ends on Jan 15, 2099, then you move to Plus."), row 21 for every other prediction (several ladders, a keep beside a leave: the existing clause words after "then you"), 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.
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 may stop automatic collection of the customer's finalized invoices (the SDK documents it for the immediate cancellation; S9), so the unpaid invoice stays due and is paid through Pay invoice. 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
@@ -104,7 +109,7 @@ Reason: a scheduled cancellation or downgrade is still a fact the member needs,
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).
- **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. After the gate, a picked row whose `position_status` is not `suspended` answers 303 to `/billing` with no Stripe call: the route exists to pay a plan's unpaid invoice, and an active subscription's open latest invoice can be an upgrade the member walked away from (an upgrade that needs approval leaves an open invoice, `plan_change.go:290-298`, which the console voids only on the next upgrade attempt, `:256-264`), which paying would apply. 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).
@@ -112,7 +117,7 @@ The card's control and the invoice detail's control are links that open a new ta
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.
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 for a suspended position and only to an open invoice because its one purpose is to pay the invoice that suspended the plan; 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
@@ -122,7 +127,7 @@ Reason: no control should lead to a page with nothing on it, and no act should r
### 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.
`ladderRows` lists one line per live position, iterating the rows in query order and skipping a ladder already seen (the first row of a ladder is the active one, D1), 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.
@@ -130,7 +135,7 @@ Reason: "No active plan" is false while the organization holds a plan Stripe is
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 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 key is applied where a subscription's state is drawn: the card's Subscriptions row (`BillingSummaryViewModel.SubscriptionsBadge()`) and the operator's subscriptions list that the card's count links to (`SubscriptionViewModel.StatusBadge()`), so the same subscription reads the same in both. 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.
@@ -146,13 +151,13 @@ Reason: today's `Suspend` leaves the subscription `active`, "a state the app doe
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.
Fulfillment's resolver and the page's loader are released in one section, because either alone leaves the two disagreeing. With the resolver released first, the page would draw a Cancel plan or Keep plan whose endpoint still answers "There's no active subscription to change on this plan.". With the page's loader released first, a suspended plan would read as not held and offer Subscribe on every priced tier, while the cancel and keep endpoints, which have no server gate on the position, would already accept the plan acts on it, and "Your cancellation is scheduled." would render over a page that shows no plan. In the one section the page draws a control only where its endpoint accepts the act, and the endpoint accepts only what the page draws. The Pay invoice routes and controls come after it, 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 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 converged by the reconcile of the renewal's `customer.subscription.updated` while the subscription is still active (D6), so it stays unconverged in the suspended branch only when no reconcile ran in the hour before Stripe charged the invoice. Then 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).
@@ -187,7 +192,7 @@ Each doc moves with the section whose code it describes (`CONTRIBUTING.md`, "Doc
| 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 |
| F18 | Pay invoice after the plan recovered | n/a | By plan: the position is active again, 303 to `/billing` with no Stripe call (and 303 to `/billing` for a suspended position whose latest invoice is paid). 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 |
@@ -199,7 +204,7 @@ Each doc moves with the section whose code it describes (`CONTRIBUTING.md`, "Doc
| 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) |
| 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). Cancel plan on it releases the schedule that holds the lower price and supersedes the `plan_switch` row that Stripe applied (`plan_change.go:582-586`, `:622-625`), and its body names the plan the card holds ending while Stripe bills the lower plan; the case needs no reconcile in the hour after the renewal (D6) and is accepted |
| 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) |
@@ -216,7 +221,7 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| 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.` |
| 6 | Cancel confirmation, plan whose payment is due: the sentence after the body (rows 19 to 22 and 24), 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) |
@@ -226,9 +231,10 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| 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.` |
| 20 | The same body, only the plan's own ladder is covered and another plan follows | `<plan> ends on <date>, then you move to <other plan>.`, for example `Pro ends on Jan 15, 2099, then you move to Plus.` |
| 21 | The same body, any other prediction (several ladders, a keep beside a leave), the clauses being the ones the existing body uses | `<plan> ends on <date>, then you <clauses>.`, for example `Pro ends on Jan 15, 2099, then you keep Plus on Hosting and leave Support.` |
| 22 | The same body, no prediction or no date | `<plan> ends with this billing period.` |
| 24 | The same body, every covered ladder keeps its plan (added; followed by row 6, and waiting for approval like every row) | `Billing for <plan> stops on <date>.`, for example `Billing for Pro stops on Jan 15, 2099.` |
### Operator
@@ -236,7 +242,7 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
|---|---|---|
| 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 |
| 23 | Billing card, the Subscriptions row's badge when the worst state is `unpaid`, and the operator subscriptions list's status badge for an unpaid subscription | 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.
@@ -249,13 +255,13 @@ Reused unchanged: Checkout's refusal (`That plan is no longer available. Refresh
## 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 decision** (`internal/plans/offer_test.go`, no database, over the file's ladder with Plus current): a suspended position 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 Plus; 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, and none with `Current` for Plus. The table's runner moves out of its inline closure into a named function that prints `Offer(<row name>) = <got>, want <want>`, so each break of the paused and suspended rules 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.
- **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; Keep over a downgrade scheduled while the subscription was active, still pending when it turned `past_due`, releases the schedule and supersedes the row; `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, `past_due` on an organization type with no default ladder (copy row 19), `past_due` with a downgrade still pending ("moves to", Keep plan) and with one that has landed (no "moves to" line, no Keep plan, Cancel plan kept), and `past_due` on a subscription whose price is not projected (no Pay invoice); the cancel bodies of rows 19 to 22 and 24 by a table test of the prediction's body; 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 an active position with no Stripe call, 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 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 and the subscriptions list's `unpaid` badges are 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.
@@ -273,17 +279,17 @@ The local sources are the stripe-go v81.4.0 doc comments (API version `2025-02-2
| 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 |
| S8 | Releasing a schedule that the console attached while the subscription was active, once the subscription is `past_due`, and what Keep plan does to one whose downgrade has landed | Before S1, press Downgrade on a lower priced tier through the console, which attaches the console's schedule with its metadata and records the `plan_switch` row; a schedule made with the CLI carries no row and not the metadata (the console sets it in a second call, `plan_change.go:356`, `:395-396`), so Keep plan would have nothing to act on. Run S1. If Stripe refuses the billing-cycle-anchor update on a subscription that a schedule manages, record S8 as not settled and release the schedule from the CLI so that the rest of S1 continues. Otherwise, still past due and before S2, press Keep plan and record Stripe's answer to the release and that the card reads "payment due" again. The landed half cannot be reached without a test clock, so it is recorded as not settled and rests on `TestKeepRefusesADowngradeThatLanded` |
| 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 |
| 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 that invoice's `hosted_invoice_url`, read with the CLI, because the console never maps a test-clock customer and so shows no Pay invoice for it. 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).
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 that the console attached while the subscription was active, 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.
- **A payment-due card can name a plan the subscription has already left.** A downgrade that landed is converged by the renewal's reconcile while the subscription is still active, so it stays unconverged only when no reconcile ran in the hour before Stripe charged the invoice (D6, D14). Then 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. Cancel plan stays on it, and releases the schedule that holds the lower price and supersedes the row Stripe applied, with a body that names the plan the card holds ending while Stripe bills the lower one (F30); the case is rare and accepted.
- **A period-end cancellation of a payment-due plan can stop collection of the customer's other open invoices.** Stripe may stop 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.
@@ -8,7 +8,7 @@ The member and operator surfaces do not. They read positions through one query,
- 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.
- Nothing tells the member that the payment is due, 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`).
@@ -16,7 +16,7 @@ The issue names `GetActiveAttachmentsByPool` as the read and `HasActiveSubscript
## 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 offer decision refuses the switch on a suspended position.** `plans.Position` gains `Suspended` and `Paused`, and `plans.Offer` gains two rules, each with its own reason: a suspended position is offered no switch, in either direction, and a paused position is offered no move toward any plan but its current one. 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.").
@@ -30,7 +30,7 @@ The issue names `GetActiveAttachmentsByPool` as the read and `HasActiveSubscript
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 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, on the card and in the operator's subscriptions list that the card's count links to; 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
@@ -53,7 +53,7 @@ None.
## 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).
- **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/fulfillment/reconcile.go` (the test `findSpentSchedule` applies becomes a function that `KeepCurrentPlan` shares), `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.
@@ -79,4 +79,4 @@ Each is a candidate for the maintainer to decide on; none is filed.
- **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).
- **Collection stops when a period-end cancellation takes effect.** When the cancellation of a `past_due` plan takes effect, Stripe may stop 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).
@@ -5,7 +5,7 @@
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.
- `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. A picked row whose position status is not `suspended` SHALL answer 303 to `/billing` and SHALL call Stripe for nothing, because the route exists to pay the invoice that suspended the plan, and an active subscription's open latest invoice can be an upgrade the member walked away from. 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.
@@ -40,6 +40,11 @@ The hosted invoice URL changes on every read, expires, and lets anyone holding i
- **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 that is not suspended goes to the Billing page
- **WHEN** a member opens the plan route for a ladder on which the position is active, whose subscription has an open latest invoice for an upgrade awaiting approval
- **THEN** the console answers 303 to `/billing` and calls Stripe for nothing
#### 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
@@ -14,7 +14,7 @@ Its answer SHALL be one move: Checkout, a switch, a downgrade at the period end
- 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.
- a paused position SHALL be offered no move toward any tier but its current one, 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 current tier keeps the reason that it is current.
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.
@@ -63,6 +63,7 @@ The Products page, Checkout, the plan switch and the switch preview SHALL all de
- **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
- **AND** asked about Standard itself, it answers no move, with the reason that the tier is current
#### Scenario: The decision reads nothing
@@ -11,7 +11,7 @@ The current card of a suspended plan SHALL differ from the card "The member's cu
- 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");
- on a plan whose payment is due, the cancel confirmation's body SHALL name the end in place of the keep that requirement defines: "Billing for {plan} stops on {date}." when every covered ladder keeps its plan; "{plan} ends on {date}." when nothing follows the plan on any covered ladder; "{plan} ends on {date}, then you move to {other plan}." when only the plan's own ladder is covered and another plan follows; "{plan} ends on {date}, then you {clauses}." for any other prediction, the clauses being the ones that requirement uses; and "{plan} ends with 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`.
@@ -46,6 +46,11 @@ This narrows "The member's current plan shows its cost and renewal" and "Plan-mo
- **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: Cancelling a plan that the prediction keeps says billing stops
- **WHEN** a member on Pro, whose payment is due and whose period ends Jan 15, 2099, opens "Cancel plan" and the prediction puts Pro itself on the ladder after the end
- **THEN** the confirmation reads "Billing for Pro stops on Jan 15, 2099. 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
@@ -43,7 +43,7 @@ On the organization composite, the billing card SHALL render three rows in this
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 **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, here and in the operator's subscriptions list that the count links to, 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".
@@ -71,7 +71,7 @@ The Billing section header's "Full billing" action SHALL remain a navigation but
#### Scenario: A single subscription names its period
- **WHEN** an operator views the billing card of an organization with one subscription
- **WHEN** an operator views the billing card of an organization with one active 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
@@ -107,7 +107,7 @@ The switch SHALL accept only a target that the offer decision (`member-offer`, "
### 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").
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 nothing until the period 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
@@ -1,13 +1,13 @@
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.
Sections 1 to 6 are one commit each, and their items are that commit's parts; section 7 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 and the Products page together, so that the page never draws a Keep plan or Cancel plan its endpoint refuses and no endpoint accepts a plan act on a suspended plan that the page still shows as not held (fulfillment's resolver and the page's loader move in one commit), 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 4. 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 3 to 6 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 3 and 5 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.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 6.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"`;
@@ -15,13 +15,13 @@ Sections 1 to 7 are one commit each, and their items are that commit's parts; se
## 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:
- [x] 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.
- [x] 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`.
- [x] 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.
- [x] 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.
- [x] 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".
- [x] 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.
- [x] 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"`;
@@ -35,73 +35,93 @@ Sections 1 to 7 are one commit each, and their items are that commit's parts; se
- `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. Fulfillment and the Products page hold the suspended plan
- [ ] 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:
- [ ] 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`, extracted into a function that `reconcile.go` and `KeepCurrentPlan` both call), returns `ErrScheduledMoveApplied` before it releases a schedule or supersedes a row; a schedule whose downgrade has not landed is released and its row superseded, as on an active subscription, and the check applies to a suspended subscription only, the active path being left as it is. `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 `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"`; two new rules. The paused rule is rule 4, applied in `Offer` directly after rule 3 and before `heldRules`: a paused position is offered none with `Paused` for every target that rules 1 to 3 did not answer, so the current product keeps `Current`. The suspended rule is rule 15, applied in `route` directly before the switch: a suspended position's switch is `Move{Reason: Suspended}` with its relation. Today's rules 4 to 13 become 5 to 14 and the switch becomes rule 16. `Offer`'s doc comment lists the sixteen rules and says why the paused rule sits above the held rung's rule 8 and the free rung's rule 14 (it replaces their Cancel answers on a paused position) and why the suspended rule sits below them (they keep their Cancel answers on a suspended position). The comments that name rule numbers (the one at `offer.go:107`, `heldRules`, `offersNothing` and `route`) follow the renumbering.
- [ ] 3.6 `internal/server/plan_offer.go` (design D2, D7): `onLadder` sets `pos.Suspended` from the row's `position_status`; it sets `pos.Paused` only when `position_status` is `suspended` and `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`, set only when `position_status` is `suspended`: `paused` when the subscription's status is `paused` and `payment_due` for every other status; `positions` no longer calls `ExcludeSuspended`.
- [ ] 3.7 `switchOffer` (design D5): after the `NotOnLadder` case and before `!pos.Subscribed`, a suspended position returns `fulfillment.ErrSubscriptionPaused` when `pos.Paused` is set (not the facts, which `switchOffer` discards, `plan_offer.go:307`), 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.
- [ ] 3.8 `internal/server/member_plans.go` (design D6, D7): `TierViewModel` gains `Suspension string`, which `setCurrent` copies from the facts before its early return for an empty price text, so that a card whose price is not projected carries it too, 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 and 24 in place of "You keep {current} until {date}" (row 24 when every covered ladder keeps its plan, row 19 when nothing follows on any covered ladder, row 20 when only the plan's own ladder is covered and another plan follows, row 21 for every other prediction with the existing clause words after "then you", 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`.
- [ ] 3.9 `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.
- [ ] 3.10 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.
- [ ] 3.11 Fulfillment 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. Each test's failure texts are written here because the breaks match them.
- `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. Failures read `SwitchPlan on a suspended subscription returned <err>, want ErrSubscriptionSuspended`, `the switch called Stripe: <calls>` and `the switch wrote a scheduled change`.
- `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`. A failure reads `CancelSubscription on a suspended subscription returned <err>, want nil`.
- `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`.
- `TestKeepReleasesAPendingDowngradeOnASuspendedSubscription`: `onTier2`, a downgrade to tier 1 scheduled while the subscription is active, then the mock's subscription `past_due` and a reconcile; `KeepCurrentPlan` returns nil, the mock received the release of the schedule, the `plan_switch` row reads `superseded`, and the provisions still read `suspended`. Failures read `KeepCurrentPlan on a pending downgrade of a suspended subscription returned <err>, want nil`, `the keep sent no release of the schedule`, `the plan_switch row reads <status>, want superseded` and `the provisions read <status>, want suspended`.
- `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. Failures read `CancelSubscription(immediate) on a suspended subscription returned <err>, want ErrImmediateCancelSuspended` and `the cancellation called Stripe: <calls>`.
- `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. A failure reads `SubscriptionBacksLadder = false for a suspended position`.
- [ ] 3.12 Page 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 breaks below can match their text (an existing test changes, and the commit message names it). The rows run over the file's ladder (free, basic, standard, plus, pro) with Plus current and subscribed, through its `held` helper (Standard held) and its `below("free", …)` helper (a prediction that puts Free on the ladder). Suspended: "suspended, a tier above" (Pro) and "suspended, a priced tier below" (Basic) answer none with `Suspended`; "suspended, the free rung that follows" (Free) answers Cancel, enabled; "suspended, the current tier" (Plus) answers none with `Current`; "suspended, the held rung below" (Standard) answers Cancel, enabled, held; "not suspended, a tier above" and "not suspended, a priced tier below" answer a switch, as the decision does today. Paused: "paused, a tier above" (Pro), "paused, the held rung below" (Standard) and "paused, the free rung that follows" (Free) each answer none with `Paused`, and "paused, the current tier" (Plus) answers none with `Current`.
- `TestCancelPredictionBodyPaymentDue` (`grant_resumption_render_test.go`, beside `TestCancelPredictionBody`, over `predictionOf`, printing `<case>:\n got %q\nwant %q` as its sibling does): "nothing follows" reads copy row 19; "only the plan's own ladder is covered" reads row 20; "several ladders" reads row 21 with the clause words; "every covered ladder keeps its plan" reads row 24.
- `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, no Upgrade; the fixture's organization type has Wiki as its default ladder with Free as the floor, so Free follows in the first and the second covers two ladders: the first's Cancel plan body is copy row 20 followed by copy row 6, "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."; the second shows Plus's "Granted" with its Downgrade, and the bodies of Pro's Cancel plan and Plus's Downgrade are copy row 21 followed by copy row 6, "Pro ends on Jan 15, 2099, then you move to Plus on Wiki and Duo. The unpaid invoice stays due."). New states: `paid-pro-unpaid` (copy rows 1 and 3, the body of copy row 20 as the first state); `paid-pro-paused` (copy rows 2 and 4, and no Pay invoice, no Keep plan and no Cancel plan); `paid-pro-past-due-no-default` (an organization type with no default ladder, Pro subscribed and suspended `past_due`: Cancel plan whose body is copy row 19 followed by copy row 6, "Pro ends on Jan 15, 2099. The unpaid invoice stays due."); `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`); `paid-pro-past-due-downgrade-scheduled` (`ScheduleDowngrade` at the 2099 period end, then `Suspend`: "moves to Standard on Jan 15, 2099", the fact line, Keep plan and Cancel plan); `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 20 followed by copy row 6); and `unprojected-subscription-past-due` (as `unprojected-subscription`, then suspended: the card without a price, and Cancel plan whose body is copy row 22 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`), built on `seedOfferMember` (the mock plus the reconcile, because the kit maps no subscription item and `resolveLadderSubscription` would fail at `plan_change.go:762-765` before any refusal), the subscription suspended through the mock's status and the requests made through `ask` and checked through `refused`, 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`. A sentence that is missing fails as the file's helper prints it, `want "<sentence>" in:` followed by the body, where an apostrophe is `&#39;`.
- `TestPlanActsRefuseWhatFulfillmentRefuses` (the same file, built the same way), 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.
- [ ] 3.13 `make screens`: no changed screen expected (the preamble's reason).
- [ ] 3.14 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`;
- the suspended refusal placed in `resolveLadderSubscription` instead of `SwitchPlan`: caught by `TestCancelASuspendedSubscriptionAtPeriodEnd`, `want_new` `CancelSubscription on a suspended subscription returned switch: the subscription's position is suspended, want nil`;
- `KeepCurrentPlan` without its landed-downgrade check: caught by `TestKeepRefusesADowngradeThatLanded`, `want_new` `KeepCurrentPlan on a landed downgrade returned <nil>, want ErrScheduledMoveApplied`;
- the landed check without its price comparison (it returns `ErrScheduledMoveApplied` for every suspended subscription with a scheduled row): caught by `TestKeepReleasesAPendingDowngradeOnASuspendedSubscription`, `want_new` `KeepCurrentPlan on a pending downgrade of a suspended subscription returned keep: the scheduled move has already been applied, want nil`;
- 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) =`;
- `ErrSubscriptionSuspended` returned for a paused subscription in `SwitchPlan`: caught by the same test's switch row, `want_new` `want ErrSubscriptionPaused`;
- the suspended rule (15) 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`;
- the suspended rule applied before the held rung's rule (8): caught by `offer_test.go`, `want_new` `Offer(suspended, the held rung below) =`;
- the suspended rule applied before the free rung's rule (14): caught by `offer_test.go`, `want_new` `Offer(suspended, the free rung that follows) =`;
- the paused rule removed: caught by `offer_test.go`, `want_new` `Offer(paused, a tier above) =`;
- the paused rule placed after the held rung's rule (8): caught by `offer_test.go`, `want_new` `Offer(paused, the held rung below) =`;
- `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."`;
- `switchOffer` without the suspended case: caught by `TestSwitchRefusesASuspendedPlan`, `want_new` `want "Pay the unpaid invoice before changing this plan." in:`;
- the suspended case placed after the current-tier case: caught by the current-tier row, `want_new` `You&#39;re already on that plan.`;
- `ErrSubscriptionPaused` never returned by `switchOffer`: caught by the paused row, `want_new` `want "A paused plan cannot be changed." in:`;
- `renderPlansAfterMove` without its four cases (rows 7, 8, 17 and 18): caught by the switch-up row, `want_new` `We couldn&#39;t complete that change. Try again in a moment.`;
- `renderPlansAfterMove` without its row 17 case: caught by `TestPlanActsRefuseWhatFulfillmentRefuses`'s landed row, `want_new` `want "That plan change has already taken effect." in:`;
- `renderPlansAfterMove` without its row 18 case: caught by the immediate row, `want_new` `want "A plan with an unpaid invoice cannot end immediately." in:`;
- `KeepCurrentPlan` without its paused refusal, seen through the page: caught by `TestPlanActsRefuseWhatFulfillmentRefuses`'s paused Keep row, `want_new` `want "A paused plan cannot be changed." in:`;
- `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`;
- `onLadder` dropping every downgrade of a suspended position (the date comparison replaced by dropping unconditionally): caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-downgrade-scheduled.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`;
- `setCancelBodies` choosing the form of row 19 when another plan follows: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due.html differs from what the code produced`;
- `setCancelBodies` choosing the form of row 20 when nothing follows: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/paid-pro-past-due-no-default.html differs from what the code produced`;
- the body of row 24 read as row 21 (the all-keep case falling to the clauses): caught by `TestCancelPredictionBodyPaymentDue`, `want_new` `every covered ladder keeps its plan:`;
- the body of row 20 read as row 21 (the own-ladder case falling to the clauses): caught by the same test, `want_new` `only the plan's own ladder is covered:`;
- `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
## 4. 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:
- [ ] 4.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 after the gate answers 303 to `/billing` with no Stripe call for a row whose `position_status` is not `suspended`, for no Stripe querier, for no mapping row for the subscription, and for 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).
- [ ] 4.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`.
- [ ] 4.3 `member_plans.html` and `member_plans.go` (design D7, D8): on the current card whose `Suspension` is `payment_due`, the first control, drawn only when `LadderID` is set (`setCurrent` sets it after its early return for an empty price text, `member_plans.go:437-440`, so a card whose price is not projected would otherwise link to `/billing/plans//unpaid-invoice`), 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.
- [ ] 4.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.
- [ ] 4.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.
- [ ] 4.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).
- [ ] 4.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`; a position whose status is `active` (the plan recovered, or an upgrade awaiting approval) answers 303 to `/billing` with no Stripe call. 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`, `paid-pro-past-due-no-default`, `paid-pro-past-due-cancellation-scheduled`, `paid-pro-past-due-downgrade-scheduled` and `paid-pro-past-due-downgrade-landed` change on purpose (the Pay invoice link first); `paid-pro-paused`, `unprojected-subscription-past-due` (its card has no price, so no `LadderID`, and draws no Pay invoice) and every other state do not, nor does any JSON golden. The billing line: `TestPlansOfferPaidMoveGate` (`billing_facts_render_test.go`) gains a row, a current tier whose `Suspension` is `payment_due`, failing with `t.Error("a plan whose payment is due does not earn the line")`; the check that Pay invoice references the paragraph is a separate test, `TestPayInvoiceReferencesTheBillingLine`, failing with `t.Error("Pay invoice does not reference the billing paragraph")`, so that `TestPlansCarryTheBillingLineOnce…`, which counts exactly two references (`billing_facts_render_test.go:16-38`), keeps its fixtures and its count. 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.
- [ ] 4.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.
- [ ] 4.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 without its suspended check: caught by its active-position row, `want_new` `the route called Stripe for a plan that is not suspended`;
- 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`;
@@ -111,44 +131,47 @@ Sections 1 to 7 are one commit each, and their items are that commit's parts; se
- 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`;
- `plansOfferPaidMove` not counting Pay invoice: caught by `TestPlansOfferPaidMoveGate`'s new row, `want_new` `a plan whose payment is due does not earn the line`;
- Pay invoice without its `aria-describedby`: caught by `TestPayInvoiceReferencesTheBillingLine`, `want_new` `Pay invoice does not reference the billing paragraph`;
- Pay invoice drawn without the `LadderID` check: caught by `TestMemberPlansGolden`, `want_new` `testdata/member_plans/unprojected-subscription-past-due.html differs from what the code produced`;
- `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
## 5. 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:
- [ ] 5.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.
- [ ] 5.2 `internal/embeds/templates/partials/member_entitlements.html`: after `<strong>{{ .TierName }}</strong>`, `{{ if .State }} {{ template "statusBadge" .StateBadge }}{{ end }}`; the template comment names the badge.
- [ ] 5.3 `orgHasEntitlement` (`internal/server/server.go:310-329`) no longer calls `ExcludeSuspended`; its doc comment says that a suspended plan counts.
- [ ] 5.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.
- [ ] 5.5 `make screens`: no changed screen expected.
- [ ] 5.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
## 6. 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:
- [ ] 6.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.
- [ ] 6.2 `ExcludeSuspended` and `TestExcludeSuspendedKeepsEverythingElse` are deleted, since no reader calls it (`deadcode` would refuse it).
- [ ] 6.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 subscription's `unpaid` badge is drawn through a new badge key, `subscription_unpaid` (`internal/server/anatomy.go`, label `Unpaid`, tone `danger`), by the existing `BillingSummaryViewModel.SubscriptionsBadge()` (`anatomy.go:716-722`, which returns a `Badge`) and by `SubscriptionViewModel.StatusBadge()` (`anatomy.go:856`), the badge of the operator's subscriptions list that the card's count links to, while the invoice's and the balance's `unpaid` keep their key and the warning tone; `TestStatusBadgeMapIsComplete` lists the new key.
- [ ] 6.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 two badge methods, tested by tone because both return a `Badge`: `SubscriptionsBadge()` for the state `unpaid` has the tone `danger` and `SubscriptionViewModel.StatusBadge()` for the state `unpaid` has the tone `danger`, every other state keeps the badge `StatusBadge` gives it, and `StatusBadge("unpaid")` keeps the tone `warning`; failures read `SubscriptionsBadge(unpaid).Tone = %q, want "danger"`, `SubscriptionViewModel(unpaid).StatusBadge().Tone = %q, want "danger"` and `StatusBadge(unpaid).Tone = %q, want "warning"`.
- [ ] 6.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.
- [ ] 6.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`; press Downgrade on a lower priced tier through the console, which attaches the console's schedule with its `scheduled_from_price_id` metadata and records the `plan_switch` row (a schedule made with the CLI carries neither, because the console sets the metadata in a second call, `plan_change.go:356`, `:395-396`, so Keep plan would have nothing to act on); start a new period now (`--billing-cycle-anchor now --proration-behavior none`). Check S1 (the subscription reads `past_due`; if Stripe refuses the billing-cycle-anchor update on a subscription that a schedule manages, record S8 as not settled and release the schedule from the CLI so that the rest of S1 continues) 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. Still past due and before S2, press Keep plan and check S8: record Stripe's answer to the release of the schedule and that the card reads "payment due" again. Then Cancel plan and check S2 and, in its confirmation, copy row 6 and the row that the prediction picks; 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). The landed half of S8 (Keep plan over a downgrade that Stripe applied) cannot be reached without a test clock and is recorded as not settled; it rests on `TestKeepRefusesADowngradeThatLanded`. S9 and S10 need a test-clock customer (S10 opens the invoice's `hosted_invoice_url`, read with the CLI, because the console maps no 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.
- [ ] 6.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 Subscriptions row's `unpaid` badge drawn through the shared `unpaid` key: caught by the badge methods' test, `want_new` `SubscriptionsBadge(unpaid).Tone = "warning", want "danger"`;
- the subscriptions list's `unpaid` badge drawn through the shared `unpaid` key: caught by the same test, `want_new` `SubscriptionViewModel(unpaid).StatusBadge().Tone = "warning", want "danger"`;
- 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
## 7. 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.
- [ ] 7.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate suspended-plans-stay-held --strict` passes.
- [ ] 7.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 6.
- [ ] 7.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.
- [ ] 7.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.