Show an operator a suspended delivery, and say a suspended plan's end

The organization page's Pools panel listed only active deliveries, so a
plan whose payment is due vanished from it and the Issue grant form did
not warn that a grant would supersede it. The panel now lists a
suspended delivery with a Suspended badge, taken from the delivery's
first row (the status trigger copies a provision's status to every rung
that has not ended), and subscriptionHeldLadders holds its ladder, so
the supersession warning shows. ExcludeSuspended is deleted: nothing
reads it any more.

The Billing card's subscription summary orders states unpaid, past_due,
paused, canceling, active, trialing, so the worst state names the card.
A suspending state drops "renews <date>", which names a period nobody
has paid for; one set to cancel at the period end keeps "ends <date>",
which is true, since Stripe cancels it then whatever its invoice says,
and is the card's only sign that the member already cancelled. The copy
table's row 14 says so. The subscription's Unpaid badge has its own key,
subscription_unpaid, in the danger tone, used by the card's
Subscriptions badge and the subscriptions list through one helper; the
invoice's and the balance's Unpaid keep the warning tone.

Rides along, listed in the proposal's quick fixes: a single
incomplete_expired subscription said "renews <date>"; like a canceled
one it now says nothing.

The design's S10 row now says the hourly invoice sweep re-reads an
invoice still open a day after its last read, so an uncollectible one
reads open for up to a day, not until its next event.

Tests: TestOrgEnrollmentGolden's suspended-subscription changes on
purpose (Small2 delivering with its badge, the supersession warning, no
renewal note), four JSON goldens gain "Suspended": false;
TestSummarizeSubscriptions gains the precedence, no-note, ends and
incomplete-expired rows; TestSubscriptionUnpaidBadgeIsDanger pins the
label and the tones.

