Files
commerce/api/billing/tier.go

504 lines
20 KiB
Go

package billing
import (
"context"
"errors"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/bucket"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/hanzoai/commerce/billing/tier"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json/http"
)
// TierLimits is what a tier ALLOWS, as the wire has always carried it: the
// registry's configuration plus the one fact that is derived rather than stored.
//
// tier.Config is embedded, not copied field by field. The registry is the
// authority on what a tier allows, and a second declaration of its five fields
// here is how a read and a gate come to disagree about maxAgents.
type TierLimits struct {
tier.Config
// MaxAgents and MaxBots are what the customer's PLAN includes — the capacity
// published in the catalog (subscription.json `limits.agents` / `limits.bots`),
// read by slug through AgentsIncluded and BotsIncluded. They are composed here
// rather than stored on tier.Config, because a tier is not a plan: six slugs
// collapse onto four tier names, so a registry keyed by name cannot answer a
// question the catalog answers per slug.
//
// The WIRE reading is the one this field has always had, and it is not the
// catalog's: 0 means NO CEILING. The two agree wherever it matters — an
// unlimited plan (catalog -1) and a plan the catalog is silent about both
// serve without a bound, which is 0 here — and `capacity` is the one place the
// translation happens, with TestTheWireKeepsItsUnlimitedReading pinning it.
MaxAgents int `json:"maxAgents"`
MaxBots int `json:"maxBots"`
// UnlimitedAgents reports that MaxAgents 0 means "no ceiling" rather than
// "no agents" — the reading a bare zero cannot carry.
UnlimitedAgents bool `json:"unlimitedAgents"`
}
// capacity translates the catalog's published roster into the wire's reading.
//
// The catalog says three things and the wire has room for two, so this is where
// the difference is decided rather than at each reader:
//
// (-1, true) unlimited -> 0, the wire's own "no ceiling"
// (n>0, true) a real bound -> n
// (_, false) the catalog is silent -> 0, serve WITHOUT a bound
//
// The silent case is the important one and it admits, per the catalog's own rule:
// "a missing CAPACITY has no safe number. Zero would refuse a customer their
// first agent." So does a plan that includes NONE of a kind — (0, true) — which
// this wire cannot distinguish from unlimited; it is returned as 0 with the same
// admitting reading, because enforcing a bound no reader can tell apart from its
// opposite is worse than enforcing none.
func capacity(n int, known bool) int {
if !known || n < 0 {
return 0
}
return n
}
// tierLimits composes what a TIER allows with what the PLAN includes. One
// constructor, so a read and a check cannot compose them differently.
func tierLimits(cfg *tier.Config, slug string) TierLimits {
agents := capacity(AgentsIncluded(slug))
return TierLimits{
Config: *cfg,
MaxAgents: agents,
MaxBots: capacity(BotsIncluded(slug)),
UnlimitedAgents: agents == 0,
}
}
// TierBalance is what the subject can actually spend, from the same three-bucket
// split the balance endpoint serves.
//
// EffectiveAvailable is the only figure a gate should compare against zero. The
// others are its parts, and prepaid + credits + daily are three sources of one
// spend, not three balances to add up a second time.
type TierBalance struct {
// Currency is the ISO code the figures are denominated in.
Currency currency.Type `json:"currency"`
// PrepaidAvailable is real money on the ledger, less holds, floored at zero.
PrepaidAvailable currency.Cents `json:"prepaidAvailable"`
// CreditsRemaining is granted, still-active, non-cash credit — spendable on
// metered usage, never on GPUs.
CreditsRemaining currency.Cents `json:"creditsRemaining"`
// DailyRemaining is what is left of the tier's daily allowance today. It is 0
// for every registered tier; see ReadTier.
DailyRemaining int64 `json:"dailyRemaining"`
// EffectiveAvailable is spendable balance plus the daily term — what the gate
// in front of the models reads.
EffectiveAvailable int64 `json:"effectiveAvailable"`
}
// TierView is a subject's tier as a TYPED value: which tier they are on, what it
// allows, and what they can spend. What a plan allows of AI is cloud's usage
// policy, read at /v1/ai/limits; this carries no figure of it.
type TierView struct {
// User is the billing subject this answers for.
User string `json:"user"`
// Plan is the catalog rung the subject is served as, or "" when they hold no
// subscription. A tier names a class of plan (every paid rung is Pro); a bound
// that differs between rungs of one class — the rolling spend cap Max
// multiplies — is keyed on this. A retired slug answers as its successor, the
// same rung every other gate reads, so a max holder reads max-5x here.
Plan string `json:"plan,omitempty"`
// Subscription is the id of the subscription row Plan is served from, when the
// tier answered is the one that row confers; "" when no row serves the subject,
// or another of the subject's rows confers the tier.
Subscription string `json:"subscription,omitempty"`
// Tier is the tier's own bounds.
Tier TierLimits `json:"tier"`
// Balance is what they can spend right now.
Balance TierBalance `json:"balance"`
}
// errTierNoOrg is ReadTier REFUSING the question rather than failing at it: with
// no tenant named there is no ledger to read, and a tier answered from nothing
// would be a tier invented.
var errTierNoOrg = errors.New("tier: no organization")
// IsTierRefusal reports whether ReadTier refused the question — no tenant named —
// as against failing to read one that exists.
//
// The two must stay apart at every caller, which is why the class is exported
// rather than left as a sentinel only this package can see. The router in front
// of the models reads ANY non-2xx from the tier endpoint as the free tier, so a
// caller that cannot tell a question it malformed (fix the call) from a ledger it
// could not read (retry, or hold the last-known tier) pins a paying customer to
// the most restrictive tier in the table, silently. The endpoint maps the first to
// 400 and the second to 500; a peer on the internal plane splits them here.
func IsTierRefusal(err error) bool { return errors.Is(err, errTierNoOrg) }
// ReadTier is a subject's tier and what it leaves them able to spend — the
// QUESTION, with no HTTP in it.
//
// It takes values rather than a request so a caller that is not a request can
// ask: the rate limiter in front of the models asks this of every call over the
// internal plane, holding no ledger of its own, and re-deriving it there would be
// a second answer to "may this subject spend" — which is the one question that
// must have exactly one.
//
// The tier is DERIVED here, from the subject's own recorded subscriptions, and
// from nothing else: no caller, however privileged, names one. Our own orgs
// resolve exactly like a customer's.
//
// Its failures are the subscription read and the ledger read; neither is ever
// answered as Free. The daily term reports what it can and stays quiet
// otherwise, exactly as it does on the wire.
func ReadTier(ctx context.Context, org *organization.Organization, user string) (*TierView, error) {
if org == nil {
return nil, errTierNoOrg
}
ctx = org.Namespaced(ctx)
name, err := deriveTier(datastore.New(ctx), user, org.TestMode())
if err != nil {
return nil, err
}
cfg := tier.Get(name)
// Spendable balance from the SAME three-bucket split the balance endpoint
// serves — prepaid real money AND still-active granted credits both spend
// (credits-first). A bespoke prepaid-only read silently zeroes accounts funded
// purely by a starter/promo grant, 402-gating orgs that hold real spendable
// credit.
cur := currency.Type("usd")
split, err := tierSplit(ctx, org, user, cur)
if err != nil {
return nil, err
}
prepaidAvailable := split.PrepaidAvailable
if prepaidAvailable < 0 {
prepaidAvailable = 0
}
spendable := split.Available
if spendable < 0 {
spendable = 0
}
// Daily replenishing credit. This is 0 for every tier (there is no free
// tier), so HasDailyCredits() is false and dailyRemaining stays 0 —
// effectiveAvailable collapses to prepaidAvailable and a zero-balance
// account is gated. The mechanism is retained (guarded by DailyCreditsCents
// > 0) so a tier could re-enable a daily allowance by configuration alone.
var dailyRemaining int64
if cfg.HasDailyCredits() {
dailyUsed := dailyUsageCents(ctx, user, org.TestMode())
dailyRemaining = cfg.DailyCreditsCents - dailyUsed
if dailyRemaining < 0 {
dailyRemaining = 0
}
}
// ONE row resolution, for the plan and the plan's roster.
// Reading it twice is two chances to answer for two different plans in one
// payload.
served := servedSubscription(datastore.New(ctx), user, org.TestMode())
slug, servedID := "", ""
if served != nil {
slug = subscriptionSlug(served)
if rowTier(served) == name {
servedID = served.Id()
}
}
return &TierView{
User: user,
Plan: servedAs(slug),
Subscription: servedID,
Tier: tierLimits(cfg, slug),
Balance: TierBalance{
Currency: cur,
PrepaidAvailable: prepaidAvailable,
CreditsRemaining: split.CreditsRemaining,
DailyRemaining: dailyRemaining,
EffectiveAvailable: int64(spendable) + dailyRemaining,
},
}, nil
}
// tierSplit is the subject's money as the tier reads it, from the one ledger.
//
// With a host ledger injected the balance lives there — where an admin grant,
// a settled card and the AI gate all address it — and it is one wallet, so it
// is all prepaid; the subject's credit grants are its credits. That is the same
// money, split the same way, that a draw spends. Standalone it is commerce's own
// ledger, split into buckets.
func tierSplit(ctx context.Context, org *organization.Organization, user string, cur currency.Type) (bucket.Split, error) {
if creditledger.Get() == nil {
return bucketedSplit(ctx, user, cur, org.TestMode())
}
pre := prepaidFor(ctx, org)
bal, err := pre.balance(ctx, user, cur)
if err != nil {
return bucket.Split{}, err
}
credits, err := creditsAvailable(pre.db, user)
if err != nil {
return bucket.Split{}, err
}
b, c := currency.Cents(bal), currency.Cents(credits)
return bucket.Split{
CreditsRemaining: c,
PrepaidBalance: b, PrepaidAvailable: b,
Balance: b + c, Available: b + c,
}, nil
}
// TierOf is the tier a subject's own subscriptions confer — the DERIVATION, with
// no request in it.
//
// It is the ONLY source of a tier. There is no override: an X-Tier header or a
// ?tier= parameter is ignored from every caller, SuperAdmins and services
// included, because a tier nobody paid for is a tier minted.
//
// Fail-safe: a lookup error is RETURNED rather than answered as Free, so a
// transient store error can never strip a paid subscriber's tier.
func TierOf(ctx context.Context, org *organization.Organization, user string) (tier.Name, error) {
if org == nil {
// No org means no store to reach, so there is genuinely no subscription
// in view. Free is the answer, not an error.
return tier.Free, nil
}
return deriveTier(datastore.New(org.Namespaced(ctx)), user, org.TestMode())
}
// GetTier is the endpoint over ReadTier.
//
// GET /v1/billing/tier?user=hanzo/alice
//
// There is no free tier: a zero-balance account has effectiveAvailable == 0
// and is gated. Onboarding funds an account once via the starter-credit grant,
// and once that is spent the account is gated until it is topped up.
//
// Every status here is load-bearing. The router in front of the models reads
// this on every request and maps ANY non-2xx to Free, so a refusal it did not
// expect downgrades a paying customer in silence — which is why the two
// unanswerable cases refuse loudly rather than answering Free.
func GetTier(c *zip.Ctx) error {
// No organization on the request is a REFUSAL, not a panic and not a Free.
//
// The one-value GetOrganization type-asserts and blew up here for every
// service-to-service caller — measured live, a 500 on every call. That is
// this route's main caller: IAMTokenRequired resolves an org only from a
// gateway-validated user identity and deliberately falls through on a bare
// X-Org-Id, so an S2S request reaches the handler with a nil org.
//
// Answering Free instead would be worse than the 500. Free is exactly what
// the router falls back to on error, so serving it from a request that could
// read nothing turns an unanswerable question into a confident wrong answer:
// every paying customer silently pinned to the most restrictive tier, with no
// error anywhere to find. A tier that cannot be read is reported as not read.
org, ok := middleware.GetOrganizationOK(c)
if !ok || org == nil {
return http.Fail(c, 400, "organization is required to read a tier", nil)
}
user := strings.ToLower(strings.TrimSpace(c.Query("user")))
if user == "" {
return http.Fail(c, 400, "user query parameter is required", nil)
}
// Fail-safe: a store hiccup must NOT downgrade a paid subscriber to Free.
// Surface the error so the caller retries or holds the last-known tier
// instead of asserting a wrong Free.
view, err := ReadTier(c.Context(), org, user)
if err != nil {
return http.Fail(c, 500, "failed to read tier", err)
}
return c.JSON(200, view)
}
// TierCheck is a lightweight endpoint for model-access gating.
// It returns the tier config and whether a specific model is allowed,
// without computing the full balance. Used by Chat and white-label services.
//
// GET /v1/billing/tier-check?user=hanzo/alice&model=zen4-max
func TierCheck(c *zip.Ctx) error {
user := strings.ToLower(strings.TrimSpace(c.Query("user")))
if user == "" {
return http.Fail(c, 400, "user query parameter is required", nil)
}
model := strings.TrimSpace(c.Query("model"))
// A missing org means no store to reach and so no subscription in view: the
// tier is Free, the slug is empty, the catalog is silent, and `capacity`
// serves without a bound rather than refusing on nothing.
org, _ := middleware.GetOrganizationOK(c)
tierName, err := TierOf(c.Context(), org, user)
if err != nil {
return http.Fail(c, 500, "failed to resolve tier", err)
}
cfg := tier.Get(tierName)
// The SAME composition ReadTier answers with, so a check and a read cannot
// report a different roster for one customer.
slug := ""
if org != nil {
slug = subscriptionPlanSlug(datastore.New(org.Namespaced(c.Context())), user, org.TestMode())
}
lim := tierLimits(cfg, slug)
resp := map[string]any{
"user": user,
"tier": map[string]any{
"name": cfg.Name,
"displayName": cfg.DisplayName,
"allowedModels": cfg.AllowedModels,
"maxAgents": lim.MaxAgents,
"maxBots": lim.MaxBots,
},
}
if model != "" {
resp["model"] = model
resp["allowed"] = cfg.IsModelAllowed(model)
}
return c.JSON(200, resp)
}
// dailyUsageCents sums the api-usage withdrawals for a user since
// midnight UTC today. This determines how much of the free-tier daily
// credit has been consumed.
func dailyUsageCents(ctx context.Context, user string, isTest bool) int64 {
db := datastore.New(ctx)
rootKey := db.NewKey("synckey", "", 1, nil)
now := time.Now().UTC()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
transs := make([]*transaction.Transaction, 0)
q := transaction.Query(db).Ancestor(rootKey).
Filter("Test=", isTest).
Filter("SourceKind=", "iam-user").
Filter("SourceId=", user).
Filter("Tags=", "api-usage")
if _, err := q.GetAll(&transs); err != nil {
return 0
}
var total int64
for _, t := range transs {
if !t.CreatedAt.Before(todayStart) {
total += int64(t.Amount)
}
}
return total
}
// deriveTier resolves a subject's REAL billing tier from their subscriptions: the
// HIGHEST tier any active/trialing subscription confers.
//
// - a trialing subscription → Starter (the entry on-ramp)
// - an active PAID plan → Enterprise (enterprise-category plan) else Pro
// - no active/trialing sub, a $0 / unknown plan, or a canceled/past_due/unpaid
// subscription → Free
//
// A plan's paid-ness and tier are read from the embedded catalog by slug
// (paidTier/lookupPlan), never the subscription's spoofable stored plan copy, so a
// forged plan name cannot inflate a tier. The paid-tier CREATION gate
// (CreateBillingSubscription rejects an org admin self-creating a paid sub) is the
// anti-forgery boundary, so — unlike the money-mint path (subscriptionPlanSlug) —
// NO payment-backed clamp is applied here: a legitimate comped/gifted paid
// subscription (ProviderType "manual_gift", no invoice) MUST still confer its
// tier. The highest qualifying tier wins so a subscriber holding several
// subscriptions is never under-granted.
//
// A plan the catalog does not publish is the other case (activeTier): a private
// plan made for one customer lives only in the plan authority, so what it was
// sold at is read from the snapshot the subscription carries.
//
// Fail-safe: the query error is returned, not collapsed to Free.
func deriveTier(db *datastore.Datastore, user string, test bool) (tier.Name, error) {
subs, err := userSubscriptions(db, user, test)
if err != nil {
return tier.Free, err
}
best := tier.Free
for _, s := range subs {
if t := rowTier(s); tierRank(t) > tierRank(best) {
best = t
}
}
return best, nil
}
// rowTier is the tier one subscription confers: a trialing row Starter, an active
// row its plan's (activeTier), and past_due / unpaid / canceled none.
func rowTier(s *subscription.Subscription) tier.Name {
switch s.Status {
case subscription.Trialing:
return tier.Starter
case subscription.Active:
return activeTier(s)
}
return tier.Free
}
// activeTier is the tier an ACTIVE subscription confers.
//
// A slug the catalog publishes answers from the catalog (tierForActivePaidSlug),
// which no edit to a row or a snapshot can move. A plan only the plan authority
// holds answers from the price the subscription was sold at (paidRow), and only on
// a row a payment opened: the creation gate scores the catalog, so it cannot vouch
// for a plan the catalog does not know, and a self-created internal row on one must
// not confer a tier nobody paid for.
func activeTier(s *subscription.Subscription) tier.Name {
slug := subscriptionSlug(s)
if lookupPlan(slug) != nil {
return tierForActivePaidSlug(slug)
}
if !paidRow(s) || !subscriptionPaymentBacked(s) {
return tier.Free
}
if s.Plan.Category == "enterprise" {
return tier.Enterprise
}
return tier.Pro
}
// tierForActivePaidSlug maps an ACTIVE subscription's plan slug to its tier,
// reading price + category from the embedded catalog (never the stored copy): an
// enterprise-category paid plan → Enterprise, any other paid plan → Pro, and a
// $0 / unknown plan → Free (a $0 plan such as "developer" is self-serve and
// confers no paid tier).
func tierForActivePaidSlug(slug string) tier.Name {
if !paidTier(slug) {
return tier.Free
}
if p := lookupPlan(slug); p != nil && p.Category == "enterprise" {
return tier.Enterprise
}
return tier.Pro
}
// tierRank orders tiers so deriveTier keeps the highest one a subject holds.
func tierRank(n tier.Name) int {
switch n {
case tier.Enterprise:
return 3
case tier.Pro:
return 2
case tier.Starter:
return 1
default:
return 0 // Free / unknown
}
}