Files
commerce/api/billing/usage.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

332 lines
13 KiB
Go

package billing
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/engine"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/events"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/idempotencykey"
"github.com/hanzoai/commerce/models/meter"
"github.com/hanzoai/commerce/models/spendalert"
"github.com/hanzoai/commerce/models/transaction"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json/http"
. "github.com/hanzoai/commerce/types"
)
type usageRequest struct {
User string `json:"user"`
Currency string `json:"currency"`
Amount int64 `json:"amount"` // cents (back-compat; prefer amountMicros)
// AmountMicros is the debit in micro-USD (1e6 = $1). Preferred over Amount:
// it carries sub-cent precision so tiny spends aren't lost to cent rounding.
// Chat's tokenValue is already micro-USD (1e6 tokenCredits = $1), so it maps
// 1:1. Zero/absent => fall back to Amount*10000.
AmountMicros int64 `json:"amountMicros"`
Model string `json:"model"`
Provider string `json:"provider"`
// Project / Service attribute this spend to a scope for per-scope caps
// (issue #70). Empty = the org-wide default scope.
Project string `json:"project"`
Service string `json:"service"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
RequestID string `json:"requestId"`
Premium bool `json:"premium"`
Stream bool `json:"stream"`
Status string `json:"status"`
ClientIP string `json:"clientIp"`
}
// meterUsage records this act on the "api-usage" meter, for an org whose plan
// prices usage from a meter rather than from the debit RecordUsage writes. No
// such meter → nothing to record.
//
// A METER EVENT'S VALUE IS A QUANTITY, AND A CHARGE IS NOT ONE. AggregateUsage
// sums Value and hands the sum to PricingRule.CalculateCost, so a value that is
// already money comes back out multiplied by a unit price — the invoice line
// charges for the charge. This wrote req.Amount, the debit in cents, which was
// wrong twice from one field: non-zero it double-charged, and on the preferred
// amountMicros path `amount` is absent, so the value was 0 and the line vanished.
// The quantity a token charge prices is the tokens; a count-aggregated meter
// ignores the value and counts the row.
//
// IT RUNS BEFORE THE ROUNDING. A charge under half a cent rounds to $0.00 and
// RecordUsage acknowledges it without a debit — statistically fair for money, and
// silent erasure for work. Per-token pricing makes most single calls sub-half-cent,
// so a meter placed after that short-circuit sees almost nothing, which is the
// exact opposite of what a meter is for: accumulating small quantities so they can
// be priced in bulk at the end of the period.
//
// One act, one row: [engine.IngestUsageEvent] already dedups on the idempotency
// key, so a retry of the same requestId counts the tokens once. This used to be a
// hand-rolled second copy of that function without the dedup.
func meterUsage(db *datastore.Datastore, req usageRequest) {
if req.TotalTokens <= 0 && req.AmountMicros <= 0 && req.Amount <= 0 {
return // nothing happened worth metering
}
rootKey := db.NewKey("synckey", "", 1, nil)
meters := make([]*meter.Meter, 0, 1)
q := meter.Query(db).Ancestor(rootKey).Filter("EventName=", "api-usage").Limit(1)
if _, err := q.GetAll(&meters); err != nil || len(meters) == 0 {
return
}
if _, _, err := engine.IngestUsageEvent(db, meters[0].Id(), req.User,
int64(req.TotalTokens), req.RequestID, time.Now(),
map[string]any{"model": req.Model, "provider": req.Provider},
); err != nil {
log.Error("Failed to record usage meter event: %v", err)
}
}
// GetUsage returns usage transactions for an IAM user, filtered by tag "api-usage".
//
// GET /v1/billing/usage?user=hanzo/alice&currency=usd
func GetUsage(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
ctx := org.Namespaced(c.Context())
db := datastore.New(ctx)
user := strings.TrimSpace(c.Query("user"))
if user == "" {
return http.Fail(c, 400, "user query parameter is required", nil)
}
rootKey := db.NewKey("synckey", "", 1, nil)
transs := make([]*transaction.Transaction, 0)
q := transaction.Query(db).Ancestor(rootKey).
Filter("Test=", org.TestMode()).
Filter("SourceKind=", "iam-user").
Filter("SourceId=", user).
Filter("Tags=", "api-usage")
cur := currency.Type(strings.ToLower(c.Query("currency")))
if cur != "" {
q = q.Filter("Currency=", cur)
}
if _, err := q.GetAll(&transs); err != nil {
log.Error("Failed to query usage transactions: %v", err, c)
return http.Fail(c, 500, "failed to query usage", err)
}
items := make([]map[string]any, 0, len(transs))
for _, t := range transs {
items = append(items, map[string]any{
"transactionId": t.Id(),
"amount": t.Amount,
"currency": t.Currency,
"notes": t.Notes,
"metadata": t.Metadata,
"createdAt": t.CreatedAt,
})
}
return c.JSON(200, map[string]any{
"user": user,
"count": len(items),
"usage": items,
})
}
// roundMicrosToCents converts a micro-USD debit (1e6 = $1, so 1 cent = 10_000
// micros) to whole cents, rounding to the NEAREST cent (round-half-up). Chosen
// over floor: floor drops every sub-cent fraction and thus systematically
// undercharges, whereas round-to-nearest has expected undercharge 0 over many
// spends. Stateless (no shared remainder) so it is safe on the append-only,
// no-atomic-RMW commerce ledger. The exact micros are preserved in the
// transaction metadata for audit / future true-up.
func roundMicrosToCents(micros int64) int64 {
if micros <= 0 {
return 0
}
return (micros + 5000) / 10000
}
// RecordUsage records an API usage event as a Withdraw transaction.
//
// POST /v1/billing/usage
//
// Creates a withdraw transaction deducting the cost from the user's balance.
func RecordUsage(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req usageRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.User == "" {
return http.Fail(c, 400, "user is required", nil)
}
// THE QUANTITY IS NOT THE MONEY, and it is recorded before the money is, because
// everything below this line is about rounding a charge and any of it can drop
// one. See meterUsage.
go meterUsage(db, req)
// Lossless sub-cent handling. `amountMicros` (micro-USD, 1e6=$1) carries full
// precision over the wire; `amount` (cents) is the back-compat fallback.
effMicros := req.AmountMicros
if effMicros <= 0 {
effMicros = req.Amount * 10000 // 1 cent = 10_000 micro-USD
}
if effMicros <= 0 {
// Genuinely zero-cost — acknowledge, no transaction.
return c.JSON(200, map[string]any{"user": req.User, "amount": 0, "status": "skipped"})
}
// Round to NEAREST cent (round-half-up), NOT floor. Floor systematically
// undercharges every sub-cent spend; round-to-nearest has expected
// undercharge 0 across many spends. A per-subject remainder accumulator would
// be exact, but the commerce datastore has NO atomic RMW / CAS / transactions
// (RunInTransaction is a stub; balance is append-only, summed from the
// ledger), so a mutable remainder would be a racy money-state — unsafe on a
// live ledger. Round-to-nearest is the stateless, race-free equivalent; the
// EXACT micros are recorded in metadata below for audit / future true-up.
amountCents := roundMicrosToCents(effMicros)
if amountCents <= 0 {
// Sub-half-cent spend rounds to $0.00 (statistically fair, E[loss]=0).
// Acknowledge without debiting; echo the exact micros for audit.
return c.JSON(200, map[string]any{"user": req.User, "amount": 0, "amountMicros": effMicros, "status": "rounded-to-zero"})
}
// Idempotency guard (money-critical). A retry (client lost the response) or a
// double-submit MUST create AT MOST ONE withdraw. Key on the caller's
// X-Idempotency-Key when supplied, else the requestId the caller sends per
// spend. The datastore is already org-namespaced, so the key is per-tenant.
// No key => caller opted out of the guard (behavior unchanged).
var idemRec *idempotencykey.IdempotencyKey
idemKey := strings.TrimSpace(c.Header("X-Idempotency-Key"))
if idemKey == "" {
idemKey = strings.TrimSpace(req.RequestID)
}
if idemKey != "" {
rec, replay, gerr := idempotencykey.Begin(db, "billing-usage", idemKey)
if gerr != nil {
// Guard store unavailable — log and proceed WITHOUT the replay guard
// rather than drop a legitimate usage record (matches topup posture).
log.Error("usage idempotency Begin failed (org=%s key=%s): %v", org.Name, idemKey, gerr, c)
} else if replay {
if rec.Status == idempotencykey.StatusCompleted && rec.Response != "" {
// Retry of a completed debit — replay the stored body, no 2nd debit.
c.SetHeader("Content-Type", "application/json")
return c.Bytes(200, []byte(rec.Response))
}
// Concurrent in-flight debit for this key — never run a second one.
return http.Fail(c, 409, "usage recording already in progress", nil)
} else {
idemRec = rec
}
}
cur := currency.Type(strings.ToLower(req.Currency))
if cur == "" {
cur = "usd"
}
notes := fmt.Sprintf("API usage: %s (%d tokens)", req.Model, req.TotalTokens)
trans := transaction.New(db)
trans.Type = transaction.Withdraw
trans.SourceId = req.User
trans.SourceKind = "iam-user"
trans.Currency = cur
trans.Amount = currency.Cents(amountCents)
trans.Notes = notes
trans.Tags = "api-usage"
// Scope attribution (issue #70): the indexed dimensions the per-scope spend
// cap sums over. Normalized so the default project ("default") and "no
// project" collapse to the same "" scope the org-wide cap counts.
trans.Project = spendalert.NormalizeProject(req.Project)
trans.Service = strings.TrimSpace(req.Service)
trans.Metadata = Map{
"model": req.Model,
"provider": req.Provider,
"promptTokens": req.PromptTokens,
"completionTokens": req.CompletionTokens,
"totalTokens": req.TotalTokens,
// Exact debit in micro-USD, before cent rounding — the audit trail that
// makes any systematic rounding bias detectable and enables a future
// true-up. sum(amountMicros) is the exact spend; sum(amount)*10000 is
// what was charged; their delta is the (E=0) rounding drift.
"amountMicros": effMicros,
"requestId": req.RequestID,
"premium": req.Premium,
"stream": req.Stream,
"status": req.Status,
"clientIp": req.ClientIP,
}
if org.TestMode() {
trans.Test = true
}
// Create the transaction. For usage recording we do NOT enforce
// balance checks — the API call already happened and must be recorded.
// Balance gating happens at request time in Cloud-API.
if err := trans.Create(); err != nil {
log.Error("Failed to record usage transaction: %v", err, c)
return http.Fail(c, 500, "failed to record usage", err)
}
// Track referral revenue share: if this user was referred, create an
// affiliate Fee for the referrer's commission. Fire-and-forget — usage
// recording must not fail because of referral tracking.
go engine.TrackRevenueShare(db, req.User, currency.Cents(amountCents), cur, trans.Id(), org.TestMode())
// Accrue the OSS-developer payout: attribute up to 25% of this charge
// across the upstream OSS packages this org's deployment depends on (from
// the SBOMs emitted on deploy) into the per-package accrual ledger.
// Fire-and-forget, same posture as revenue share.
go engine.AccrueOSSPayout(db, org.Name, req.User, currency.Cents(amountCents), cur, trans.Id(), !org.Live)
// Emit the api-usage debit to the analytics collector (commerce.events) so the
// fleet usage view (admin.hanzo.ai) can aggregate metered spend — best-effort,
// fire-and-forget, never blocks the money path.
emitAPIUsageDebit(c, org.Name, &req, amountCents, effMicros)
// Fire any spend-alert this debit pushed over its soft-warn threshold or cap —
// the "alert" half of a spend-alert (the "cap" half is the metering gate). This
// is the ONLY write path where period spend accrues, so the crossing is detected
// here, once, off the money path (detached goroutine, like the debits above) and
// debounced per (period, level) so it re-arms only at the monthly window reset.
ev, _ := c.Locals("events").(*events.Client)
fireProject := spendalert.NormalizeProject(req.Project)
fireService := strings.TrimSpace(req.Service)
fireCtx := context.WithoutCancel(c.Context())
go checkAndFireSpendAlerts(fireCtx, db, org.Name, org.TestMode(), fireProject, fireService, ev)
resp := map[string]any{
"transactionId": trans.Id(),
"user": req.User,
"amount": amountCents,
"amountMicros": effMicros,
"currency": cur,
"type": "withdraw",
}
// Seal the idempotency guard with the exact success body so a retry replays
// it verbatim (no second withdraw, identical response).
if idemRec != nil {
if body, mErr := json.Marshal(resp); mErr == nil {
_ = idempotencykey.Complete(idemRec, string(body))
}
}
return c.JSON(201, resp)
}