Show a suspended plan in the member's entitlements panel with its state

The "Your entitlements" panel listed only active plan positions, so a
plan whose payment is due read as no plan at all. It now lists every
live position, one line per ladder from the ladder's first row in query
order, which is its active row when it has one, and badges a suspended
line "Payment due" (danger) or "Paused" (secondary). An active row
never carries a badge, whatever its subscription's status says. The
badge comes from badgeMap through a new payment_due key, so the map
stays the one place a state becomes a label.

orgHasEntitlement counts a suspended plan too. Its only caller is the
dashboard's empty state, which grants no access, so a member whose
payment is due is no longer told they hold nothing.

Tests: TestLadderRowsYieldOneLinePerLadder (one line per ladder from its
active row, and an active row whose subscription is past_due carries no
badge); TestMemberEntitlementsGolden's past-due-plan changes on purpose
(the plan's line with Payment due, and Enrolled true, which the
template's empty-state check reads) and gains paused-plan; three JSON
goldens gain "State": "" and nothing else; TestOrgHasEntitlement's
payment-due row now wants true.

Suite 4605 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:
  ladderRows keeps ExcludeSuspended
  State always payment_due
  the template without the badge
  orgHasEntitlement keeps ExcludeSuspended
  ladderRows without the one-line skip
  subscription status decides an active row
This commit is contained in:
2026-10-24 03:43:12 -04:00
parent a7bea2fadf
commit ddd57bf162
15 changed files with 187 additions and 36 deletions
@@ -8,7 +8,8 @@
usage meters, granted on/off capabilities, and source
attribution. Badges render through statusBadge (member-facing labels
only; ui-vocabulary D4.3 bans raw-key badges, so the raw resource
key is demoted to a title attribute). */}}
key is demoted to a title attribute). A suspended plan's line carries
a Payment due or Paused badge after the tier name. */}}
{{ if .Error }}
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
@@ -19,7 +20,7 @@
{{ range .PoolTiers }}
<div class="mb-2">
{{ if .HasTier }}
<span>{{ .LadderName }}: you are on <strong>{{ .TierName }}</strong></span>
<span>{{ .LadderName }}: you are on <strong>{{ .TierName }}</strong>{{ if .State }} {{ template "statusBadge" .StateBadge }}{{ end }}</span>
{{ else }}
<span class="text-muted">No active plan</span>
{{ end }}
+9 -1
View File
@@ -190,6 +190,8 @@ var badgeMap = map[string]Badge{
"trialing": {Label: "Trialing", Tone: "info"},
"past_due": {Label: "Past due", Tone: "danger"},
"paused": {Label: "Paused", Tone: "secondary"},
// A suspended plan the member can still pay for (the entitlements panel)
"payment_due": {Label: "Payment due", Tone: "danger"},
// Pool lifecycle beyond active/inactive
"suspended": {Label: "Suspended", Tone: "danger"},
// Tier-change verbs (transition_type; the composite's Tier changes)
@@ -352,7 +354,7 @@ var knownStates = []string{
"purchasable", "ready_to_grant",
"paid", "unpaid", "overdue", "verified", "unverified", "draft", "published", "retired",
"expired", "canceled", "released", "revoked",
"trialing", "past_due", "paused", "suspended", "incomplete_expired", "initiate", "upgrade", "downgrade", "transfer", "extension", "resumption", "end", "entitlement_set",
"trialing", "past_due", "paused", "payment_due", "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",
@@ -1468,6 +1470,12 @@ func (BooleanEntitlementViewModel) IncludedBadge() Badge {
return StatusBadge("included")
}
// StateBadge is the badge after a suspended plan's tier name: "Payment due"
// in the danger tone, or "Paused" in the secondary tone.
func (t PoolTierViewModel) StateBadge() Badge {
return StatusBadge(t.State)
}
func (s EntitlementSourceViewModel) ReasonBadge() Badge {
return StatusBadge(s.Reason)
}
@@ -16,7 +16,7 @@ import (
// TestOrgHasEntitlement pins what the dashboard counts as holding an
// entitlement: a numeric limit, a granted boolean or a rung on a ladder in the
// default pool, and nothing else.
// default pool (a suspended plan's rung included), and nothing else.
func TestOrgHasEntitlement(t *testing.T) {
ctx := context.Background()
db := dbtest.Open(t)
@@ -78,7 +78,7 @@ func TestOrgHasEntitlement(t *testing.T) {
o := w.Org("Due", orgType, owner)
w.Suspend(w.Subscribe(o, world.SubOpts{}, due), "past_due")
return o.ID
}, false},
}, true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := orgHasEntitlement(ctx, q, tc.build(t, world.New(t, db))); got != tc.want {
+24 -9
View File
@@ -102,6 +102,10 @@ type PoolTierViewModel struct {
LadderName string
TierName string
HasTier bool
// State is "" for an active position and for the off-plan line,
// "payment_due" for a suspended one and "paused" for a suspended one
// whose subscription is paused (suspensionOf, design D2).
State string
}
// EntitlementsData holds data for the member_entitlements.html partial.
@@ -271,13 +275,15 @@ func (h *MemberProductsHandler) sourceRows(ctx context.Context, orgID string) []
return rows
}
// ladderRows resolves the default pool's active ladder attachments.
// "Current" is a set (one rung per enrolled ladder), so it emits one line per
// active attachment, not just the first, named by its ladder and ordered as
// the plans region orders its ladders (sort_order, name). A pool with no
// attachment gets a single off-plan line. Only enrolled axes are listed;
// discovering unjoined ladders is the catalog's job. enrolled is true when
// the pool holds a rung on at least one ladder.
// ladderRows resolves the default pool's live ladder attachments, active and
// suspended. "Current" is a set (one rung per enrolled ladder), so it emits
// one line per ladder, not just the first, named by its ladder and ordered as
// the plans region orders its ladders (sort_order, name). The rows come
// active first, so the first row of a ladder is the one kept and a later row
// of the same ladder is skipped; a suspended plan's line carries its state.
// A pool with no attachment gets a single off-plan line. Only enrolled axes
// are listed; discovering unjoined ladders is the catalog's job. enrolled is
// true when the pool holds a rung on at least one ladder.
func (h *MemberProductsHandler) ladderRows(ctx context.Context, poolID string) (tiers []PoolTierViewModel, enrolled bool, err error) {
ladders, err := h.BillingQ.ListPlanLadders(ctx)
if err != nil {
@@ -297,12 +303,21 @@ 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{})
}
seen := make(map[string]bool, len(positions))
for _, row := range positions {
vm := PoolTierViewModel{LadderName: ladderName[row.PlanLadderID], TierName: row.ProductName, HasTier: true}
if seen[row.PlanLadderID] {
continue
}
seen[row.PlanLadderID] = true
vm := PoolTierViewModel{
LadderName: ladderName[row.PlanLadderID],
TierName: row.ProductName,
HasTier: true,
State: suspensionOf(row.PositionStatus, row.SubscriptionStatus.String),
}
pos, ok := ladderPos[row.PlanLadderID]
if !ok {
pos = len(ladderPos)
@@ -3,7 +3,7 @@
package server
// The entitlements panel, characterized state by state: for each of five
// The entitlements panel, characterized state by state: for each of six
// 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,12 +50,12 @@ func (f *entitlementsGoldenFixture) orgNamed(t *testing.T, state string) string
return ""
}
// newEntitlementsGoldenFixture builds the five organizations: one without a
// newEntitlementsGoldenFixture builds the six 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, 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.
// sponsor's perks with a lapsed trial and some usage, one on a default rung
// whose set has no rule, one subscribed to a plan whose payment is due, and
// one subscribed to a paused plan. 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()
registerGoldenKeys(t, w)
@@ -125,6 +125,9 @@ func newEntitlementsGoldenFixture(ctx context.Context, t *testing.T, w *world.Wo
o = org("past-due-plan", plain)
w.Suspend(w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, due), "past_due")
o = org("paused-plan", plain)
w.Suspend(w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, due), "paused")
return f
}
@@ -4,8 +4,14 @@
package server
import (
"context"
"database/sql"
"log/slog"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// Resource rows lead with the human label and integration attribution;
@@ -84,3 +90,61 @@ func TestBooleanEntitlementRows(t *testing.T) {
t.Errorf("Included badge rendered with no granted booleans")
}
}
// positionRowsQ answers ListLadderPositionsByPool with its rows, in the
// query's order.
type positionRowsQ struct {
entitlements.Querier
rows []entitlements.ListLadderPositionsByPoolRow
}
func (q positionRowsQ) ListLadderPositionsByPool(context.Context, string) ([]entitlements.ListLadderPositionsByPoolRow, error) {
return q.rows, nil
}
// planLaddersQ answers ListPlanLadders with its ladders.
type planLaddersQ struct {
billing.Querier
ladders []billing.PlanLadder
}
func (q planLaddersQ) ListPlanLadders(context.Context) ([]billing.PlanLadder, error) {
return q.ladders, nil
}
// TestLadderRowsYieldOneLinePerLadder pins the panel's lines over rows the
// console cannot write today: a ladder holding an active and a suspended row
// yields one line, from the active row the query returns first, and the
// position's status decides the badge while the subscription's only names it.
func TestLadderRowsYieldOneLinePerLadder(t *testing.T) {
status := func(s string) sql.NullString { return sql.NullString{String: s, Valid: true} }
h := &MemberProductsHandler{
Logger: slog.New(slog.DiscardHandler),
BillingQ: planLaddersQ{ladders: []billing.PlanLadder{
{PlanLadderID: "b", Name: "Hosting"}, {PlanLadderID: "a", Name: "Support"}, {PlanLadderID: "c", Name: "Storage"},
}},
EntitlementsQ: positionRowsQ{rows: []entitlements.ListLadderPositionsByPoolRow{
{PlanLadderID: "a", ProductName: "Pro", PositionStatus: "active", SubscriptionStatus: status("past_due")},
{PlanLadderID: "a", ProductName: "Plus", PositionStatus: "suspended", SubscriptionStatus: status("past_due")},
{PlanLadderID: "b", ProductName: "Basic", PositionStatus: "suspended", SubscriptionStatus: status("active")},
{PlanLadderID: "c", ProductName: "Lite", PositionStatus: "suspended", SubscriptionStatus: status("paused")},
}},
}
tiers, enrolled, err := h.ladderRows(context.Background(), "pool")
if err != nil || !enrolled {
t.Fatalf("ladderRows error %v, enrolled %v, want no error and enrolled", err, enrolled)
}
want := []PoolTierViewModel{
{LadderName: "Hosting", TierName: "Basic", HasTier: true, State: "payment_due"},
{LadderName: "Support", TierName: "Pro", HasTier: true},
{LadderName: "Storage", TierName: "Lite", HasTier: true, State: "paused"},
}
if len(tiers) != len(want) {
t.Fatalf("ladderRows yields %d lines, want one per ladder: %+v", len(tiers), tiers)
}
for i := range want {
if tiers[i] != want[i] {
t.Errorf("line %d = %+v, want %+v", i, tiers[i], want[i])
}
}
}
+3 -2
View File
@@ -301,7 +301,8 @@ func (d IndexPageData) IsEmpty() bool {
// orgHasEntitlement reports whether the person's organization holds any
// entitlement: a numeric or granted-boolean entitlement in its default
// pool, or a rung on any plan ladder — the same facts
// pool, or a rung on any plan ladder (a suspended plan counts: the
// organization still holds it) — the same facts
// member_entitlements.go's GetEntitlements combines into HasEntitlements /
// Enrolled for the Products page's own empty state, condensed to one bool
// since the dashboard renders no entitlement detail of its own. An
@@ -322,7 +323,7 @@ func orgHasEntitlement(ctx context.Context, entitlementsQ entitlements.Querier,
}
}
}
if positions, err := entitlementsQ.ListLadderPositionsByPool(ctx, pool.PoolID); err == nil && len(entitlements.ExcludeSuspended(positions)) > 0 {
if positions, err := entitlementsQ.ListLadderPositionsByPool(ctx, pool.PoolID); err == nil && len(positions) > 0 {
return true
}
return false
@@ -45,12 +45,14 @@
{
"LadderName": "Zeta",
"TierName": "Starter",
"HasTier": true
"HasTier": true,
"State": ""
},
{
"LadderName": "Alpha",
"TierName": "Starter",
"HasTier": true
"HasTier": true,
"State": ""
}
],
"Error": ""
@@ -8,7 +8,8 @@
{
"LadderName": "",
"TierName": "",
"HasTier": false
"HasTier": false,
"State": ""
}
],
"Error": ""
@@ -3,7 +3,7 @@
<div class="mb-2">
<span class="text-muted">No active plan</span>
<span>Due: you are on <strong>Due</strong> <span class="badge text-bg-danger">Payment due</span></span>
</div>
@@ -1,5 +1,5 @@
{
"Enrolled": false,
"Enrolled": true,
"HasEntitlements": true,
"Entitlements": [
{
@@ -14,9 +14,10 @@
"Sources": null,
"PoolTiers": [
{
"LadderName": "",
"TierName": "",
"HasTier": false
"LadderName": "Due",
"TierName": "Due",
"HasTier": true,
"State": "payment_due"
}
],
"Error": ""
@@ -0,0 +1,30 @@
<div class="mb-2">
<span>Due: you are on <strong>Due</strong> <span class="badge text-bg-secondary">Paused</span></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,24 @@
{
"Enrolled": true,
"HasEntitlements": true,
"Entitlements": [
{
"ResourceKey": "plan_golden_sites",
"Label": "Plan Sites",
"IntegrationName": "",
"ResourceLimit": 0,
"CurrentUsage": 0
}
],
"BooleanEntitlements": null,
"Sources": null,
"PoolTiers": [
{
"LadderName": "Due",
"TierName": "Due",
"HasTier": true,
"State": "paused"
}
],
"Error": ""
}
@@ -14,7 +14,8 @@
{
"LadderName": "Entry",
"TierName": "Empty",
"HasTier": true
"HasTier": true,
"State": ""
}
],
"Error": ""
@@ -142,12 +142,12 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
## 5. The entitlements panel and the dashboard count the suspended plan
- [ ] 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.
- [x] 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.
- [x] 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.
- [x] 5.3 `orgHasEntitlement` (`internal/server/server.go:310-329`) no longer calls `ExcludeSuspended`; its doc comment says that a suspended plan counts.
- [x] 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:
- [x] 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;