mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
387 lines
11 KiB
Go
387 lines
11 KiB
Go
package billing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/middleware"
|
|
"github.com/hanzoai/commerce/models/transaction"
|
|
"github.com/hanzoai/commerce/models/transaction/util"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
httperr "github.com/hanzoai/commerce/util/json/http"
|
|
|
|
. "github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
// zapMintMethods are the ZAP-over-HTTP methods that MINT money / spendable
|
|
// balance and therefore carry the SAME platform-only bar as the REST mint routes
|
|
// (middleware.PlatformOnly): the internal service token OR a platform global
|
|
// admin ONLY. billing.deposit is the ZAP twin of POST /v1/billing/deposit —
|
|
// DestinationKind="iam-user", client-supplied amount — so an org-level Admin
|
|
// (a legacy per-org access token or a gateway-minted org owner) must NOT reach
|
|
// it, exactly as it cannot reach /deposit. Reads (getBalance*, getUsage) and
|
|
// recordUsage (a WITHDRAW/debit, not a mint) stay open to the admin group. Kept
|
|
// as a SET so the mint-surface guard (mint_surface_test.go) can assert every ZAP
|
|
// method that mints is gated here — the anti-regression for a new ZAP mint verb.
|
|
var zapMintMethods = map[string]bool{
|
|
"billing.deposit": true,
|
|
}
|
|
|
|
// ── ZAP-over-HTTP dispatch ──────────────────────────────────────────────
|
|
//
|
|
// Single endpoint that speaks the ZAP JSON envelope protocol.
|
|
//
|
|
// POST /v1/zap
|
|
//
|
|
// Request: {"method":"billing.getBalance","id":"req-1","params":{...}}
|
|
// Response: {"id":"req-1","result":{...}} or {"id":"req-1","error":{...}}
|
|
|
|
type zapRequest struct {
|
|
Method string `json:"method"`
|
|
ID string `json:"id"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type zapResponse struct {
|
|
ID string `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *zapError `json:"error,omitempty"`
|
|
}
|
|
|
|
type zapError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ZapDispatch is the single ZAP-over-HTTP endpoint for billing.
|
|
func ZapDispatch(c *zip.Ctx) error {
|
|
body := c.Body()
|
|
|
|
var req zapRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return c.JSON(400, zapResponse{
|
|
Error: &zapError{Code: -32700, Message: "parse error: " + err.Error()},
|
|
})
|
|
}
|
|
|
|
if req.Method == "" {
|
|
return c.JSON(400, zapResponse{
|
|
ID: req.ID,
|
|
Error: &zapError{Code: -32600, Message: "method is required"},
|
|
})
|
|
}
|
|
|
|
// Money-MINT methods over ZAP are gated to the internal service token / a
|
|
// platform global admin ONLY — the SAME principal PlatformOnly enforces on the
|
|
// REST mint routes. This is the ZAP counterpart of that gate: without it, /zap
|
|
// billing.deposit let ANY org owner (Admin bit) mint unlimited iam-user balance
|
|
// while /deposit correctly 403'd them (C1). It is a real HTTP 403 (an auth
|
|
// boundary), not a 200 JSON-RPC error envelope — mirroring middleware.PlatformOnly.
|
|
// Reads + recordUsage (a debit) are deliberately NOT gated.
|
|
if zapMintMethods[req.Method] {
|
|
if !middleware.MayMintMoney(c) {
|
|
return httperr.Fail(c, 403,
|
|
"This operation requires platform-administrator or internal-service credentials.",
|
|
errors.New("ZAP money-mint method: caller is not a platform admin"))
|
|
}
|
|
// Proven mint principal → authorize the ledger sink so zapDeposit's write
|
|
// passes mintauth.Enforce (its datastore is built after this via
|
|
// org.Namespaced, which reads the authorized context).
|
|
middleware.AuthorizeMint(c)
|
|
}
|
|
|
|
var result interface{}
|
|
var zapErr *zapError
|
|
|
|
switch req.Method {
|
|
case "billing.getBalance":
|
|
result, zapErr = zapGetBalance(c, req.Params)
|
|
case "billing.getBalanceAll":
|
|
result, zapErr = zapGetBalanceAll(c, req.Params)
|
|
case "billing.getUsage":
|
|
result, zapErr = zapGetUsage(c, req.Params)
|
|
case "billing.recordUsage":
|
|
result, zapErr = zapRecordUsage(c, req.Params)
|
|
case "billing.deposit":
|
|
result, zapErr = zapDeposit(c, req.Params)
|
|
default:
|
|
zapErr = &zapError{Code: -32601, Message: "method not found: " + req.Method}
|
|
}
|
|
|
|
if zapErr != nil {
|
|
return c.JSON(200, zapResponse{ID: req.ID, Error: zapErr})
|
|
}
|
|
|
|
return c.JSON(200, zapResponse{ID: req.ID, Result: result})
|
|
}
|
|
|
|
// ── ZAP method handlers ─────────────────────────────────────────────────
|
|
|
|
type zapBalanceParams struct {
|
|
User string `json:"user"`
|
|
Currency string `json:"currency"`
|
|
}
|
|
|
|
func zapGetBalance(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
|
|
var p zapBalanceParams
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
|
|
}
|
|
if p.User == "" {
|
|
return nil, &zapError{Code: -32602, Message: "user is required"}
|
|
}
|
|
|
|
org := middleware.GetOrganization(c)
|
|
ctx := org.Namespaced(c.Context())
|
|
|
|
cur := currency.Type(strings.ToLower(p.Currency))
|
|
if cur == "" {
|
|
cur = "usd"
|
|
}
|
|
|
|
datas, err := util.GetTransactionsByCurrency(ctx, p.User, "iam-user", cur, org.TestMode())
|
|
if err != nil {
|
|
return nil, &zapError{Code: -32000, Message: "balance query failed: " + err.Error()}
|
|
}
|
|
|
|
var balance, holds currency.Cents
|
|
if data, ok := datas.Data[cur]; ok {
|
|
balance = data.Balance
|
|
holds = data.Holds
|
|
}
|
|
|
|
available := balance - holds
|
|
if available < 0 {
|
|
available = 0
|
|
}
|
|
|
|
return map[string]any{
|
|
"user": p.User,
|
|
"currency": cur,
|
|
"balance": balance,
|
|
"holds": holds,
|
|
"available": available,
|
|
}, nil
|
|
}
|
|
|
|
func zapGetBalanceAll(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
|
|
var p struct {
|
|
User string `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
|
|
}
|
|
if p.User == "" {
|
|
return nil, &zapError{Code: -32602, Message: "user is required"}
|
|
}
|
|
|
|
org := middleware.GetOrganization(c)
|
|
ctx := org.Namespaced(c.Context())
|
|
|
|
datas, err := util.GetTransactions(ctx, p.User, "iam-user", org.TestMode())
|
|
if err != nil {
|
|
return nil, &zapError{Code: -32000, Message: "balance query failed: " + err.Error()}
|
|
}
|
|
|
|
balances := make([]map[string]any, 0, len(datas.Data))
|
|
for cur, data := range datas.Data {
|
|
available := data.Balance - data.Holds
|
|
if available < 0 {
|
|
available = 0
|
|
}
|
|
balances = append(balances, map[string]any{
|
|
"currency": cur,
|
|
"balance": data.Balance,
|
|
"holds": data.Holds,
|
|
"available": available,
|
|
})
|
|
}
|
|
|
|
return map[string]any{
|
|
"user": p.User,
|
|
"balances": balances,
|
|
}, nil
|
|
}
|
|
|
|
func zapGetUsage(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
|
|
var p zapBalanceParams
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
|
|
}
|
|
if p.User == "" {
|
|
return nil, &zapError{Code: -32602, Message: "user is required"}
|
|
}
|
|
|
|
org := middleware.GetOrganization(c)
|
|
ctx := org.Namespaced(c.Context())
|
|
db := datastore.New(ctx)
|
|
|
|
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=", p.User).
|
|
Filter("Tags=", "api-usage")
|
|
|
|
cur := currency.Type(strings.ToLower(p.Currency))
|
|
if cur != "" {
|
|
q = q.Filter("Currency=", cur)
|
|
}
|
|
|
|
if _, err := q.GetAll(&transs); err != nil {
|
|
return nil, &zapError{Code: -32000, Message: "usage query failed: " + err.Error()}
|
|
}
|
|
|
|
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 map[string]any{
|
|
"user": p.User,
|
|
"count": len(items),
|
|
"usage": items,
|
|
}, nil
|
|
}
|
|
|
|
func zapRecordUsage(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
|
|
var req usageRequest
|
|
if err := json.Unmarshal(params, &req); err != nil {
|
|
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
|
|
}
|
|
|
|
if req.User == "" {
|
|
return nil, &zapError{Code: -32602, Message: "user is required"}
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
return map[string]any{"user": req.User, "amount": 0, "status": "skipped"}, nil
|
|
}
|
|
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
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(req.Amount)
|
|
trans.Notes = notes
|
|
trans.Tags = "api-usage"
|
|
trans.Metadata = Map{
|
|
"model": req.Model,
|
|
"provider": req.Provider,
|
|
"promptTokens": req.PromptTokens,
|
|
"completionTokens": req.CompletionTokens,
|
|
"totalTokens": req.TotalTokens,
|
|
"requestId": req.RequestID,
|
|
"premium": req.Premium,
|
|
"stream": req.Stream,
|
|
"status": req.Status,
|
|
"clientIp": req.ClientIP,
|
|
}
|
|
|
|
if org.TestMode() {
|
|
trans.Test = true
|
|
}
|
|
|
|
if err := trans.Create(); err != nil {
|
|
log.Error("ZAP: Failed to record usage: %v", err, c)
|
|
return nil, &zapError{Code: -32000, Message: "failed to record usage: " + err.Error()}
|
|
}
|
|
|
|
return map[string]any{
|
|
"transactionId": trans.Id(),
|
|
"user": req.User,
|
|
"amount": req.Amount,
|
|
"currency": cur,
|
|
"type": "withdraw",
|
|
}, nil
|
|
}
|
|
|
|
func zapDeposit(c *zip.Ctx, params json.RawMessage) (interface{}, *zapError) {
|
|
var req depositRequest
|
|
if err := json.Unmarshal(params, &req); err != nil {
|
|
return nil, &zapError{Code: -32602, Message: "invalid params: " + err.Error()}
|
|
}
|
|
|
|
if req.User == "" {
|
|
return nil, &zapError{Code: -32602, Message: "user is required"}
|
|
}
|
|
|
|
if req.Amount <= 0 {
|
|
return nil, &zapError{Code: -32602, Message: "amount must be positive"}
|
|
}
|
|
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
cur := currency.Type(strings.ToLower(req.Currency))
|
|
if cur == "" {
|
|
cur = "usd"
|
|
}
|
|
|
|
notes := req.Notes
|
|
if notes == "" {
|
|
notes = fmt.Sprintf("Deposit: %d cents %s", req.Amount, cur)
|
|
}
|
|
|
|
trans := transaction.New(db)
|
|
trans.Type = transaction.Deposit
|
|
trans.DestinationId = req.User
|
|
trans.DestinationKind = "iam-user"
|
|
trans.Currency = cur
|
|
trans.Amount = currency.Cents(req.Amount)
|
|
trans.Notes = notes
|
|
trans.Tags = req.Tags
|
|
|
|
trans.ExpiresAt = time.Now().AddDate(0, 0, expiryDays(req.ExpiresIn))
|
|
|
|
if org.TestMode() {
|
|
trans.Test = true
|
|
}
|
|
|
|
if err := trans.Create(); err != nil {
|
|
log.Error("ZAP: Failed to create deposit: %v", err, c)
|
|
return nil, &zapError{Code: -32000, Message: "failed to create deposit: " + err.Error()}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"transactionId": trans.Id(),
|
|
"user": req.User,
|
|
"amount": req.Amount,
|
|
"currency": cur,
|
|
"type": "deposit",
|
|
"tags": req.Tags,
|
|
}
|
|
if !trans.ExpiresAt.IsZero() {
|
|
resp["expiresAt"] = trans.ExpiresAt
|
|
}
|
|
|
|
return resp, nil
|
|
}
|