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.
244 lines
9.9 KiB
Go
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
|
|
})
|
|
}
|