Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions plans/rank.go
Original file line number Diff line number Diff line change
@@ -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
}
86 changes: 86 additions & 0 deletions plans/rank_test.go
Original file line number Diff line number Diff line change
@@ -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 "))
}