Suite 4613 pass, 15 skip, 0 fail after the review's fixes. make screens:
only the known data-only differences (regenerated ids, a domain's age).

Breaks, all CAUGHT:
  pools panel loop filters suspended positions
  subscriptionHeldLadders skips suspended positions
  delivery Suspended never set
  precedence without unpaid ahead of past_due
  paused left out of the precedence
  subscriptions row unpaid badge through the shared key
  subscriptions list unpaid badge through the shared key
  note kept for a suspending state
  unpaid label through the Suspended word
  unpaid not a suspending state for the note
  paused not a suspending state for the note
  past due ranked below paused
  shared unpaid key drawn in danger
  past due drawn as unpaid
  suspending state says nothing even when canceling
  incomplete expired renews
This commit is contained in:
2026-10-24 04:22:47 -04:00
parent ddd57bf162
commit dc2fd46aa0
15 changed files with 169 additions and 99 deletions
@@ -154,7 +154,7 @@
(grant-resumption D9). */}}
{{ range .Deliveries }}
<div class="mt-2">
<p class="mb-0"><strong>{{ .ProductName }}</strong></p>
<p class="mb-0"><strong>{{ .ProductName }}</strong>{{ if .Suspended }} {{ template "statusBadge" .StateBadge }}{{ end }}</p>
<p class="mb-0 small text-muted">
{{ range $i, $rung := .Rungs }}{{ if $i }} · {{ end }}<span title="since {{ $rung.ActivatedAt }}">{{ $rung.LadderName }}, rank {{ $rung.Rank }}</span>{{ end }}
</p>
-14
View File
@@ -20,17 +20,3 @@ 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
}
-23
View File
@@ -4,7 +4,6 @@
package entitlements
import (
"slices"
"testing"
)
@@ -28,25 +27,3 @@ 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)
}
}
+16 -4
View File
@@ -190,9 +190,11 @@ var badgeMap = map[string]Badge{
"trialing": {Label: "Trialing", Tone: "info"},
"past_due": {Label: "Past due", Tone: "danger"},
"paused": {Label: "Paused", Tone: "secondary"},
// The subscription's own unpaid state, as loud as its past-due state.
"subscription_unpaid": {Label: "Unpaid", Tone: "danger"},
// A suspended plan the member can still pay for (the entitlements panel)
"payment_due": {Label: "Payment due", Tone: "danger"},
// Pool lifecycle beyond active/inactive
// Pool lifecycle beyond active/inactive, and a suspended delivery on the Pools panel
"suspended": {Label: "Suspended", Tone: "danger"},
// Tier-change verbs (transition_type; the composite's Tier changes)
"initiate": {Label: "Started", Tone: "light"},
@@ -354,7 +356,7 @@ var knownStates = []string{
"purchasable", "ready_to_grant",
"paid", "unpaid", "overdue", "verified", "unverified", "draft", "published", "retired",
"expired", "canceled", "released", "revoked",
"trialing", "past_due", "paused", "payment_due", "suspended", "incomplete_expired", "initiate", "upgrade", "downgrade", "transfer", "extension", "resumption", "end", "entitlement_set",
"trialing", "past_due", "paused", "payment_due", "subscription_unpaid", "suspended", "incomplete_expired", "initiate", "upgrade", "downgrade", "transfer", "extension", "resumption", "end", "entitlement_set",
"rule_added", "rule_modified", "rule_deactivated",
"open", "void", "uncollectible", "refunded", "partially_paid", "succeeded", "failed", "closed",
"canceling", "shared", "current", "hosted", "awaiting_dns",
@@ -716,13 +718,23 @@ func (OrgEnrollmentData) NoTierChanges() EmptyStateParams {
// surfaces name it the same way); InvoiceBadge renders the state the
// invoices list derives, Overdue included.
func (b BillingSummaryViewModel) SubscriptionsBadge() Badge {
badge := StatusBadge(b.SubscriptionState)
badge := subscriptionStatusBadge(b.SubscriptionState)
if b.SubscriptionState == "canceling" {
return badge.WithTitle("Cancels at the period end and will not renew.")
}
return badge
}
// subscriptionStatusBadge is the badge of a subscription's state. Its unpaid
// state has a key of its own, in the danger tone, because the subscription is
// suspended; the invoice's and the balance's unpaid keep the warning tone.
func subscriptionStatusBadge(state string) Badge {
if state == "unpaid" {
return StatusBadge("subscription_unpaid")
}
return StatusBadge(state)
}
func (BillingSummaryViewModel) BalanceBadge() Badge {
return StatusBadge("unpaid")
}
@@ -855,7 +867,7 @@ func (OperatorInvoiceDetailData) NoLineItems() EmptyStateParams {
func (v InvoiceViewModel) StatusBadge() Badge { return StatusBadge(v.StatusState) }
func (v InvoiceViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
func (v SubscriptionViewModel) StatusBadge() Badge { return StatusBadge(v.StatusState) }
func (v SubscriptionViewModel) StatusBadge() Badge { return subscriptionStatusBadge(v.StatusState) }
func (v SubscriptionViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
func (v PaymentViewModel) StatusBadge() Badge { return StatusBadge(v.StatusState) }
func (v PaymentViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
+50 -24
View File
@@ -201,6 +201,15 @@ type PoolDeliveryViewModel struct {
ProductName string
ProductID string
Rungs []PoolRungViewModel
// Suspended is the delivery's own state: the provision's status, which the
// status trigger copies to every rung, so the first row of the delivery
// decides it.
Suspended bool
}
// StateBadge is the badge after a suspended delivery's product name.
func (PoolDeliveryViewModel) StateBadge() Badge {
return StatusBadge("suspended")
}
// PoolEnrollmentViewModel represents a pool's current enrollment state.
@@ -351,12 +360,16 @@ type BillingSummaryViewModel struct {
}
// subscriptionStatePrecedence ranks the states the card's aggregate badge
// picks from, worst first (design D5, round 5): a past-due subscription is
// what an operator must act on, a canceling one is the next standing fact,
// then the healthy states. Every other state (canceled, unpaid, paused,
// incomplete) ranks below all of these; among equals the newest
// subscription wins, since the query orders by created_at descending.
var subscriptionStatePrecedence = []string{"past_due", "canceling", "active", "trialing"}
// picks from, worst first: the three suspending states come first, because
// each is a subscription whose entitlements are suspended and that the
// operator must act on, and an unpaid subscription is further along that road
// than a past-due one, which Stripe is still retrying; a paused one follows,
// because it has no invoice to collect.
// A canceling subscription is the next standing fact, then the healthy
// states. Every other state (canceled, incomplete) ranks below all of these;
// among equals the newest subscription wins, since the query orders by
// created_at descending.
var subscriptionStatePrecedence = []string{"unpaid", "past_due", "paused", "canceling", "active", "trialing"}
func subscriptionStateRank(state string) int {
for i, s := range subscriptionStatePrecedence {
@@ -395,7 +408,7 @@ func summarizeSubscriptions(subs []billing.Subscription) (count int, state strin
}
}
if len(subs) == 1 {
return 1, worst, subscriptionPeriodNote(worst, subs[0].CurrentPeriodEnd)
return 1, worst, subscriptionPeriodNote(worst, subs[0].CancelAtPeriodEnd, subs[0].CurrentPeriodEnd)
}
inWorst := 0
for _, s := range subs {
@@ -410,14 +423,23 @@ func summarizeSubscriptions(subs []billing.Subscription) (count int, state strin
}
// subscriptionPeriodNote reads the single subscription's period end as the
// event it is: a canceling subscription ends on that date, any other live
// one renews on it. A subscription that has already ended says nothing.
func subscriptionPeriodNote(state string, periodEnd sql.NullTime) string {
if !periodEnd.Valid || state == "canceled" {
// event it is: a subscription set to cancel ends on that date, any other live
// one renews on it. A subscription that has already ended says nothing. A
// subscription in a suspending state does not renew either: Stripe advances a
// past-due or unpaid subscription's period whether or not its invoice is
// paid, and a paused one generates no invoice, so the date names no period
// anyone has paid for. It says nothing unless it is set to cancel, because the
// cancellation still ends it on that date.
func subscriptionPeriodNote(state string, cancelAtPeriodEnd bool, periodEnd sql.NullTime) string {
suspending := state == "past_due" || state == "unpaid" || state == "paused"
if !periodEnd.Valid || state == "canceled" || state == "incomplete_expired" {
return ""
}
if suspending && !cancelAtPeriodEnd {
return ""
}
verb := "renews"
if state == "canceling" {
if state == "canceling" || suspending {
verb = "ends"
}
return verb + " " + periodEnd.Time.Format("Jan 2, 2006")
@@ -1341,14 +1363,14 @@ func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollm
data.PoolMissing = len(allPools) == 0
}
// Every active ladder placement each pool holds, read per pool, every
// pool of any type and status, never through the member side's
// default-pool loader. The names of every pool's rows are read together.
// Every live ladder placement each pool holds, active or suspended,
// read per pool, every pool of any type and status, never through the
// member side's default-pool loader. The names of every pool's rows
// are read together.
positions := make([][]entitlements.ListLadderPositionsByPoolRow, len(allPools))
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)
@@ -1389,7 +1411,9 @@ func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollm
// several ladders at once, and a shared product places ONE delivery on
// several of them. Grouping renders the product once; per-ladder facts
// become rung pills (maintainer design round 2026-08-24). Deliveries keep
// the order of their first placement, rungs the order of the rows.
// the order of their first placement, rungs the order of the rows. A
// suspended position is listed, with the delivery marked Suspended: it is the
// organization's plan while it delivers nothing.
func poolDeliveries(positions []entitlements.ListLadderPositionsByPoolRow, names *operatorNames) []PoolDeliveryViewModel {
var deliveries []PoolDeliveryViewModel
deliveryIndex := map[string]int{}
@@ -1399,6 +1423,7 @@ func poolDeliveries(positions []entitlements.ListLadderPositionsByPoolRow, names
d := PoolDeliveryViewModel{
ProvisionID: att.ProvisionID,
ProductID: att.ProductID,
Suspended: att.PositionStatus == "suspended",
}
d.ProductName, _ = names.Product(att.ProductID)
di = len(deliveries)
@@ -1784,12 +1809,13 @@ func (h *OperatorPartialsHandler) productsOnLadders(r *http.Request, ladders map
}
// subscriptionHeldLadders returns the set of plan ladders currently occupied by
// 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 ExcludeSuspended keeps it, the filter the
// Pools panel applies too.
// a subscription-sourced provision, active or suspended, 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 suspended position is held: core.confer supersedes
// it as it does an active one, and the subscription stays in Stripe until it
// is changed in the billing view.
func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools []entitlements.ResourcePool) map[string]bool {
held := map[string]bool{}
for _, pool := range pools {
@@ -1797,7 +1823,7 @@ func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools
if err != nil {
continue
}
for _, pos := range entitlements.ExcludeSuspended(positions) {
for _, pos := range positions {
if pos.SubscriptionID.Valid {
held[pos.PlanLadderID] = true
}
@@ -336,10 +336,11 @@ func TestOrgBillingListURLs(t *testing.T) {
}
// TestSummarizeSubscriptions pins the aggregate the card renders: the count,
// the worst state across every subscription (past_due > canceling > active >
// trialing > everything else), the exception note when the worst state is
// not the whole account's state, and the single subscription's period
// clause.
// the worst state across every subscription (unpaid > past_due > paused >
// canceling > active > trialing > everything else), the exception note when
// the worst state is not the whole account's state, and the single
// subscription's period clause, which a suspending state carries only when it
// is set to cancel.
func TestSummarizeSubscriptions(t *testing.T) {
end := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
at := func(status string, canceling bool, periodEnd bool) billing.Subscription {
@@ -367,7 +368,15 @@ func TestSummarizeSubscriptions(t *testing.T) {
{name: "active outranks trialing", subs: []billing.Subscription{at("trialing", false, true), at("active", false, true)}, wantCount: 2, wantState: "active", wantNote: "1 active"},
{name: "trialing outranks canceled", subs: []billing.Subscription{at("canceled", false, true), at("trialing", false, true)}, wantCount: 2, wantState: "trialing", wantNote: "1 trialing"},
{name: "uniform states need no note", subs: []billing.Subscription{at("active", false, true), at("active", false, true)}, wantCount: 2, wantState: "active"},
{name: "cancel at period end on a past-due subscription reads past due", subs: []billing.Subscription{at("past_due", true, true)}, wantCount: 1, wantState: "past_due", wantNote: "renews Mar 1, 2026"},
{name: "cancel at period end on a past-due subscription reads past due", subs: []billing.Subscription{at("past_due", true, true)}, wantCount: 1, wantState: "past_due", wantNote: "ends Mar 1, 2026"},
{name: "cancel at period end on an unpaid subscription reads unpaid", subs: []billing.Subscription{at("unpaid", true, true)}, wantCount: 1, wantState: "unpaid", wantNote: "ends Mar 1, 2026"},
{name: "cancel at period end on a paused subscription reads paused", subs: []billing.Subscription{at("paused", true, true)}, wantCount: 1, wantState: "paused", wantNote: "ends Mar 1, 2026"},
{name: "unpaid outranks past due", subs: []billing.Subscription{at("past_due", false, true), at("unpaid", false, true)}, wantCount: 2, wantState: "unpaid", wantNote: "1 unpaid"},
{name: "past due outranks paused", subs: []billing.Subscription{at("paused", false, true), at("past_due", false, true)}, wantCount: 2, wantState: "past_due", wantNote: "1 past due"},
{name: "paused outranks canceling", subs: []billing.Subscription{at("active", true, true), at("paused", false, true)}, wantCount: 2, wantState: "paused", wantNote: "1 paused"},
{name: "one unpaid carries no note", subs: []billing.Subscription{at("unpaid", false, true)}, wantCount: 1, wantState: "unpaid"},
{name: "one incomplete expired says nothing", subs: []billing.Subscription{at("incomplete_expired", false, true)}, wantCount: 1, wantState: "incomplete_expired"},
{name: "one paused carries no note", subs: []billing.Subscription{at("paused", false, true)}, wantCount: 1, wantState: "paused"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -1240,3 +1249,30 @@ func TestPoolCardTypeBadgeOnlyForSharedOrDedicated(t *testing.T) {
}
}
}
// TestSubscriptionUnpaidBadgeIsDanger pins that a subscription's unpaid state
// is drawn in the danger tone on the Billing card and in the subscriptions
// list, the other live states keep the badge StatusBadge gives it, and the
// shared unpaid key (the invoice's and the balance's) stays warning.
func TestSubscriptionUnpaidBadgeIsDanger(t *testing.T) {
if got := (BillingSummaryViewModel{SubscriptionState: "unpaid"}).SubscriptionsBadge().Tone; got != "danger" {
t.Errorf("SubscriptionsBadge(unpaid).Tone = %q, want \"danger\"", got)
}
if got := (BillingSummaryViewModel{SubscriptionState: "unpaid"}).SubscriptionsBadge().Label; got != "Unpaid" {
t.Errorf("SubscriptionsBadge(unpaid).Label = %q, want \"Unpaid\"", got)
}
if got := (SubscriptionViewModel{StatusState: "unpaid"}).StatusBadge().Tone; got != "danger" {
t.Errorf("SubscriptionViewModel(unpaid).StatusBadge().Tone = %q, want \"danger\"", got)
}
if got := StatusBadge("unpaid").Tone; got != "warning" {
t.Errorf("StatusBadge(unpaid).Tone = %q, want \"warning\"", got)
}
for _, state := range []string{"active", "past_due", "paused", "trialing"} {
if got, want := (BillingSummaryViewModel{SubscriptionState: state}).SubscriptionsBadge(), StatusBadge(state); got != want {
t.Errorf("SubscriptionsBadge(%s) = %+v, want %+v", state, got, want)
}
if got, want := (SubscriptionViewModel{StatusState: state}).StatusBadge(), StatusBadge(state); got != want {
t.Errorf("SubscriptionViewModel(%s).StatusBadge() = %+v, want %+v", state, got, want)
}
}
}
+2 -1
View File
@@ -201,7 +201,8 @@
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
}
],
"Usage": null,
@@ -253,7 +253,8 @@
"Rank": 1,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
},
{
"ProvisionID": "<provision:Plus extended>",
@@ -270,7 +271,8 @@
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
}
],
"Usage": [
+4 -2
View File
@@ -239,7 +239,8 @@
"Rank": 1,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
},
{
"ProvisionID": "<provision:Plus extended>",
@@ -256,7 +257,8 @@
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
}
],
"Usage": [
+4 -2
View File
@@ -201,7 +201,8 @@
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
}
],
"Usage": null,
@@ -223,7 +224,8 @@
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
]
],
"Suspended": false
}
],
"Usage": null,
@@ -54,8 +54,6 @@
<span class="badge text-bg-danger">Past due</span>
<a href="/operator/billing/subscriptions?q=suspended-subscription&#43;">1 subscription</a>
<small class="text-muted">· renews Jan 15, 2099</small>
</div>
</div>
@@ -87,12 +85,17 @@
</h3>
<p class="text-muted mb-0">No plan is delivering into this pool</p>
</div>
</div>
<div class="mt-2">
<p class="mb-0"><strong>Small2</strong> <span class="badge text-bg-danger">Suspended</span></p>
<p class="mb-0 small text-muted">
<span title="since <today> <hh:mm>">Box, rank 0</span>
</p>
</div>
</div>
</div>
@@ -137,7 +140,7 @@
<form id="form-operator.enrollment.grant.issue" data-form="operator.enrollment.grant.issue" data-form-kind="create" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:suspended-subscription>/grant/create" hx-target="#operator-body" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-operator.enrollment.grant.issue-error" class="app-form-error" role="alert"></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.</div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view.</div></div>
@@ -159,7 +162,7 @@
<option value="<product:Plus>">Plus</option>
<option value="<product:Pro>">Pro</option>
<option value="<product:Small>">Small</option>
<option value="<product:Small2>">Small2</option>
<option value="<product:Small2>">Small2 ⚠</option>
<option value="<product:Starter>">Starter</option>
<option value="<product:Starter2>">Starter2</option>
<option value="<product:Top>">Top</option>
@@ -6,7 +6,7 @@
"AccountStatus": "active",
"SubscriptionCount": 1,
"SubscriptionState": "past_due",
"SubscriptionNote": "renews Jan 15, 2099",
"SubscriptionNote": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
@@ -150,7 +150,7 @@
"Name": "Small2",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
"SupersedesSubscription": true
},
{
"ProductID": "<product:Starter>",
@@ -190,9 +190,23 @@
"PoolName": "Default",
"PoolType": "default",
"Status": "active",
"Deliveries": null,
"Deliveries": [
{
"ProvisionID": "<uuid>",
"ProductName": "Small2",
"ProductID": "<product:Small2>",
"Rungs": [
{
"LadderName": "Box",
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
],
"Suspended": true
}
],
"Usage": null,
"HasAttachment": false
"HasAttachment": true
}
],
"ProductsError": "",
@@ -135,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 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 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 `renews <date>` note for `past_due`, `unpaid` or `paused`, and `ends <date>` when the subscription is set to cancel at the period end, as for an active one. A suspending status with `cancel_at_period_end` stays its status, as today (`subscriptionState`). A single `incomplete_expired` subscription says nothing, as a `canceled` one does. 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.
@@ -197,7 +197,7 @@ Each doc moves with the section whose code it describes (`CONTRIBUTING.md`, "Doc
| F20 | An `unpaid` subscription with several open invoices | n/a | The card's link pays the latest when it is open; each invoice's detail links to its own |
| F21 | Operator Pools panel | "No plan is delivering into this pool" | The delivery with its rungs and a Suspended badge |
| F22 | Operator Issue grant advisory | No warning | The warning, as for an active subscription. A grant issued under it over a suspended subscription is ended when the subscription recovers (#212 covers an active subscription at any reconcile too) |
| F23 | Operator Billing card | `unpaid` and `paused` rank below `active`; a single suspended subscription reads "renews {date}" | Worst first; no note for a suspending state |
| F23 | Operator Billing card | `unpaid` and `paused` rank below `active`; a single suspended subscription reads "renews {date}" | Worst first; no `renews` note for a suspending state, `ends {date}` when it is set to cancel |
| F24 | The cancellation badge's tooltip on a suspended plan | "access continues until this date" | No tooltip |
| F25 | Floor (`ReapplyDefaultsIfVacant`) | Counts a suspended position as occupied | Unchanged |
| F26 | Reconcile, suspended branch, and a downgrade due during suspension | Suspends only; the row stays scheduled and the sweeper warns daily | Unchanged (D14) |
@@ -241,7 +241,7 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| # | Where | Text |
|---|---|---|
| 13 | Pools panel, a suspended delivery, after the product's name | the existing badge `Suspended` (danger tone) |
| 14 | Billing card, a single subscription in `past_due`, `unpaid` or `paused` | no note, in place of `renews <date>` |
| 14 | Billing card, a single subscription in `past_due`, `unpaid` or `paused` | no note, in place of `renews <date>`; a subscription set to cancel keeps `ends <date>` |
| 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, the existing state badges `Past due` and `Paused` on the Billing card, and the Pay invoice routes' 404 page (`The page you requested does not exist.`) and 500 page (`An unexpected error occurred. Try again.`).
@@ -281,7 +281,7 @@ The local sources are the stripe-go v81.4.0 doc comments (API version `2025-02-2
| 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 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 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 |
| S10 | The hosted page of an `uncollectible` invoice still takes payment; the webhook does not handle `invoice.marked_uncollectible` (`webhook_invoice.go:24-27`), so the console keeps such an invoice `open` until the hourly invoice sweep re-reads it, up to about a day later (`invoice_sweep.go`), and its detail offers Pay invoice until then | 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 |
| S11 | An abandoned upgrade's open invoice after `pending_update.expires_at`: `upgradeNow` voids it only while the pending update exists (`plan_change.go:300`), so if Stripe leaves it `open`, the detail's Pay invoice charges a proration for a move Stripe no longer applies | Not settled by the plain sandbox run. Settled only with a test-clock customer, through the CLI, because the console never maps one (S10): set `4000 0027 6000 3184` as the subscription's default payment method, update it to a higher price with `--payment-behavior pending_if_incomplete --proration-behavior always_invoice`, leave the invoice unpaid, advance the clock past `pending_update.expires_at`, and read the invoice's status. Unsettled, a member may pay that proration; if the invoice stays `open`, that is a candidate issue: void it in the sweep, or withhold `PayPath` for it |
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).
@@ -29,7 +29,8 @@ 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 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; one set to cancel at the period end keeps "ends {date}", as an active one does.
- **A single `incomplete_expired` subscription on the operator Billing card says nothing.** It reads "renews {date}" today, though the reconcile classifies it as ended; it now says nothing, as a `canceled` one does.
- **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.
@@ -155,13 +155,13 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
## 6. The operator's organization page, the Billing card, and the sandbox check
- [ ] 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"`.
- [x] 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.
- [x] 6.2 `ExcludeSuspended` and `TestExcludeSuspendedKeepsEverythingElse` are deleted, since no reader calls it (`deadcode` would refuse it).
- [x] 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` unless the subscription is set to cancel at the period end, which keeps `ends <date>` as an active one does, and its doc comment says why; a single `incomplete_expired` subscription says nothing, as a `canceled` one does (a ride-along fix, listed in the proposal). 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.
- [x] 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 `ends Mar 1, 2026`, 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, cancel at period end on an unpaid and on a paused subscription each reads `ends Mar 1, 2026`, one incomplete expired says nothing; 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`, and `SubscriptionsBadge()` for `unpaid` has the label `Unpaid`; failures read `SubscriptionsBadge(unpaid).Label = %q, want "Unpaid"`, `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:
- [x] 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);
@@ -169,7 +169,15 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
- `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 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", "")`.
- the suspending branch removed, so a suspending state keeps a note: caught by the unpaid row, `want_new` `summarizeSubscriptions = (1, "unpaid", "ends Mar 1, 2026"), want (1, "unpaid", "")`;
- the suspending branch returning `""` before the cancel check: caught by the past-due cancel row, `want_new` `summarizeSubscriptions = (1, "past_due", ""), want (1, "past_due", "ends Mar 1, 2026")`;
- `unpaid` left out of the suspending states: caught by the unpaid row, `want_new` `summarizeSubscriptions = (1, "unpaid", "renews Mar 1, 2026"), want (1, "unpaid", "")`;
- `paused` left out of the suspending states: caught by the paused row, `want_new` `summarizeSubscriptions = (1, "paused", "renews Mar 1, 2026"), want (1, "paused", "")`;
- `past_due` ranked below `paused`: caught by the row "past due outranks paused", `want_new` `summarizeSubscriptions = (2, "paused", "1 paused"), want (2, "past_due", "1 past due")`;
- `subscription_unpaid`'s label changed to `Suspended`: caught by the badge methods' test, `want_new` `SubscriptionsBadge(unpaid).Label = "Suspended", want "Unpaid"`;
- the shared `unpaid` key drawn in the danger tone: caught by the same test, `want_new` `StatusBadge(unpaid).Tone = "danger", want "warning"`;
- a past-due subscription drawn through the unpaid key: caught by the same test, `want_new` `SubscriptionsBadge(past_due) = {Label:Unpaid`;
- `incomplete_expired` not ending the note: caught by the incomplete expired row, `want_new` `summarizeSubscriptions = (1, "incomplete_expired", "renews Mar 1, 2026"), want (1, "incomplete_expired", "")`.
## 7. Landing