mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
504 lines
20 KiB
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
|
|
}
|
|
}
|