Files
commerce/api/billing/plans_drift_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

356 lines
15 KiB
Go

package billing
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"testing"
)
// Step 0 drift guard. The vendored @hanzo/plans JSON (api/billing/plans/*.json)
// is embedded and is the seed source + charge fallback, so an accidental or
// malicious edit to a vendored plan file must fail CI. package.json is gitignored
// (not present in a fresh clone) — the pin is PinnedPlansVersion + the content
// digests of the two EMBEDDED files, recorded PER VERSION below.
//
// versionDigests is keyed by version, so (Red F4) a content change can't land
// without a conscious PinnedPlansVersion bump, and a version bump can't land
// without the matching re-vendored content: bumping the version selects a new map
// entry whose digests must equal the new bytes, and editing the bytes under the
// current version breaks the current entry.
var versionDigests = map[string]struct{ subscription, dns string }{
"1.8.22": {
subscription: "e7ef23f2926f1d1b54aa0802a2cbdc69c1aa4738db94b54738ea1e8ac0711b5b",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.21": {
subscription: "daabfab8b2e954f6c6e4b5337127aa93fd47f8416f06dc80308fc5b51b29a11a",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.20": {
subscription: "a381018ffcf670f47772405376c279810cfea24eebe421ac4a0d81f10d758dac",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.19": {
subscription: "d10182cbf79a1df8117b68eb99422af2777427be438c7ce17bc64a87b2c6b135",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.18": {
subscription: "033c9631b07eb8d0a99538dbcf0b52d20e341bedacd542f0c58d0070dc8afaf7",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.17": {
subscription: "6d8eb673140bdfb63249025a3b5a3e61dbc284cc4c51950f10519d214e878536",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.16": {
subscription: "3c42fb9996ae69cb9a5bae01e279de62104f44bb807f89cea5bfa5a9b867d4b1",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.15": {
subscription: "81233e73006d1f31fc6de14a6c3aafdd3147b83c87ed2072ec9e8090d94dab36",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.9": {
subscription: "cc8481a800b3891d4df6a2eb5670e87d80c89b50a210b3eb4fe615eae55d7f5b",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.8": {
subscription: "1cdca4fcd5606ff7cc5dec939f50ecf0e97830782fe65e441e96378848d2a3fa",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.6": {
subscription: "7fa880b400e466154a41f8020abfa0012138b7c484010c5fa953346ec88f1feb",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.5": {
subscription: "b70c7f759414c7ce2817b460a4c25e5104e22b724013c6d2db40119b481cf957",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.4": {
subscription: "fdddcd25ba382c2963ebadf80a2e0d43066dd841ee57deb25065563a14f2d0ca",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.8.3": {
subscription: "4e7cf986f1b48435cc79435820828b28a90fce0f9c4f04e9eb333ed4ecff8434",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.6.0": {
subscription: "883bbdc5261feddc90f2782590a3c806e71c7bc40d017b5fa4ed77ddd2d8c1cb",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.5.0": {
subscription: "f76c7b381c0a56127b566b060b34a998c077975eddeccd6255081c8d56981e34",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.4.20": {
subscription: "f89507c4fdebf0df2f5c135281a6f89ae4b6bc1eb4f6aec178b804a795e87339",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
"1.4.4": {
subscription: "e490185e58b4e83d925eaf2dfd4778e28023655b610d0504b8058670bbdf2f79",
dns: "de7da2ab600268bdf5528b9ec1fd037bdbe8f9112f3755d80b5f93a4cbf1cd87",
},
"1.4.8": {
subscription: "7affa8d6d75bf28fed1f014e96bffe25f08b6c0df008cdc24d375a3d3107b38d",
dns: "de7da2ab600268bdf5528b9ec1fd037bdbe8f9112f3755d80b5f93a4cbf1cd87",
},
"1.4.13": {
subscription: "c511dfb34552d6adbff33a25aa72cf2ef68eec7bca7fbed41ab17057f58540b1",
dns: "de7da2ab600268bdf5528b9ec1fd037bdbe8f9112f3755d80b5f93a4cbf1cd87",
},
"1.4.16": {
subscription: "3ca9e1b0c77abaa2ddcb3d2fb708750b8a3c34db96f475c09eb39764727fb94d",
dns: "de7da2ab600268bdf5528b9ec1fd037bdbe8f9112f3755d80b5f93a4cbf1cd87",
},
"1.4.18": {
subscription: "ecb4357f7357f0182a1faa833598f79e45e4d41e9e9c2b7f67dd0ab5ce27b52d",
dns: "620485fd5fcda4bb860021167f8f9c91a9b0dfe4dcc498d1b91cf8641bfcacbc",
},
}
func digest(t *testing.T, fs interface {
ReadFile(string) ([]byte, error)
}, path string) string {
t.Helper()
b, err := fs.ReadFile(path)
if err != nil {
t.Fatalf("read embedded %s: %v", path, err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// TestVendoredPlansMatchPinnedVersion fails if the embedded plan JSON drifts from
// the pinned @hanzo/plans version — the definitive vendored-dependency tripwire.
func TestVendoredPlansMatchPinnedVersion(t *testing.T) {
want, ok := versionDigests[PinnedPlansVersion]
if !ok {
t.Fatalf("PinnedPlansVersion = %q has no digests in versionDigests; add its subscription+dns digests when re-vendoring", PinnedPlansVersion)
}
if got := digest(t, subscriptionJSON, "plans/subscription.json"); got != want.subscription {
t.Fatalf("subscription.json drifted from @hanzo/plans@%s\n got %s\n want %s\n(re-vendor AND bump PinnedPlansVersion + add a versionDigests entry together)", PinnedPlansVersion, got, want.subscription)
}
if got := digest(t, dnsJSON, "plans/dns.json"); got != want.dns {
t.Fatalf("dns.json drifted from @hanzo/plans@%s\n got %s\n want %s", PinnedPlansVersion, got, want.dns)
}
}
// TestVendoredPlanPrices is a diagnostic price-canary: if a money-bearing plan's
// cents change, THIS test names which one (the digest test only says "drifted").
// These are the @hanzo/[email protected] cents — monthly, the annual price tag shown
// per month, and the year a yearly subscription is charged. contactSales plans
// are null-priced → stored as 0 + ContactSales (never a chargeable $0).
func TestVendoredPlanPrices(t *testing.T) {
if got := len(catalog); got != 14 { // 11 subscription + 3 dns
t.Fatalf("catalog = %d, want 14 (11 subscription + 3 dns)", got)
}
if got := len(dnsPlans); got != 3 {
t.Fatalf("dnsPlans = %d, want 3", got)
}
bySlug := map[string]staticPlan{}
for _, p := range catalog {
bySlug[p.Slug] = p
}
// none is an annual price the catalog does not state: null on the wire, never $0.
const none = -1
type want struct {
monthly, annual, year int64
contactSales bool
}
// A personal year is ten months ($200, $1,000, $2,000) and a team seat's is
// $240 against $25 monthly; the annual tag is that year over twelve, rounded
// to the cent. The catalog states the total because twelve rounded tags would
// bill $200.04.
cases := map[string]want{
"free": {0, 0, 0, false}, // a real $0 rung, not a null price
"dev": {2000, 1667, 20000, false}, // shown as Pro
"max-5x": {10000, 8333, 100000, false},
"max-20x": {20000, 16667, 200000, false},
"max-50x": {50000, 41667, 500000, false}, // draft: priced, held unlisted
"max-100x": {100000, 83333, 1000000, false},
"team": {2500, 2000, 24000, false}, // per seat
"team-premium": {10000, 8000, 96000, false}, // per seat, the team's premium seat
"advisory": {499900, none, 0, false}, // agency, monthly only
"dedicated": {999900, none, 0, false},
"enterprise": {0, none, 0, true}, // null price → 0 + contactSales
"dns-free": {0, 0, 0, false},
"dns-pro": {500, 400, 4800, false}, // dns states no total: a year is twelve annual months
"dns-enterprise": {2500, 2000, 24000, false},
}
for slug, w := range cases {
p, ok := bySlug[slug]
if !ok {
t.Fatalf("plan %q missing from embed", slug)
}
annual := int64(none)
if p.PriceAnnual != nil {
annual = *p.PriceAnnual
}
if p.Price != w.monthly || annual != w.annual || p.AnnualTotal != w.year {
t.Errorf("plan %q price = %d/%d/%d cents, want %d/%d/%d (monthly/annual tag, %d for none/year)",
slug, p.Price, annual, p.AnnualTotal, w.monthly, w.annual, none, w.year)
}
if p.ContactSales != w.contactSales {
t.Errorf("plan %q contactSales = %v, want %v (free-vs-null distinction)", slug, p.ContactSales, w.contactSales)
}
}
}
// TestVendoredPlanRoster is the price canary's twin for what a plan may RUN.
// These are capacities, not allowances of runtime: they decide whether a
// customer may create their next agent, while the hours it then runs meter
// separately at the catalog's hourly rate. A number that drifts here refuses
// paying work or gives away concurrency — and the digest test only ever says
// "drifted", never which figure moved.
//
// It reads through AgentsIncluded/BotsIncluded rather than the raw field so the
// accessor cloud enforces with is the thing under test. A bare struct read
// would pass while the exported answer was wrong.
func TestVendoredPlanRoster(t *testing.T) {
cases := map[string]struct{ agents, bots int }{
"free": {1, 0}, // one personal agent, no bot
"dev": {10, 0}, // Pro, the $20 tier
"max-5x": {10, 1}, // a Max rung's own bots grow with it: 1, 3, 5, 10
"max-20x": {10, 3},
"max-50x": {10, 5},
"max-100x": {10, 10},
"max": {10, 1}, // retired, and answered by max-5x for its holders
"advisory": {10, 1},
"dedicated": {-1, 1},
"team": {10, 0},
"team-premium": {10, 0},
"enterprise": {-1, -1}, // -1 is unlimited, as it is for maxMembers
}
for slug, w := range cases {
agents, ok := AgentsIncluded(slug)
if !ok {
t.Errorf("plan %q publishes no agent roster — enforcement has nothing to read and a holder is refused their first agent", slug)
continue
}
if agents != w.agents {
t.Errorf("plan %q includes %d agents, want %d", slug, agents, w.agents)
}
bots, ok := BotsIncluded(slug)
if !ok {
t.Errorf("plan %q publishes no bot roster", slug)
continue
}
if bots != w.bots {
t.Errorf("plan %q includes %d bots, want %d", slug, bots, w.bots)
}
}
// Silence must stay distinguishable from zero, or the accessor's whole
// contract collapses into "unknown means refuse".
if n, ok := AgentsIncluded("no-such-plan"); ok || n != 0 {
t.Errorf("AgentsIncluded(unknown) = (%d, %v), want (0, false)", n, ok)
}
}
// TestAYearIsStatedOrExact: a yearly subscription charges AnnualTotal, which the
// decoder takes from price_ref.recurring.annual_total_usd and, for a row that
// states none, as twelve of its annual price. That second reading is only honest
// when the annual price was never rounded — a whole-dollar month — so a row whose
// per-month tag carries cents MUST state its year, or the charge becomes twelve
// rounded months: $198.96 for a $199 year.
func TestAYearIsStatedOrExact(t *testing.T) {
for _, f := range []struct {
fs interface{ ReadFile(string) ([]byte, error) }
path string
}{{subscriptionJSON, "plans/subscription.json"}, {dnsJSON, "plans/dns.json"}} {
raw, err := f.fs.ReadFile(f.path)
if err != nil {
t.Fatalf("read %s: %v", f.path, err)
}
var rows []canonicalPlan
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatalf("decode %s: %v", f.path, err)
}
for _, r := range rows {
if r.PriceAnnual == nil || *r.PriceAnnual <= 0 {
continue
}
stated := r.PriceRef != nil && r.PriceRef.Recurring != nil && r.PriceRef.Recurring.AnnualTotalUSD != nil
if !stated && *r.PriceAnnual != float64(int64(*r.PriceAnnual)) {
t.Errorf("%s: %q shows $%.2f a month annually and states no year; twelve rounded months is not a price anyone set",
f.path, r.ID, *r.PriceAnnual)
}
}
}
}
// A retired slug must STAY retired in the embed. The embed is what a fresh
// database seeds from, so a tier that comes back here comes back on the pricing
// page of every new deployment — which is exactly the resurrection plan.Status
// prevents for rows that already exist, and cannot prevent for rows that do not.
func TestRetiredSlugsAreNotInTheEmbed(t *testing.T) {
bySlug := map[string]bool{}
for _, p := range catalog {
bySlug[p.Slug] = true
}
for _, slug := range []string{
// go ($9), pro ($49) and max ($99) came off the ladder; a renewal still
// prices through its own snapshot, but the embed must not offer them for
// sale again. max is answered by max-5x (its "replaces"), never listed.
"go", "pro", "max",
"developer", "plus", "team-max", "custom",
"world-free", "world-pro", "world-team", "world-enterprise",
"social-free", "social-pro", "social-team", "social-team-max", "social-enterprise",
} {
if bySlug[slug] {
t.Errorf("retired plan %q is back in the embed", slug)
}
}
}
// TestVendoredAllotmentAmounts canaries what a plan gives away: no rung mints
// prepaid credit — the ledger holds only credit a buyer bought.
func TestVendoredAllotmentAmounts(t *testing.T) {
for _, slug := range []string{"free", "dev", "max-5x", "max-20x", "max", "team", "team-premium", "advisory", "dedicated", "enterprise", "dns-pro", "go", "pro"} {
if got := IncludedMonthlyCents(slug); got != 0 {
t.Errorf("IncludedMonthlyCents(%q) = %d, want 0 — no rung mints credit", slug, got)
}
}
}
// TestVendoredMoveScores canaries the plan-move gate's input: each plan's list
// price from the embed, per seat on a per-seat plan, resolved through retired
// slugs. A drift here changes who may move where without paying, and must fail
// by name.
func TestVendoredMoveScores(t *testing.T) {
for slug, cents := range map[string]int64{
"free": 0,
"dev": 2000,
"max-5x": 10000,
"max-20x": 20000,
"max": 10000, // retired; scored as max-5x
"team": 2500, // per seat
"team-premium": 10000, // per seat
"advisory": 499900,
"dedicated": 999900,
"enterprise": math.MaxInt64, // priced by negotiation
"dns-pro": 500,
"go": 0, // retired with no successor
"pro": 0,
} {
if got := listCents(slug); got != cents {
t.Errorf("listCents(%q) = %d, want %d", slug, got, cents)
}
}
}
// TestVendoredPriceLadder: no rung is sold at levels. The $99 rung used to carry
// a ladder up to $999; the multiples are separate rungs now (max-5x, max-20x),
// so a ladder on either would be a second way to buy the same usage at a
// different price. A plan with no ladder is sold at exactly Price and every
// level above 0 is refused (LevelPrice), which is the direction that cannot
// overcharge.
func TestVendoredPriceLadder(t *testing.T) {
for _, p := range catalog {
if len(p.Prices) != 0 {
t.Errorf("plan %q publishes a price ladder %v; the ladder is its own rungs now", p.Slug, p.Prices)
}
}
}