mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
306 lines
11 KiB
Go
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)
|
|
}
|
|
}
|