mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
GET /v1/billing/plans served priceAnnual 0 for advisory, dedicated and enterprise, where the catalog states null: the wire type was a plain int64, so an absent annual price became a $0 one, which on a plan that charges advertises a free year of it. PlanView.PriceAnnual is now *int64 and the embed keeps the catalog's null. The row keeps money non-nullable and stores "none" as zero, as it stores a sales call's price beside ContactSales; annualOf reads a zero annual price on a plan that charges back as null, so the seeded authority the endpoint reads answers what the embed does. Charging is unchanged: a year still bills AnnualTotal and is refused where that is 0. TestAnnualPriceIsTheCatalogs marshals the embed and the seeded authority through ReadPlans and requires every plan's priceAnnual to be the catalog's, null included. Co-authored-by: Hanzo Dev <[email protected]>
240 lines
9.8 KiB
Go
240 lines
9.8 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.9"
|
|
|
|
// 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,
|
|
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,
|
|
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 (managed) rows authoritative. Seed values equal the embed,
|
|
// so it changes NO charge — it only makes prices editable + repairs bad rows.
|
|
// Returns (created, corrected); corrected counts reconciles, archives, 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
|
|
})
|
|
}
|