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:
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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": ""
|
||||
}
|
||||
+2
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user