Files
commerce/api/billing/plans_licensing_test.go
zeekay 123d61bf84 billing: pin @hanzo/plans 1.8.22, and a plan row carries early access
The catalog's licensing.early_access lands on the row's licensing block
(Licensing.EarlyAccess) with the rest of what a tier licenses, so cloud's
beta predicate reads it off the row and a retired tier keeps it. The
seed compares it, so a row that lost it is corrected and a converged one
is not rewritten. Every Max rung carries it and nothing else does.

1.8.22 also grows the Max rungs' bots to 1, 3, 5 and 10, which the
roster canary now states, and moves Hanzo Dev, MCP in Dev and the CLI
from Free's lines to Pro's.
2026-10-20 07:40:35 -07:00

306 lines
11 KiB
Go

package billing
import (
"slices"
"testing"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/util/test/ae"
)
// TestPublishedTiersCarryLicensing: the seed persists what the catalog publishes
// under licensing.*, onto the row's own typed block. This is the mapping every
// other guarantee here rests on — if the row never records it, nothing downstream
// can survive the tier's retirement.
func TestPublishedTiersCarryLicensing(t *testing.T) {
c := ae.NewContext()
defer c.Close()
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
want := map[string][]string{
"free": nil, // $0, and licenses no proprietary product
"dev": {"crm", "team"},
"max-5x": {"crm", "engine", "team"},
"max-20x": {"crm", "engine", "team"},
"team": {"crm", "team"},
"enterprise": {"crm", "engine", "team"},
}
for slug, w := range want {
p := plan.New(plan.AuthorityDB(c))
ok, err := p.Query().Filter("Slug=", slug).Get()
if err != nil || !ok {
t.Fatalf("seeded plan %q missing: ok=%v err=%v", slug, ok, err)
}
var got []string
if p.Licensing != nil {
got = p.Licensing.Products
}
if !slices.Equal(got, w) {
t.Errorf("plan %q licenses %v, want %v (catalog licensing.product_ids must land on the row)", slug, got, w)
}
}
}
// TestEarlyAccessIsMaxs: licensing.early_access lands on the row of every Max rung
// and of nothing else, because cloud's beta predicate reads the row, never the
// catalog: a plan that carried it only in the catalog would open no beta, and one
// that carried it by accident would open the betas to every subscriber of it.
func TestEarlyAccessIsMaxs(t *testing.T) {
c := ae.NewContext()
defer c.Close()
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
max := map[string]bool{"max-5x": true, "max-20x": true, "max-50x": true, "max-100x": true}
for _, r := range SeedRows() {
p := plan.New(plan.AuthorityDB(c))
ok, err := p.Query().Filter("Slug=", r.Slug).Get()
if err != nil || !ok {
t.Fatalf("seeded plan %q missing: ok=%v err=%v", r.Slug, ok, err)
}
got := p.Licensing != nil && p.Licensing.EarlyAccess
if got != max[r.Slug] {
t.Errorf("plan %q early access = %v, want %v", r.Slug, got, max[r.Slug])
}
}
// A row that lost it is corrected by the next boot, and a converged one is not
// rewritten: early access is compared, not only copied.
if _, corrected, err := SeedPlans(c); err != nil || corrected != 0 {
t.Fatalf("re-seed = corrected %d, err %v; want 0 — a converged catalog rewrites nothing", corrected, err)
}
p := plan.New(plan.AuthorityDB(c))
if ok, err := p.Query().Filter("Slug=", "max-20x").Get(); err != nil || !ok {
t.Fatalf("max-20x: ok=%v err=%v", ok, err)
}
p.Licensing.EarlyAccess = false
if err := p.Update(); err != nil {
t.Fatalf("update: %v", err)
}
if _, corrected, err := SeedPlans(c); err != nil || corrected != 1 {
t.Fatalf("re-seed = corrected %d, err %v; want 1", corrected, err)
}
p = plan.New(plan.AuthorityDB(c))
if ok, err := p.Query().Filter("Slug=", "max-20x").Get(); err != nil || !ok || !p.Licensing.EarlyAccess {
t.Fatalf("max-20x after re-seed: early access %v, want true", p.Licensing)
}
}
// TestLicensingSurvivesRetirement is the whole point, stated as the rule: a tier
// the catalog STOPS publishing keeps knowing what it licensed.
//
// It seeds the published catalog, then reconciles against a catalog that no longer
// carries "dev" — exactly what shipping a release that retires a tier does. The row
// is archived, as it should be, and it must STILL answer the licensing question,
// because the subscriber on it is still being charged.
func TestLicensingSurvivesRetirement(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := plan.AuthorityDB(c)
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
// Publish a catalog with "dev" removed — retire the tier.
var kept []*plan.Plan
for _, r := range SeedRows() {
if r.Slug != "dev" {
kept = append(kept, r)
}
}
if _, _, err := plan.Seed(db, kept); err != nil {
t.Fatalf("re-seed without dev: %v", err)
}
p := plan.New(db)
ok, err := p.Query().Filter("Slug=", "dev").Get()
if err != nil || !ok {
t.Fatalf("retired plan %q must stay resolvable: ok=%v err=%v", "dev", ok, err)
}
if p.Listed() {
t.Fatalf("retired plan %q is still listed; archiving is what stops the sale", "dev")
}
if p.Licensing == nil || !slices.Equal(p.Licensing.Products, []string{"crm", "team"}) {
t.Fatalf("retired plan %q licenses %v, want [crm team] — a retired tier must not lose its licences", "dev", p.Licensing)
}
}
// TestRetiredTiersResolveLicensing is the migration, and it is the part that fixes
// the customers who are stranded RIGHT NOW.
//
// It reconstructs the production state exactly: a row archived back when Plan had
// no licensing field, so it carries none, for a tier the catalog has long since
// dropped. Persisting the block on new rows does nothing for this row — only the
// backfill does. Without it the row licenses nothing and a paying subscriber is
// refused the product they bought.
func TestRetiredTiersResolveLicensing(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := plan.AuthorityDB(c)
// The world as it exists today: archived, licensing-less rows for tiers the
// catalog retired at @hanzo/plans 1.4.5.
for _, slug := range []string{"plus", "team-max", "custom", "developer"} {
p := plan.New(db)
p.Slug = slug
p.Category = "personal"
p.Price = 10000
p.Status = plan.StatusArchived
p.Managed = true
if err := p.Create(); err != nil {
t.Fatalf("create archived %q: %v", slug, err)
}
}
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
// plus and team-max each licensed "team" when they were last on sale.
for _, slug := range []string{"plus", "team-max"} {
p := plan.New(db)
ok, err := p.Query().Filter("Slug=", slug).Get()
if err != nil || !ok {
t.Fatalf("archived plan %q missing: ok=%v err=%v", slug, ok, err)
}
if p.Licensing == nil {
t.Errorf("archived plan %q carries no licensing; a subscriber still paying for it is refused every product it licensed", slug)
continue
}
if !slices.Equal(p.Licensing.Products, []string{"team"}) {
t.Errorf("archived plan %q licenses %v, want [team]", slug, p.Licensing.Products)
}
}
// custom and developer licensed NOTHING when they were last on sale, so no
// block is the correct answer. The backfill must not invent a grant.
for _, slug := range []string{"custom", "developer"} {
p := plan.New(db)
ok, err := p.Query().Filter("Slug=", slug).Get()
if err != nil || !ok {
t.Fatalf("archived plan %q missing: ok=%v err=%v", slug, ok, err)
}
if p.Licensing != nil {
t.Errorf("archived plan %q licenses %v, want none — the backfill must never fabricate a grant", slug, p.Licensing.Products)
}
}
// Archiving is untouched by the backfill: these tiers are still not for sale.
p := plan.New(db)
if ok, _ := p.Query().Filter("Slug=", "plus").Get(); ok && p.Listed() {
t.Errorf("backfilled plan %q became listed; restoring a licence must never put a retired tier back on sale", "plus")
}
}
// TestBackfillIsIdempotent: the backfill runs on every boot, so a second run must
// write nothing, and it must never overwrite a licensing block the catalog or an
// admin has since supplied.
func TestBackfillIsIdempotent(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := plan.AuthorityDB(c)
p := plan.New(db)
p.Slug = "plus"
p.Category = "personal"
p.Status = plan.StatusArchived
if err := p.Create(); err != nil {
t.Fatalf("create: %v", err)
}
first, err := plan.Backfill(db, retired)
if err != nil {
t.Fatalf("backfill: %v", err)
}
if first != 1 {
t.Fatalf("first backfill filled %d rows, want 1", first)
}
second, err := plan.Backfill(db, retired)
if err != nil {
t.Fatalf("re-backfill: %v", err)
}
if second != 0 {
t.Fatalf("re-backfill filled %d rows, want 0 (idempotent)", second)
}
// An admin-supplied block wins: the migration restores a missing fact, it does
// not re-decide one that is already recorded.
q := plan.New(db)
if ok, _ := q.Query().Filter("Slug=", "team-max").Get(); ok {
t.Fatalf("team-max should not exist in this fixture")
}
q.Slug = "team-max"
q.Category = "team"
q.Status = plan.StatusArchived
q.Licensing = &plan.Licensing{Products: []string{"engine"}}
if err := q.Create(); err != nil {
t.Fatalf("create team-max: %v", err)
}
if _, err := plan.Backfill(db, retired); err != nil {
t.Fatalf("backfill over existing: %v", err)
}
r := plan.New(db)
if ok, _ := r.Query().Filter("Slug=", "team-max").Get(); !ok {
t.Fatalf("team-max missing")
}
if r.Licensing == nil || !slices.Equal(r.Licensing.Products, []string{"engine"}) {
t.Errorf("backfill overwrote an existing licensing block: got %v, want [engine]", r.Licensing)
}
}
// TestBackfillSkipsARevivedSlug: a retired name that is on sale again must not
// inherit the retired tier's grants.
//
// The backfill matched on slug alone, and it runs on every boot from SeedPlans.
// So the moment a retired slug is published again — for any reason, by anyone —
// the boot seed would write the OLD tier's licensing onto the new row, and keep
// writing it. `plus` was a $100/mo personal tier licensing the `team` product;
// republished at any price with no licensing of its own, it would silently
// acquire that grant, and a grant is the half of a plan that costs the platform
// rather than the tenant.
//
// The reuse itself stays a mistake this cannot fix: an archived row is kept so
// that invoices and renewals which recorded the slug still price from it, and
// aiming the name at a different product rewrites what every one of those
// records means. This only stops the mistake from also minting entitlement.
func TestBackfillSkipsARevivedSlug(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := plan.AuthorityDB(c)
// `plus` is in the retired map, carrying licensing.product_ids ["team"].
if retired["plus"] == nil {
t.Fatal("fixture assumes `plus` is in the retired map; it is not, so this test proves nothing")
}
// On sale again, licensing nothing of its own.
p := plan.New(db)
p.Slug = "plus"
p.Category = "personal"
p.Status = plan.StatusActive
if err := p.Create(); err != nil {
t.Fatalf("create: %v", err)
}
if !p.Listed() {
t.Fatal("fixture row is not listed, so it does not exercise the guard")
}
filled, err := plan.Backfill(db, retired)
if err != nil {
t.Fatalf("backfill: %v", err)
}
if filled != 0 {
t.Errorf("backfill filled %d rows, want 0 — it wrote to a tier that is on sale", filled)
}
got := plan.New(db)
if ok, _ := got.Query().Filter("Slug=", "plus").Get(); !ok {
t.Fatal("row vanished")
}
if got.Licensing != nil {
t.Errorf("a listed %q was granted %v by the boot seed — the retired tier's terms, "+
"applied to a product that merely reuses its name", "plus", got.Licensing.Products)
}
}