diff --git a/plans/rank.go b/plans/rank.go new file mode 100644 index 0000000..6c7ba41 --- /dev/null +++ b/plans/rank.go @@ -0,0 +1,57 @@ +// rank.go — totally-ordered rank of plan tiers, shared across api/, worker/, +// and any future module that needs to classify a tier transition as an +// upgrade vs a downgrade. +// +// Two package-private rank functions used to live in the api repo +// (internal/handlers/billing.go::tierRank and +// internal/handlers/admin_customers.go::adminTierRank). They had subtly +// different orderings — billing.go covered 6 tiers (anonymous .. team), +// admin_customers.go covered 4 (free .. team) and was off-by-one against +// billing for the same names. The discrepancy never bit production because +// the admin surface never sees anonymous/growth, but it's a footgun waiting +// to happen the moment the admin surface is widened. +// +// This file promotes a single canonical ordering. Callers compare ranks +// (a.rank < b.rank ⇒ a is "lower tier") and MUST guard against the -1 +// sentinel returned for unknown tiers. + +package plans + +import "strings" + +// Rank returns a totally-ordered integer rank for the given plan tier name. +// Higher rank = more capacity. The canonical ordering is: +// +// anonymous = 0 +// free = 1 +// hobby = 2 +// growth = 3 +// pro = 4 +// team = 5 +// +// Unknown tiers return -1. Callers that compare ranks to classify a +// transition (upgrade vs downgrade vs renewal) MUST treat -1 as the +// "no transition direction" verdict — i.e. emit no audit row rather than +// guess which way an unknown tier sits relative to a known one. +// +// The function is intentionally case- and whitespace-insensitive so callers +// don't need to pre-normalise. The "*_yearly" billing variants are NOT +// special-cased here — pass them through CanonicalTier first if you want +// "pro_yearly" to rank the same as "pro" (billing.go does exactly this). +func Rank(tier string) int { + switch strings.ToLower(strings.TrimSpace(tier)) { + case "anonymous": + return 0 + case "free": + return 1 + case "hobby": + return 2 + case "growth": + return 3 + case "pro": + return 4 + case "team": + return 5 + } + return -1 +} diff --git a/plans/rank_test.go b/plans/rank_test.go new file mode 100644 index 0000000..8a4f13b --- /dev/null +++ b/plans/rank_test.go @@ -0,0 +1,86 @@ +package plans_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "instant.dev/common/plans" +) + +// TestRank_AllStandardTiers asserts the canonical ordering documented in +// rank.go. Lock-in test — changing any of these values is an API break +// because callers compare ranks across modules (api/, worker/). +func TestRank_AllStandardTiers(t *testing.T) { + cases := map[string]int{ + "anonymous": 0, + "free": 1, + "hobby": 2, + "growth": 3, + "pro": 4, + "team": 5, + } + for tier, want := range cases { + t.Run(tier, func(t *testing.T) { + assert.Equal(t, want, plans.Rank(tier), + "Rank(%q) — canonical ordering must remain stable", tier) + }) + } +} + +// TestRank_UnknownReturnsMinusOne covers the sentinel contract: any tier +// name not in the canonical list returns -1 so callers can short-circuit +// rather than guess a direction. +func TestRank_UnknownReturnsMinusOne(t *testing.T) { + cases := []string{ + "", + "enterprise", + "premium", + "basic", + "unknown", + "pro_yearly", // Yearly variants must be normalised via CanonicalTier first. + "hobby_yearly", + " ", // Empty-after-trim stays unknown. + "pro-yearly", + "freetier", + } + for _, tier := range cases { + t.Run(tier, func(t *testing.T) { + if tier == "pro_yearly" || tier == "hobby_yearly" { + // Yearly variants intentionally return -1 — callers MUST + // pass them through CanonicalTier first. This row asserts + // the "don't auto-normalise" contract. + assert.Equal(t, -1, plans.Rank(tier), + "Rank(%q) — yearly variants must NOT auto-normalise (callers use CanonicalTier)", tier) + return + } + assert.Equal(t, -1, plans.Rank(tier), + "Rank(%q) — unknown tier must return -1 sentinel", tier) + }) + } +} + +// TestRank_MonotonicallyIncreasing asserts that the canonical chain +// anonymous < free < hobby < growth < pro < team is strictly increasing. +// This is the property callers actually depend on (a.rank < b.rank ⇒ +// a is the lower tier); the absolute values in TestRank_AllStandardTiers +// could in principle be remapped, but the relative ordering can't. +func TestRank_MonotonicallyIncreasing(t *testing.T) { + chain := []string{"anonymous", "free", "hobby", "growth", "pro", "team"} + for i := 1; i < len(chain); i++ { + prev := plans.Rank(chain[i-1]) + curr := plans.Rank(chain[i]) + assert.Less(t, prev, curr, + "Rank(%q)=%d must be strictly less than Rank(%q)=%d", + chain[i-1], prev, chain[i], curr) + } +} + +// TestRank_CaseInsensitive covers the documented case-insensitive +// behaviour — callers shouldn't need to normalise before calling. +func TestRank_CaseInsensitive(t *testing.T) { + assert.Equal(t, 4, plans.Rank("PRO")) + assert.Equal(t, 4, plans.Rank("Pro")) + assert.Equal(t, 4, plans.Rank("pRo")) + assert.Equal(t, 2, plans.Rank(" hobby ")) +}