diff --git a/internal/embeds/templates/partials/operator_enrollment.html b/internal/embeds/templates/partials/operator_enrollment.html index 2eb43546..0044ec31 100644 --- a/internal/embeds/templates/partials/operator_enrollment.html +++ b/internal/embeds/templates/partials/operator_enrollment.html @@ -154,7 +154,7 @@ (grant-resumption D9). */}} {{ range .Deliveries }}
{{ .ProductName }}
+{{ .ProductName }}{{ if .Suspended }} {{ template "statusBadge" .StateBadge }}{{ end }}
{{ range $i, $rung := .Rungs }}{{ if $i }} · {{ end }}{{ $rung.LadderName }}, rank {{ $rung.Rank }}{{ end }}
diff --git a/internal/entitlements/positions.go b/internal/entitlements/positions.go index 2b559707..394df2ec 100644 --- a/internal/entitlements/positions.go +++ b/internal/entitlements/positions.go @@ -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 -} diff --git a/internal/entitlements/positions_test.go b/internal/entitlements/positions_test.go index 7753351a..75c96494 100644 --- a/internal/entitlements/positions_test.go +++ b/internal/entitlements/positions_test.go @@ -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) - } -} diff --git a/internal/server/anatomy.go b/internal/server/anatomy.go index fd4b52e5..5c98233b 100644 --- a/internal/server/anatomy.go +++ b/internal/server/anatomy.go @@ -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) } diff --git a/internal/server/operator_enrollment.go b/internal/server/operator_enrollment.go index 6e43f994..334ca5ce 100644 --- a/internal/server/operator_enrollment.go +++ b/internal/server/operator_enrollment.go @@ -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 } diff --git a/internal/server/operator_enrollment_render_test.go b/internal/server/operator_enrollment_render_test.go index 971ccd27..40a33532 100644 --- a/internal/server/operator_enrollment_render_test.go +++ b/internal/server/operator_enrollment_render_test.go @@ -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) + } + } +} diff --git a/internal/server/testdata/org_enrollment/default-only.json b/internal/server/testdata/org_enrollment/default-only.json index fb2c90ef..7f8e5efc 100644 --- a/internal/server/testdata/org_enrollment/default-only.json +++ b/internal/server/testdata/org_enrollment/default-only.json @@ -201,7 +201,8 @@ "Rank": 0, "ActivatedAt": "No plan is delivering into this pool
- +Small2 Suspended
++ Box, rank 0 +
+