mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
791 lines
34 KiB
Go
791 lines
34 KiB
Go
package plan
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"slices"
|
||
"sync"
|
||
|
||
"github.com/hanzoai/commerce/datastore"
|
||
"github.com/hanzoai/commerce/log"
|
||
"github.com/hanzoai/commerce/models/mixin"
|
||
"github.com/hanzoai/commerce/models/types/currency"
|
||
"github.com/hanzoai/commerce/models/types/refs"
|
||
"github.com/hanzoai/commerce/util/json"
|
||
"github.com/hanzoai/commerce/util/nscontext"
|
||
"github.com/hanzoai/commerce/util/val"
|
||
"github.com/hanzoai/orm"
|
||
|
||
. "github.com/hanzoai/commerce/types"
|
||
)
|
||
|
||
// seedMu serializes concurrent Seed calls IN-PROCESS so the per-slug
|
||
// check-then-create is atomic (Red F3: RunInTransaction is a no-op, plan rows are
|
||
// hashid-keyed so two racing creates can't ON-CONFLICT-collapse). Commerce's data
|
||
// PVC is ReadWriteOnce (single writer) and the boot seed runs BEFORE the HTTP
|
||
// server accepts requests, so there is no cross-process concurrent writer; this
|
||
// mutex closes the only real window — two concurrent in-process callers (e.g. the
|
||
// admin POST /plans/seed racing itself). Keeping plan hashid-keyed (not
|
||
// WithStringKey) avoids changing sub.PlanId across the money path.
|
||
var seedMu sync.Mutex
|
||
|
||
// Namespace is the platform-global namespace the plan authority lives in — the
|
||
// SAME "system" namespace the product catalog uses. Subscription and DNS plan
|
||
// pricing is platform-wide and governed centrally by admin.hanzo.ai, NOT per
|
||
// tenant, so every authoritative touch of a plan (the boot seed, the SuperAdmin
|
||
// CRUD, GET /v1/billing/plans, and resolveSubscriptionPlan → the internal-ledger
|
||
// charge) resolves HERE. Kept identical to catalog's "system" so one platform
|
||
// namespace holds all cross-tenant CMS/pricing data.
|
||
const Namespace = "system"
|
||
|
||
// AuthorityDB returns a datastore scoped (via context — the only namespacing the
|
||
// SQL layer honors) to the platform plan-authority namespace. Pass the request
|
||
// context so tracing/deadlines flow through; a nil context degrades to
|
||
// Background (bootstrap/CLI callers).
|
||
func AuthorityDB(ctx context.Context) *datastore.Datastore {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
return datastore.New(nscontext.WithNamespace(ctx, Namespace))
|
||
}
|
||
|
||
var IgnoreFieldMismatch = datastore.IgnoreFieldMismatch
|
||
|
||
func init() { orm.Register[Plan]("plan") }
|
||
|
||
// Based On Stripe Plan
|
||
// Stripe\Plan JSON: {
|
||
// "id": "gold21323",
|
||
// "object": "plan",
|
||
// "amount": 2000,
|
||
// "created": 1386247539,
|
||
// "currency": "usd",
|
||
// "interval": "month",
|
||
// "interval_count": 1,
|
||
// "livemode": false,
|
||
// "metadata": {
|
||
// },
|
||
// "name": "New plan name",
|
||
// "statement_descriptor": null,
|
||
// "trial_period_days": null
|
||
// }
|
||
|
||
type Plan struct {
|
||
mixin.Model[Plan]
|
||
|
||
// Unique human readable id
|
||
Slug string `json:"slug"`
|
||
// Internal id
|
||
SKU string `json:"sku"`
|
||
|
||
// Human readable name
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
|
||
// Category is the plan family ("personal"/"team"/"enterprise"/"world"/
|
||
// "social"/"dns"); GET /v1/billing/plans?category= filters on it.
|
||
Category string `json:"category"`
|
||
|
||
Price currency.Cents `json:"price"`
|
||
// PriceAnnual is the per-month price when billed annually, in cents — the
|
||
// authoritative annual price (previously derived at the read edge from the
|
||
// embed). Like Price it is a TYPED money field, never an untyped Metadata
|
||
// value, so a stored/spoofed plan copy can never inflate it. Zero on a plan
|
||
// that charges means the catalog states no annual price (null): a plan sold
|
||
// by the month only, or a sales call. The wire serves it as null again.
|
||
PriceAnnual currency.Cents `json:"priceAnnual"`
|
||
// AnnualTotal is what a year of this plan charges, in cents: the catalog's
|
||
// price_ref.recurring.annual_total_usd. PriceAnnual is that year divided into
|
||
// months and rounded to the cent, so it is a price tag and never a figure to
|
||
// bill from — $199 / 12 is $16.5833…, and twelve rounded months is $198.96.
|
||
// Zero means the plan is not sold by the year.
|
||
AnnualTotal currency.Cents `json:"annualTotal"`
|
||
Currency currency.Type `json:"currency"`
|
||
Interval Interval `json:"interval"`
|
||
IntervalCount int `json:"intervalCount"`
|
||
TrialPeriodDays int `json:"trialPeriodDays"`
|
||
|
||
// PerSeat marks a plan billed per seat (catalog price_ref.recurring.per_seat):
|
||
// invoices charge Price × subscription quantity, floored at 1.
|
||
PerSeat bool `json:"perSeat"`
|
||
|
||
// Prices is every price this plan is sold at, ascending, Prices[0] == Price.
|
||
// A tier the customer buys at ONE price publishes none; a tier sold at a
|
||
// chosen level publishes the whole ladder, and a subscription opens at the
|
||
// level its checkout named (LevelPrice).
|
||
//
|
||
// It is the ONE place a level's price is written down. That is the point: the
|
||
// page renders these, the charge validates against these, and the invoice
|
||
// carries whichever one was chosen — so a price can never be edited in one
|
||
// place and quoted from another. It rides the display envelope with Licensing
|
||
// rather than sitting in a column of its own, and for the same reason: it is a
|
||
// list, it is published as a unit, and it is read whole or not at all.
|
||
//
|
||
// An absent ladder does not break a plan or a subscription. Level 0 is always
|
||
// Price, so a plan that publishes nothing here is sold at exactly one price and
|
||
// every higher level is refused — which is the safe direction if a re-vendored
|
||
// catalog were ever to drop it.
|
||
Prices []currency.Cents `json:"prices,omitempty" datastore:"-"`
|
||
|
||
// ContactSales marks a custom / "contact sales" plan whose price is NULL, not
|
||
// $0 — the ONE way to preserve the free($0)-vs-custom(null) distinction while
|
||
// keeping Price a typed, non-nullable Cents (mirrors the embed's staticPlan).
|
||
// A null-priced plan is stored Price=0 + ContactSales=true; a free plan is
|
||
// Price=0 + ContactSales=false. Never coerce null→0 WITHOUT this flag, or a
|
||
// custom plan spuriously reads as a $0 charge.
|
||
ContactSales bool `json:"contactSales,omitempty"`
|
||
// Popular flags the highlighted tier within a category (display only).
|
||
Popular bool `json:"popular,omitempty"`
|
||
|
||
// AdminEdited marks a row whose CURRENT VALUE WAS DECIDED BY A HUMAN through
|
||
// the SuperAdmin CRUD. It is the discriminator Managed could never be.
|
||
//
|
||
// Managed is set by BOTH the seed and the admin CRUD, so it carries two
|
||
// different facts under one name and the seed cannot tell its own prior
|
||
// output from a deliberate price edit. The consequence was that the seed
|
||
// could never correct itself: once a row existed it was frozen forever, so
|
||
// publishing a new catalog created the plans that were missing and left every
|
||
// plan that had changed at its old price — a catalog half-new and half-stale,
|
||
// which is worse than either.
|
||
//
|
||
// Only api/plan's Create/Update sets this. Seed reconciles anything else to
|
||
// the published catalog, which is exactly the rule everyone assumed already
|
||
// held: the package is the source, an admin edit is an override.
|
||
AdminEdited bool `json:"adminEdited,omitempty"`
|
||
|
||
// Managed marks a row OWNED by the seed or the SuperAdmin CRUD (authoritative).
|
||
// The corrective boot seed force-corrects UNMANAGED rows — accidental partials
|
||
// written by a subscription-flow path (Red: the bundle expansion wrote a
|
||
// Price=0, envelope-less row that under-charged a direct sub) — to the embed,
|
||
// and LEAVES managed rows so an admin price edit is preserved. One flag
|
||
// distinguishes "authoritative" from "accidental", closing the divergence.
|
||
Managed bool `json:"managed,omitempty"`
|
||
|
||
// Status is the row's lifecycle, on the Shopify product model: a plan is
|
||
// ACTIVE (sold and listed), DRAFT (exists, not yet listed), PRIVATE (made for
|
||
// one customer: never listed, assigned by staff) or ARCHIVED (retired, kept).
|
||
// Only ACTIVE rows reach the public catalog.
|
||
//
|
||
// It exists because without it "stop selling this" and "destroy this" were the
|
||
// SAME operation. The public read returned every stored row, so the only way to
|
||
// unlist a plan was DELETE — which takes the row's history with it and orphans
|
||
// any subscription that recorded the slug. Retiring a tier should never be
|
||
// destructive; archiving keeps the row resolvable for invoices and renewals
|
||
// that already reference it.
|
||
//
|
||
// EMPTY MEANS ACTIVE, deliberately. Every row written before this field existed
|
||
// has no value for it, and a zero-value that meant "draft" would unlist the
|
||
// entire catalog on deploy. Use Listed() rather than comparing this directly.
|
||
Status string `json:"status,omitempty"`
|
||
|
||
// The DISPLAY ENVELOPE: presentation, not money. The typed columns above are
|
||
// what charges; these are what the pricing page renders.
|
||
//
|
||
// They are JSON-visible and datastore-SKIPPED on purpose. JSON-visible so the
|
||
// model's shape IS the wire shape — the admin CRUD decodes a request body
|
||
// straight onto a Plan, so whatever GET /v1/billing/plans emits, PUT
|
||
// /v1/plans/entries/:slug accepts. Before they existed the envelope lived ONLY
|
||
// inside Metadata as a packed JSON string, so an admin PUT carrying `features`
|
||
// set the price and silently discarded the copy: a tier could be repriced but
|
||
// never re-described, which is how a plan comes to be sold at one price while
|
||
// its own feature list quotes another.
|
||
//
|
||
// Datastore-skipped because they still PERSIST packed into Metadata_ (Save
|
||
// packs, Load unpacks) — one storage location, unchanged on disk, so existing
|
||
// rows keep working and there is no migration.
|
||
Features []string `json:"features,omitempty" datastore:"-"`
|
||
Bundles []string `json:"bundles,omitempty" datastore:"-"`
|
||
IncludedIn []string `json:"includedIn,omitempty" datastore:"-"`
|
||
Limits *Limits `json:"limits,omitempty" datastore:"-"`
|
||
|
||
// Licensing is what this tier LICENSES — see the type. It rides the envelope
|
||
// with the fields above, but it is NOT display: it decides whether a paying
|
||
// subscriber may run a proprietary product, so it is money-adjacent in the same
|
||
// way Price is.
|
||
Licensing *Licensing `json:"licensing,omitempty" datastore:"-"`
|
||
|
||
// Metadata carries anything else a plan needs to hold, plus the packed
|
||
// envelope above. Metadata_ MUST be datastore:",noindex" (persisted), NOT "-"
|
||
// (skipped): with "-" the whole blob Save()s but never round-trips, so a DB
|
||
// read drops limits/minSeats/features (prod: team.minSeats served null).
|
||
// Mirrors catalogentry exactly — the persisted-blob pattern is the one way.
|
||
Metadata Map `json:"metadata,omitempty" datastore:"-"`
|
||
Metadata_ string `json:"-" datastore:",noindex"`
|
||
|
||
Ref refs.EcommerceRef `json:"ref,omitempty"`
|
||
}
|
||
|
||
// Plan lifecycle, on the Shopify product model. A plan is never deleted to stop
|
||
// selling it — it is archived, so invoices and subscriptions that recorded the
|
||
// slug still resolve.
|
||
const (
|
||
StatusActive = "active" // sold and listed publicly
|
||
StatusDraft = "draft" // exists, not listed — being written
|
||
StatusPrivate = "private" // made for one customer: not listed, assigned by staff
|
||
StatusArchived = "archived" // retired, kept for history
|
||
)
|
||
|
||
// Listed reports whether this plan is ON SALE — the ONE predicate behind both
|
||
// halves of that, because they are one decision:
|
||
//
|
||
// - it appears in the public catalog (GET /v1/billing/plans), and
|
||
// - a NEW subscription may open on it (the three purchase entrypoints).
|
||
//
|
||
// Resolving a plan is a DIFFERENT question and is deliberately not gated here:
|
||
// resolveSubscriptionPlan answers "which row is this" and must keep answering for
|
||
// an archived plan, or a renewal or invoice on a retired tier would fail to price
|
||
// itself. Retiring a tier stops new sales; it never strands a subscriber.
|
||
//
|
||
// An empty Status counts as active: every row written before Status existed has
|
||
// no value for it, and treating that as unlisted would blank the catalog the
|
||
// moment this ships. Only an explicit draft, private or archived hides a row.
|
||
func (p *Plan) Listed() bool {
|
||
return p.Status == "" || p.Status == StatusActive
|
||
}
|
||
|
||
// Assignable reports whether STAFF may put a customer on this plan: everything
|
||
// Listed, and a private plan besides. A private plan is one made for a single
|
||
// customer — a price agreed with them — so it is never offered, and no self-serve
|
||
// path may open it: each of those asks Listed. The staff path, which records a
|
||
// plan paid outside checkout, asks this.
|
||
func (p *Plan) Assignable() bool {
|
||
return p.Listed() || p.Status == StatusPrivate
|
||
}
|
||
|
||
// ErrLevel refuses a level this plan does not publish. It is a refusal about the
|
||
// PLAN, so it says nothing about the caller and carries no amount.
|
||
var ErrLevel = errors.New("plan is not sold at that level")
|
||
|
||
// LevelPrice is what this plan costs at level, and the ONE place that question is
|
||
// answered. Level 0 is Price; every other level is read out of Prices; anything
|
||
// else is ErrLevel.
|
||
//
|
||
// The level is an INDEX, never an amount, and that is the whole safety property.
|
||
// A caller picks one of the prices the catalog already published and the server
|
||
// holds every one of them, so a request to buy a $99 plan for $1 is not merely
|
||
// rejected — there is no field it could be written in. Validating a client-sent
|
||
// amount against a list would refuse the same request, but it would also put an
|
||
// amount on the wire, and the next endpoint to grow one is the endpoint that
|
||
// forgets the check.
|
||
//
|
||
// Level 0 answers Price rather than Prices[0] so that a plan carrying no ladder
|
||
// still sells at its own price, and a ladder that somehow arrived empty refuses
|
||
// every level above the base instead of charging zero.
|
||
func (p *Plan) LevelPrice(level int) (currency.Cents, error) {
|
||
if level == 0 {
|
||
return p.Price, nil
|
||
}
|
||
if level < 0 || level >= len(p.Prices) {
|
||
return 0, ErrLevel
|
||
}
|
||
return p.Prices[level], nil
|
||
}
|
||
|
||
// YearlyTotal returns the authoritative yearly price charged upfront in cents,
|
||
// falling back to AnnualTotal field or standard tier multipliers.
|
||
func (p *Plan) YearlyTotal() currency.Cents {
|
||
if p.AnnualTotal > 0 {
|
||
return p.AnnualTotal
|
||
}
|
||
switch p.Slug {
|
||
case "dev", "pro":
|
||
return 20000
|
||
case "max-5x":
|
||
return 100000
|
||
case "max-20x":
|
||
return 200000
|
||
case "max-50x":
|
||
return 500000
|
||
case "team":
|
||
return 24000
|
||
case "team-premium":
|
||
return 96000
|
||
case "dns-pro":
|
||
return 4800
|
||
case "dns-enterprise":
|
||
return 24000
|
||
default:
|
||
if p.PriceAnnual > 0 {
|
||
return currency.Cents(int64(p.PriceAnnual) * 12)
|
||
}
|
||
if p.Price > 0 {
|
||
return currency.Cents(int64(p.Price) * 10)
|
||
}
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// Licensing is what a tier LICENSES: the proprietary products, app builds and
|
||
// engine capabilities a subscription on it may run. The catalog publishes it under
|
||
// the licensing.* entitlement keys.
|
||
//
|
||
// It is persisted on the ROW, rather than looked up in the catalog when the
|
||
// question is asked, for the same reason Category and Price are. The catalog lists
|
||
// what is ON SALE TODAY. A retired tier is not on sale, so asking the price list
|
||
// what an archived tier licensed answers "nothing" — and answers it definitively,
|
||
// which reads as a real refusal rather than a missing record. A subscriber who is
|
||
// still being charged then loses the products they are paying for.
|
||
//
|
||
// Status already promises the row stays resolvable after retirement, so invoices
|
||
// and renewals keep pricing themselves. Carrying the licensing block is what
|
||
// extends that same promise to licences: retiring a tier stops new sales; it never
|
||
// strands a subscriber.
|
||
type Licensing struct {
|
||
// Products are the commerce SKUs / proprietary products this tier entitles
|
||
// ("engine", "engine-rocm", "team", plugin ids).
|
||
Products []string `json:"products,omitempty"`
|
||
// Apps are the engine app builds it licenses ("hanzo", "lux", "zoo").
|
||
Apps []string `json:"apps,omitempty"`
|
||
// Features are engine capability tokens granted verbatim ("inference",
|
||
// "training", …).
|
||
Features []string `json:"features,omitempty"`
|
||
// Seats is the number of named users/devices the licence covers; -1 is
|
||
// unlimited, nil is unstated. (Distinct from Limits.MaxMembers, which counts
|
||
// billing-org membership.)
|
||
Seats *int `json:"seats,omitempty"`
|
||
// EarlyAccess opens every product in beta to a subscriber's org without an
|
||
// application to the beta (catalog licensing.early_access; every Max rung).
|
||
// Cloud's beta predicate reads it here, on the row, so a retired tier keeps it
|
||
// the way it keeps its products.
|
||
EarlyAccess bool `json:"earlyAccess,omitempty"`
|
||
}
|
||
|
||
// Limits is a plan's limits block — capacities, seat floors, included credit and
|
||
// the DNS quotas. It lives HERE, with the plan, because it is plan data: the
|
||
// catalog publishes it and the row stores it. api/billing aliases this type
|
||
// rather than declaring a second copy, and serves only its capacities in public
|
||
// (api/billing Capacity). It states no AI usage: what a plan allows of AI is
|
||
// cloud's usage policy (apps/ai/limits), so a field for it here would be a second
|
||
// home for one figure.
|
||
type Limits struct {
|
||
FreeCredit *int `json:"freeCredit,omitempty"`
|
||
MaxMembers *int `json:"maxMembers,omitempty"`
|
||
|
||
// MinSeats is the minimum billable seat count for per-seat plans
|
||
// (price_ref.recurring.per_seat) — the ONE canonical home for seat minimums.
|
||
// Billing charges at least this many seats.
|
||
MinSeats *int `json:"minSeats,omitempty"`
|
||
|
||
// TeamGuests is the back-compat source for the team.guests entitlement:
|
||
// max invited guests on the holder's hanzo.team workspace (-1 = unlimited).
|
||
TeamGuests *int `json:"teamGuests,omitempty"`
|
||
|
||
// Agents and Bots are how many of each the plan may RUN — the back-compat
|
||
// source for the ai.agents / ai.bots entitlements. They are a CAPACITY, not
|
||
// an allowance of runtime: the hours those agents run meter separately at
|
||
// the catalog's hourly rate (seats.json runtime.agentHourUSD). Reading an
|
||
// included count as included hours is the expensive mistake in both
|
||
// directions — it either bills a customer for what they were promised or
|
||
// hands out a resident bot's month for nothing.
|
||
//
|
||
// -1 is unlimited, as it is for MaxMembers. A present 0 means the plan
|
||
// includes NONE of that kind and is enforceable; nil means no one has said,
|
||
// and a caller must not read it as zero — that difference is what stops a
|
||
// tier whose catalog entry predates this field from locking its holders out.
|
||
Agents *int `json:"agents,omitempty"`
|
||
Bots *int `json:"bots,omitempty"`
|
||
|
||
// IncludedCreditUsd is a legacy alias (entitlements["commerce.included_credit_usd"]).
|
||
// IncludedCloudCredits(+PerUser) is the cloud allowance the monthly allotment
|
||
// grants (the canonical cloud.included_credits_usd entitlement).
|
||
IncludedCreditUsd *int `json:"includedCreditUsd,omitempty"`
|
||
IncludedCloudCredits *int `json:"includedCloudCredits,omitempty"`
|
||
IncludedCloudCreditsPerUser *int `json:"includedCloudCreditsPerUser,omitempty"`
|
||
|
||
// DNS limits
|
||
Zones *int `json:"zones,omitempty"`
|
||
RecordsPerZone *int `json:"recordsPerZone,omitempty"`
|
||
QueriesPerDay *int `json:"queriesPerDay,omitempty"`
|
||
}
|
||
|
||
// EnvelopeKey is the ONE Metadata key the display envelope is packed under. One
|
||
// key holding one JSON string, so it round-trips cleanly through the
|
||
// Map[string]interface{} the ORM deserializes Metadata into — a string survives,
|
||
// where a nested struct would degrade to map[string]interface{} and lose the
|
||
// typed Limits shape.
|
||
const EnvelopeKey = "envelope"
|
||
|
||
// envelope is the on-disk form of the display fields. It is unexported because
|
||
// nothing outside this file should construct one: the typed fields on Plan are
|
||
// the interface, and this is only how they are written down.
|
||
type envelope struct {
|
||
Features []string `json:"features,omitempty"`
|
||
Bundles []string `json:"bundles,omitempty"`
|
||
IncludedIn []string `json:"includedIn,omitempty"`
|
||
Limits *Limits `json:"limits,omitempty"`
|
||
Licensing *Licensing `json:"licensing,omitempty"`
|
||
Prices []currency.Cents `json:"prices,omitempty"`
|
||
}
|
||
|
||
func (p *Plan) Load(ps []datastore.Property) (err error) {
|
||
// Load supported properties
|
||
if err = datastore.LoadStruct(p, ps); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Deserialize from datastore
|
||
if len(p.Metadata_) > 0 {
|
||
if err = json.DecodeBytes([]byte(p.Metadata_), &p.Metadata); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// Unpack the display envelope onto its typed fields. A row stores it as one
|
||
// JSON string under EnvelopeKey; callers read Features/Limits/… directly.
|
||
if s, ok := p.Metadata[EnvelopeKey].(string); ok && s != "" {
|
||
var env envelope
|
||
if err := json.DecodeBytes([]byte(s), &env); err == nil {
|
||
p.Features, p.Bundles, p.IncludedIn, p.Limits = env.Features, env.Bundles, env.IncludedIn, env.Limits
|
||
p.Licensing, p.Prices = env.Licensing, env.Prices
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (p *Plan) Save() (ps []datastore.Property, err error) {
|
||
// Pack the display envelope into Metadata under its one key.
|
||
//
|
||
// It goes into Metadata, not Metadata_, because Metadata is what actually
|
||
// persists: the ORM stores a row as JSON of the struct, and Metadata_ is
|
||
// `json:"-"`. Metadata_ is the older datastore-property path, kept because
|
||
// Load still honors it, but a value written only there does not survive.
|
||
env := envelope{Features: p.Features, Bundles: p.Bundles, IncludedIn: p.IncludedIn, Limits: p.Limits, Licensing: p.Licensing, Prices: p.Prices}
|
||
if len(env.Features) > 0 || len(env.Bundles) > 0 || len(env.IncludedIn) > 0 || env.Limits != nil || env.Licensing != nil || len(env.Prices) > 0 {
|
||
if p.Metadata == nil {
|
||
p.Metadata = Map{}
|
||
}
|
||
p.Metadata[EnvelopeKey] = string(json.EncodeBytes(&env))
|
||
} else if p.Metadata != nil {
|
||
// Nothing to carry: drop a stale envelope rather than leave the previous
|
||
// one behind, or clearing a plan's features would be a no-op.
|
||
delete(p.Metadata, EnvelopeKey)
|
||
}
|
||
p.Metadata_ = string(json.EncodeBytes(&p.Metadata))
|
||
|
||
// Save properties
|
||
return datastore.SaveStruct(p)
|
||
}
|
||
|
||
func (p *Plan) Validator() *val.Validator {
|
||
return val.New()
|
||
}
|
||
|
||
func New(db *datastore.Datastore) *Plan {
|
||
p := new(Plan)
|
||
p.Init(db)
|
||
return p
|
||
}
|
||
|
||
func Query(db *datastore.Datastore) datastore.Query {
|
||
return db.Query("plan")
|
||
}
|
||
|
||
// Seed reconciles the plan authority to the published catalog. It is safe to run
|
||
// on EVERY boot (cheap: one point query per row; writes only when needed) and is
|
||
// the ONE seeder.
|
||
//
|
||
// The rule is simply: the published catalog decides, an admin edit overrides.
|
||
//
|
||
// - MISSING → create it.
|
||
// - present, ADMIN-EDITED → LEAVE. A human decided this value; the catalog does
|
||
// not get to overwrite it. That is the whole point of an editable authority.
|
||
// Once the catalog publishes exactly what the human decided, the row is the
|
||
// catalog's again: the mark is cleared and the next catalog change reaches it.
|
||
// - present, otherwise → RECONCILE its typed money fields + display envelope to
|
||
// the catalog. This covers both a row the seed itself wrote earlier (which it
|
||
// previously could never correct — see AdminEdited) and a partial row written
|
||
// by a subscription-flow path such as the bundle expansion, which once wrote
|
||
// Price=0 and under-charged.
|
||
//
|
||
// Rows the catalog NO LONGER publishes are ARCHIVED, not deleted and not left
|
||
// listed. A tier that stops being published must stop being sold, or retiring it
|
||
// requires a second manual step that is easy to forget and easy to get wrong;
|
||
// archiving keeps the row resolvable for invoices and renewals that recorded the
|
||
// slug. An admin-edited row is never archived this way — a plan a human added on
|
||
// purpose is not "missing from the catalog", it is theirs.
|
||
//
|
||
// Idempotent: a second run finds every row already equal to the catalog and
|
||
// writes nothing. seedMu makes the per-slug check-then-write atomic in-process.
|
||
// Returns (created, corrected) where corrected counts reconciles + archives.
|
||
func Seed(db *datastore.Datastore, rows []*Plan) (created, corrected int, err error) {
|
||
seedMu.Lock()
|
||
defer seedMu.Unlock()
|
||
for _, r := range rows {
|
||
if r == nil || r.Slug == "" {
|
||
continue
|
||
}
|
||
existing := New(db)
|
||
ok, qerr := existing.Query().Filter("Slug=", r.Slug).Get()
|
||
if qerr != nil {
|
||
return created, corrected, qerr
|
||
}
|
||
if ok {
|
||
if existing.AdminEdited {
|
||
// The catalog caught up with the edit: hand the row back, so a
|
||
// later reprice or rewording in the catalog is not stopped by a
|
||
// decision the catalog already carries.
|
||
if catalogStatus(existing.Status) == catalogStatus(r.Status) && planEqual(existing, r) {
|
||
// The admin CRUD does not take seedMu, so the release is a swap:
|
||
// it writes only while the row is still the one read here, and
|
||
// an edit landing in between keeps the row the admin's.
|
||
existing.AdminEdited = false
|
||
existing.Managed = true
|
||
wrote, serr := existing.Swap()
|
||
if serr != nil {
|
||
return created, corrected, serr
|
||
}
|
||
if !wrote {
|
||
continue
|
||
}
|
||
log.Info("plan authority: %q matches the catalog and is the catalog's again", r.Slug)
|
||
corrected++
|
||
}
|
||
continue
|
||
}
|
||
// THE CATALOG DECIDES WHETHER A PLAN IS SOLD, IN BOTH DIRECTIONS. The
|
||
// loop below archives a slug the catalog stopped publishing; this is
|
||
// the other half, and without it a retired tier can never come back.
|
||
// Status is carried by neither copyInto nor planEqual, so a
|
||
// republished row reconciles to the catalog's values and stays
|
||
// invisible — reported as present by the seed and absent by the public
|
||
// catalog, with nothing to read that says why. The catalog's own status
|
||
// is the answer: active makes a row sellable, draft makes it unlisted,
|
||
// and a row already where the catalog says is left alone.
|
||
want := catalogStatus(r.Status)
|
||
moved := catalogStatus(existing.Status) != want
|
||
if !moved && planEqual(existing, r) {
|
||
continue // already the published value — write nothing
|
||
}
|
||
copyInto(existing, r)
|
||
existing.Status = want
|
||
existing.Managed = true
|
||
if uerr := existing.Update(); uerr != nil {
|
||
return created, corrected, uerr
|
||
}
|
||
corrected++
|
||
continue
|
||
}
|
||
e := New(db)
|
||
copyInto(e, r)
|
||
e.Status = r.Status // a draft row is born unlisted
|
||
e.Managed = true
|
||
if cerr := e.Create(); cerr != nil {
|
||
return created, corrected, cerr
|
||
}
|
||
created++
|
||
}
|
||
|
||
// Retire what the catalog stopped publishing.
|
||
published := make(map[string]bool, len(rows))
|
||
for _, r := range rows {
|
||
if r != nil {
|
||
published[r.Slug] = true
|
||
}
|
||
}
|
||
var all []*Plan
|
||
if _, qerr := Query(db).GetAll(&all); qerr != nil {
|
||
return created, corrected, qerr
|
||
}
|
||
for _, row := range all {
|
||
if row.AdminEdited || published[row.Slug] || !row.Listed() {
|
||
continue
|
||
}
|
||
// Re-load through the same point query the reconcile above uses. A row
|
||
// that arrived via GetAll is a VALUE — it carries no datastore binding,
|
||
// so Update on it writes nowhere and reports no error.
|
||
existing := New(db)
|
||
ok, qerr := existing.Query().Filter("Slug=", row.Slug).Get()
|
||
if qerr != nil {
|
||
return created, corrected, qerr
|
||
}
|
||
if !ok {
|
||
continue
|
||
}
|
||
existing.Status = StatusArchived
|
||
// An archived row is sold at no level. Renewals bill the subscription's own
|
||
// snapshot, so the ladder here prices nothing anyone holds; keeping it would
|
||
// leave a price list for a plan that is not for sale.
|
||
existing.Prices = nil
|
||
if uerr := existing.Update(); uerr != nil {
|
||
return created, corrected, uerr
|
||
}
|
||
corrected++
|
||
}
|
||
return created, corrected, nil
|
||
}
|
||
|
||
// catalogStatus is a status as the catalog states it: absent is active, so a row
|
||
// written before the field existed and a catalog row that states none agree.
|
||
func catalogStatus(s string) string {
|
||
if s == "" {
|
||
return StatusActive
|
||
}
|
||
return s
|
||
}
|
||
|
||
// planEqual reports whether a stored row already carries the catalog's values, so
|
||
// a reconciling boot writes nothing when there is nothing to change. Compares the
|
||
// fields copyInto actually sets — money, identity and the display envelope.
|
||
func planEqual(a, b *Plan) bool {
|
||
if a.Name != b.Name || a.Description != b.Description || a.Category != b.Category ||
|
||
a.Price != b.Price || a.PriceAnnual != b.PriceAnnual || a.AnnualTotal != b.AnnualTotal || a.Currency != b.Currency ||
|
||
a.Interval != b.Interval || a.IntervalCount != b.IntervalCount ||
|
||
a.TrialPeriodDays != b.TrialPeriodDays || a.PerSeat != b.PerSeat ||
|
||
a.ContactSales != b.ContactSales || a.Popular != b.Popular {
|
||
return false
|
||
}
|
||
return slices.Equal(a.Features, b.Features) &&
|
||
slices.Equal(a.Bundles, b.Bundles) &&
|
||
slices.Equal(a.IncludedIn, b.IncludedIn) &&
|
||
slices.Equal(a.Prices, b.Prices) &&
|
||
limitsEqual(a.Limits, b.Limits) &&
|
||
licensingEqual(a.Licensing, b.Licensing)
|
||
}
|
||
|
||
// licensingEqual compares two licensing blocks BY VALUE, for the same reason
|
||
// limitsEqual exists: Seats is a *int, so struct equality would compare it by
|
||
// address and the seed would rewrite every row on every boot.
|
||
func licensingEqual(a, b *Licensing) bool {
|
||
if a == nil || b == nil {
|
||
return a == b
|
||
}
|
||
return slices.Equal(a.Products, b.Products) &&
|
||
slices.Equal(a.Apps, b.Apps) &&
|
||
slices.Equal(a.Features, b.Features) &&
|
||
eqInt(a.Seats, b.Seats) &&
|
||
a.EarlyAccess == b.EarlyAccess
|
||
}
|
||
|
||
// limitsEqual compares Limits BY VALUE.
|
||
//
|
||
// `*a == *b` is wrong here and quietly so: every field is a *int, and struct
|
||
// equality compares pointer fields by ADDRESS. Two Limits decoded separately from
|
||
// the same JSON hold equal numbers at different addresses, so the seed would find
|
||
// every row unequal and rewrite all of them on every boot — values still correct,
|
||
// but `corrected` never reaching zero, which is precisely the signal that tells
|
||
// an operator the catalog has converged.
|
||
func limitsEqual(a, b *Limits) bool {
|
||
if a == nil || b == nil {
|
||
return a == b
|
||
}
|
||
return eqInt(a.FreeCredit, b.FreeCredit) &&
|
||
eqInt(a.MaxMembers, b.MaxMembers) &&
|
||
eqInt(a.MinSeats, b.MinSeats) &&
|
||
eqInt(a.TeamGuests, b.TeamGuests) &&
|
||
eqInt(a.Agents, b.Agents) &&
|
||
eqInt(a.Bots, b.Bots) &&
|
||
eqInt(a.IncludedCreditUsd, b.IncludedCreditUsd) &&
|
||
eqInt(a.IncludedCloudCredits, b.IncludedCloudCredits) &&
|
||
eqInt(a.IncludedCloudCreditsPerUser, b.IncludedCloudCreditsPerUser) &&
|
||
eqInt(a.Zones, b.Zones) &&
|
||
eqInt(a.RecordsPerZone, b.RecordsPerZone) &&
|
||
eqInt(a.QueriesPerDay, b.QueriesPerDay)
|
||
}
|
||
|
||
// eqInt compares two optional ints by value; both-absent counts as equal.
|
||
func eqInt(a, b *int) bool {
|
||
if a == nil || b == nil {
|
||
return a == b
|
||
}
|
||
return *a == *b
|
||
}
|
||
|
||
// copyInto applies what the CATALOG PUBLISHES onto dst, which keeps its own
|
||
// datastore binding. Every field here is one planEqual also compares — that is
|
||
// not a coincidence to maintain by hand, it is the invariant that makes "reconcile
|
||
// only when different" mean the same thing as "copy": a field copied but not
|
||
// compared would be rewritten forever or cleared silently, depending on which
|
||
// other fields happened to differ.
|
||
//
|
||
// So SKU and Metadata are deliberately NOT here. The catalog publishes neither
|
||
// (canonicalPlan has no SKU field, and the display envelope now rides typed
|
||
// fields that Save packs), so assigning them would only ever clear a stored value
|
||
// the catalog has no opinion about. Save updates the envelope key inside whatever
|
||
// Metadata the row already carries, leaving any other key intact.
|
||
func copyInto(dst, src *Plan) {
|
||
dst.Slug = src.Slug
|
||
dst.Name = src.Name
|
||
dst.Description = src.Description
|
||
dst.Category = src.Category
|
||
dst.Price = src.Price
|
||
dst.PriceAnnual = src.PriceAnnual
|
||
dst.AnnualTotal = src.AnnualTotal
|
||
dst.Prices = src.Prices
|
||
dst.Currency = src.Currency
|
||
dst.Interval = src.Interval
|
||
dst.IntervalCount = src.IntervalCount
|
||
dst.TrialPeriodDays = src.TrialPeriodDays
|
||
dst.PerSeat = src.PerSeat
|
||
dst.ContactSales = src.ContactSales
|
||
dst.Popular = src.Popular
|
||
dst.Features = src.Features
|
||
dst.Bundles = src.Bundles
|
||
dst.IncludedIn = src.IncludedIn
|
||
dst.Limits = src.Limits
|
||
dst.Licensing = src.Licensing
|
||
}
|
||
|
||
// Backfill writes licensing onto rows the catalog NO LONGER PUBLISHES, from the
|
||
// record of what each tier licensed when it was last on sale. It returns how many
|
||
// rows it filled.
|
||
//
|
||
// It exists because persisting the licensing block only fixes tiers retired AFTER
|
||
// this change: the seed writes licensing from the catalog while the catalog still
|
||
// describes the tier, and archiving then preserves it. Rows archived BEFORE this
|
||
// change were written when Plan had no licensing field at all, so they carry none,
|
||
// and the catalog that could have told us has already dropped them. Those are
|
||
// exactly the subscribers who are stranded today, so a fix that skips them fixes
|
||
// nothing that is currently broken.
|
||
//
|
||
// This is a MIGRATION, not a lookup: it writes the fact onto the row once, and
|
||
// afterwards the row answers for itself. Nothing reads this map to serve a request.
|
||
// It can never grow, either — from this change on, a tier carries its licensing
|
||
// before it is ever archived.
|
||
//
|
||
// Only rows that carry NO licensing are touched, so it is idempotent and it never
|
||
// overwrites a value the catalog or an admin has since supplied. Slugs the caller
|
||
// does not name are left alone: a tier that licensed nothing is correctly
|
||
// represented by no block at all.
|
||
func Backfill(db *datastore.Datastore, licensing map[string]*Licensing) (filled int, err error) {
|
||
seedMu.Lock()
|
||
defer seedMu.Unlock()
|
||
for slug, lic := range licensing {
|
||
if slug == "" || lic == nil {
|
||
continue
|
||
}
|
||
// Point-query the row so it carries a datastore binding: a row read via
|
||
// GetAll is a VALUE, and Update on it writes nowhere and reports no error.
|
||
existing := New(db)
|
||
ok, qerr := existing.Query().Filter("Slug=", slug).Get()
|
||
if qerr != nil {
|
||
return filled, qerr
|
||
}
|
||
if !ok || existing.Licensing != nil {
|
||
continue
|
||
}
|
||
// The row has to still be RETIRED. This map records what a tier licensed
|
||
// when it was last on sale, so it is an answer about a tier that is no
|
||
// longer sold; a slug that is listed again is a different product wearing
|
||
// a name that was already spent, and the terms of the old one are not its
|
||
// terms. Matching on the slug alone made the boot seed hand a revived tier
|
||
// whatever the retired one licensed, on every boot, for free — and grants
|
||
// are the half of a plan a tenant must never be able to set for itself.
|
||
//
|
||
// This is a guard against reuse, not a prediction that it will happen: the
|
||
// safe move is to never reuse a slug at all, because an archived row exists
|
||
// so that renewals and invoices which recorded that slug still price from
|
||
// it, and pointing the name at something else rewrites what those records
|
||
// mean. The guard is what makes the mistake visible instead of expensive.
|
||
if existing.Listed() {
|
||
continue
|
||
}
|
||
existing.Licensing = lic
|
||
if uerr := existing.Update(); uerr != nil {
|
||
return filled, uerr
|
||
}
|
||
filled++
|
||
}
|
||
return filled, nil
|
||
}
|