Files
commerce/api/billing/plans_converge_test.go
zeekay 387c74cfba Commerce copies @hanzo/plans 1.8.19: Max 50x is on sale at $500, and Free and Pro say only what the product keeps
The vendored subscription.json is 1.8.19's, its digest recorded beside
the earlier ones and PinnedPlansVersion moved with it. Max 50x left
draft on the owner's word, so the draft test holds Max 100x alone to
unlisted and sold by nothing, and Max 50x to listed and sold.
2026-10-16 14:19:37 -07:00

137 lines
4.7 KiB
Go

package billing
import (
"testing"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/nscontext"
"github.com/hanzoai/commerce/util/test/ae"
)
// The production cutover, end to end: a store holding the OLD catalog boots
// against the NEW one and converges — without a token, a script, or a human.
//
// This is the case the previous seed could not handle. Every stored row was
// Managed (the seed set that flag itself), so the seed skipped all of them: it
// would create `go` and `free`, leave `pro` at its old price, and leave `developer` and
// `plus` on sale beside them. A catalog half-new and half-stale, which reads to a
// customer as a pricing page that cannot make up its mind, and to billing as two
// prices for one tier.
func TestCatalogConverges_OldStoreToPublishedLadder(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := datastore.New(nscontext.WithNamespace(c, plan.Namespace))
// Plant the catalog as production actually holds it: seeded rows, Managed,
// carrying the retired tiers and the old prices.
for _, old := range []struct {
slug, name, category string
cents int64
}{
{"developer", "Developer", "personal", 0},
{"pro", "Pro", "personal", 2000},
{"plus", "Plus", "personal", 10000},
{"max", "Max", "personal", 20000},
{"world-pro", "World Pro", "world", 2900},
} {
p := plan.New(db)
p.Slug, p.Name, p.Category, p.Price, p.Managed = old.slug, old.name, old.category, currencyCents(old.cents), true
if err := p.Create(); err != nil {
t.Fatalf("plant %s: %v", old.slug, err)
}
}
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
rows, ok := planAuthorityRows(c)
if !ok {
t.Fatal("authority read failed")
}
got := map[string]int64{}
for _, r := range rows {
got[r.Slug] = r.Price
}
// The published ladder is what is offered, at the published prices.
for slug, want := range map[string]int64{"free": 0, "dev": 2000, "max-5x": 10000, "max-20x": 20000, "team": 2500} {
if got[slug] != want {
t.Errorf("public %q = %d cents, want %d", slug, got[slug], want)
}
}
// The retired tiers are gone from the offer.
for _, slug := range []string{"developer", "plus", "world-pro", "pro", "max"} {
if _, still := got[slug]; still {
t.Errorf("retired %q is still on sale", slug)
}
}
// But their ROWS survive, so anything that recorded the slug still resolves.
for _, slug := range []string{"developer", "plus", "world-pro", "pro", "max"} {
p := plan.New(db)
if found, err := p.Query().Filter("Slug=", slug).Get(); err != nil || !found {
t.Errorf("retired %q was DESTROYED (found=%v err=%v); history cannot resolve it", slug, found, err)
}
}
}
func currencyCents(v int64) currency.Cents { return currency.Cents(v) }
// THE TOP OF THE LADDER IS PUBLISHED AND SOLD BY NOTHING UNTIL THE OWNER SAYS. Max
// 100x ($1,000) is a draft in the catalog: a boot creates its row unlisted, the
// public catalog does not list it, and every purchase entrypoint treats it as a
// slug that never existed — so a deploy that carries the price charges no one.
// Max 50x ($500) left draft on the owner's word (@hanzo/plans 1.8.19): it is
// listed and sold like every other rung.
func TestDraftTiersAreSeededUnlistedAndSoldByNothing(t *testing.T) {
c := ae.NewContext()
defer c.Close()
db := datastore.New(nscontext.WithNamespace(c, plan.Namespace))
if _, _, err := SeedPlans(c); err != nil {
t.Fatalf("seed: %v", err)
}
listed, ok := planAuthorityRows(c)
if !ok {
t.Fatal("authority read failed")
}
sold := false
for _, r := range listed {
if r.Slug == "max-100x" {
t.Errorf("%s is in the public catalog while it is a draft", r.Slug)
}
sold = sold || r.Slug == "max-50x"
}
if !sold {
t.Error("max-50x is on sale and missing from the public catalog")
}
for slug, cents := range map[string]int64{"max-50x": 50000, "max-100x": 100000} {
p, err := resolveSubscriptionPlan(db, slug)
if err != nil {
t.Fatalf("%s must still resolve (renewals, invoices): %v", slug, err)
}
if int64(p.Price) != cents {
t.Errorf("%s resolves at %d cents, want %d", slug, p.Price, cents)
}
if on := p.Listed(); on != (slug == "max-50x") {
t.Errorf("%s resolves listed=%v", slug, on)
}
}
// The embed fallback, used when the authority cannot answer, offers the draft to no one.
for _, p := range listedCatalog() {
if p.Slug == "max-100x" {
t.Errorf("the embed fallback offers %s", p.Slug)
}
}
for _, p := range StaticPlans() {
if p.Slug == "max-100x" {
t.Errorf("StaticPlans hands %s to a seeder", p.Slug)
}
}
if lookupPlan("max-100x") == nil {
t.Error("a draft plan must still be answerable by lookupPlan")
}
}