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

244 lines
9.9 KiB
Go

package billing
import (
"context"
"sort"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/types/currency"
types "github.com/hanzoai/commerce/types"
)
// The plan authority (models/plan, "system" namespace) is seeded from — and
// falls back to — the SAME embedded @hanzo/plans catalog (catalog) that
// SyncStripe and StaticPlans read. Seeding therefore changes NO charge: a
// seeded row's typed money fields equal the embed's. Once seeded, admin.hanzo.ai
// edits the rows and BOTH GET /v1/billing/plans and resolveSubscriptionPlan read
// the edited price. SyncStripe is untouched — it still projects StaticPlans().
// PinnedPlansVersion is the @hanzo/plans package version the vendored JSON under
// api/billing/plans is a copy of. Re-vendoring the JSON and bumping this pin go
// together; plans_drift_test.go asserts the embedded bytes still hash to the
// recorded digest for this version, so a stray edit to a vendored price (which
// the seed writes and resolveSubscriptionPlan charges) fails CI loudly.
// NOTE: the vendored plans/package.json is gitignored (a broad package.json
// ignore), so it is NOT the pin — this const + the digest test are.
const PinnedPlansVersion = "1.8.22"
// The display envelope (features/bundles/includedIn/limits) is the MODEL's, not
// this package's: plan.Plan carries those fields and packs them itself. This file
// used to declare a private copy plus its own pack/unpack pair, which is why the
// admin CRUD could not write them — the shape lived here, where the admin handler
// could not reach it.
// SeedRows projects the embedded plan catalog onto authority model rows. Typed
// money fields (Price/PriceAnnual/Category/ContactSales/PerSeat/…) become
// columns; the rich display envelope rides Metadata. This is the ONE seed
// source, so the DB authority starts byte-for-byte equal to the embed.
func SeedRows() []*plan.Plan {
rows := make([]*plan.Plan, 0, len(catalog))
for i := range catalog {
rows = append(rows, planFromStatic(&catalog[i]))
}
return rows
}
// planFromStatic maps one embed wire plan onto an authority model row. It
// PRESERVES the free($0)-vs-custom(null) distinction: the embed already carries
// it as Price=0 + ContactSales, and we copy ContactSales verbatim — never
// coercing a null-priced custom plan into a chargeable $0.
func planFromStatic(sp *staticPlan) *plan.Plan {
p := &plan.Plan{
Slug: sp.Slug,
Name: sp.Name,
Description: sp.Description,
Category: sp.Category,
Price: currency.Cents(sp.Price),
PriceAnnual: currency.Cents(sp.annual()),
AnnualTotal: currency.Cents(sp.AnnualTotal),
Prices: centsOf(sp.Prices),
Currency: currency.Type(sp.Currency),
Interval: types.Interval(sp.Interval),
IntervalCount: sp.IntervalCount,
TrialPeriodDays: sp.TrialPeriodDays,
PerSeat: sp.PerSeat,
ContactSales: sp.ContactSales,
Popular: sp.Popular,
Status: sp.Status,
Features: sp.Features,
Bundles: sp.Bundles,
IncludedIn: sp.IncludedIn,
Limits: sp.Limits,
Licensing: sp.Licensing,
}
return p
}
// centsOf and plainOf carry the price ladder across the wire/row boundary. The
// row types money as currency.Cents and the wire sends plain cents, exactly as
// Price already does on the line above each call; these only do it for a list.
// nil in, nil out — an absent ladder must stay absent rather than become an empty
// one, because the seed compares them and a nil/[] difference would rewrite every
// row on every boot.
func centsOf(in []int64) []currency.Cents {
if in == nil {
return nil
}
out := make([]currency.Cents, len(in))
for i, v := range in {
out[i] = currency.Cents(v)
}
return out
}
func plainOf(in []currency.Cents) []int64 {
if in == nil {
return nil
}
out := make([]int64, len(in))
for i, v := range in {
out[i] = int64(v)
}
return out
}
// retired records what a tier licensed WHEN IT WAS LAST ON SALE, for the rows that
// were archived before the plan row carried a licensing block at all.
//
// Provenance: @hanzo/[email protected] subscription.json — the last published version
// that still listed these tiers (1.4.5 dropped them). Those exact bytes are already
// pinned in this package: plans_drift_test.go's versionDigests["1.4.4"].subscription
// is their sha256, e490185e58b4e83d925eaf2dfd4778e28023655b610d0504b8058670bbdf2f79.
// So this is not a second catalog and not a new source of truth — it is a reading
// of the one catalog at the version that last described these tiers.
//
// Of the thirteen retired slugs, only these two licensed anything: plus and
// team-max each carried licensing.product_ids ["team"]. developer, custom and the
// world-*/social-* lines carried no licensing block in 1.4.4, so for them "no
// block" is already the correct answer and there is nothing to restore.
//
// This map is closed. A tier retired from here on carries its licensing on the row
// before it is archived, so nothing will ever need to be added.
var retired = map[string]*plan.Licensing{
"plus": {Products: []string{"team"}},
"team-max": {Products: []string{"team"}},
}
// staticPlanFromModel projects an authority row back onto the wire type served by
// GET /v1/billing/plans. Money + core fields come from typed columns (so an admin
// edit wins); the rich display envelope comes from Metadata. The promo overlay is
// applied separately at the read edge (withPromo), exactly as for the embed.
func staticPlanFromModel(p *plan.Plan) staticPlan {
return staticPlan{
Slug: p.Slug,
Name: p.Name,
Description: p.Description,
Category: p.Category,
Group: group(p.Slug),
Price: int64(p.Price),
PriceAnnual: annualOf(p),
AnnualTotal: int64(p.AnnualTotal),
Prices: plainOf(p.Prices),
Currency: string(p.Currency),
Interval: string(p.Interval),
IntervalCount: p.IntervalCount,
TrialPeriodDays: p.TrialPeriodDays,
ContactSales: p.ContactSales,
Popular: p.Popular,
PerSeat: p.PerSeat,
Features: p.Features,
Bundles: p.Bundles,
IncludedIn: p.IncludedIn,
Limits: p.Limits,
Licensing: p.Licensing,
}
}
// annualOf is a row's annual price on the wire. The row stores "the catalog states
// no annual price" as zero, and zero is never a real annual price on a plan that
// charges — by the month or by a sales call — so there it reads back as null, the
// catalog's own value. Only a plan that charges nothing states an annual $0.
func annualOf(p *plan.Plan) *int64 {
if p.PriceAnnual == 0 && (p.Price > 0 || p.ContactSales) {
return nil
}
annual := int64(p.PriceAnnual)
return &annual
}
// SeedPlans reconciles the subscription + DNS plan authority to the embed
// (SyncStripe/StaticPlans read the SAME source). Safe on every boot: it creates
// missing plans and FORCE-CORRECTS any unmanaged partial row (Red: the bundle
// expansion wrote Price=0 rows the old count-gated seed then skipped) while
// leaving admin-edited rows authoritative until the embed publishes the same
// values, when they are the embed's again. A new embed can change a charge: it
// reprices every row the embed owns on the next boot.
// Returns (created, corrected); corrected counts reconciles, archives, releases
// of admin-edited rows the embed caught up with, and the
// licensing backfilled onto rows retired before the row carried a licensing block.
func SeedPlans(ctx context.Context) (created, corrected int, err error) {
db := plan.AuthorityDB(ctx)
created, corrected, err = plan.Seed(db, SeedRows())
if err != nil {
return created, corrected, err
}
filled, err := plan.Backfill(db, retired)
return created, corrected + filled, err
}
// planAuthorityRows reads the plan authority for the read edge (ListPlans/
// GetPlan). It returns (rows, true) on success; on a query error OR an empty
// authority (the seed did not run) it logs LOUDLY and returns (nil, false) so
// the caller serves the embed fallback — never a silently blank plan list.
func planAuthorityRows(ctx context.Context) ([]staticPlan, bool) {
var rows []*plan.Plan
if _, err := plan.Query(plan.AuthorityDB(ctx)).GetAll(&rows); err != nil {
log.Error("billing plans: authority read FAILED, serving embed fallback: %v", err, nil)
return nil, false
}
if len(rows) == 0 {
log.Warn("billing plans: authority is EMPTY (seed did not run?), serving embed fallback")
return nil, false
}
out := make([]staticPlan, 0, len(rows))
for _, p := range rows {
// Draft and archived rows exist but are not sold. This is the whole point
// of Status: before it, the only way to unlist a plan was to DELETE it,
// which takes the row's history and orphans any subscription that recorded
// the slug. An archived plan still resolves for renewals and invoices —
// it just stops being offered.
if !p.Listed() {
continue
}
out = append(out, staticPlanFromModel(p))
}
// An authority holding ONLY unlisted rows is not the same as an empty one: the
// seed ran, the operator archived everything. Serving the embed fallback there
// would resurrect retired tiers on the public page, which is the exact failure
// this field exists to prevent. Empty-and-deliberate is a valid answer.
sortByEmbedOrder(out)
return out, true
}
// sortByEmbedOrder gives the authority rows the canonical embed display order
// (so a seeded==embed authority serves the same order the client expects);
// admin-added plans not in the embed sort after, by slug. Stable + deterministic.
func sortByEmbedOrder(rows []staticPlan) {
idx := make(map[string]int, len(catalog))
for i := range catalog {
idx[catalog[i].Slug] = i
}
sort.SliceStable(rows, func(i, j int) bool {
oi, oki := idx[rows[i].Slug]
oj, okj := idx[rows[j].Slug]
if oki && okj {
return oi < oj
}
if oki != okj {
return oki // embed-known plans before admin-added ones
}
return rows[i].Slug < rows[j].Slug
})
}