Files
commerce/api/billing/balance.go
5d430a002c Only an ecosystem org's own account is Enterprise; a person inside it is tiered by their own plan
IsEcosystemAccount is the one predicate: the bare org slug of a paid ecosystem
org, within that org's books. That is the payer IAM names for the org's
machines, owners and admins, and for every member of a pooled org. A person's
wallet in the org ("hanzo/alice") was Enterprise by prefix, with every model,
a balance floored at $1,000, free ecosystem top-ups and paid subscriptions
without a card or platform credentials. It is now a customer's: its own
subscriptions, its own money.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 21:54:40 -07:00

146 lines
4.2 KiB
Go

package billing
import (
"context"
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/transaction/util"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/util/json/http"
)
// GetBalance returns the current balance for an IAM user.
//
// GET /v1/billing/balance?user=hanzo/alice&currency=usd
//
// All amounts in cents. available = balance - holds.
func GetBalance(c *zip.Ctx) error {
org, _ := middleware.GetOrganizationOK(c)
var ctx context.Context
if org != nil {
ctx = org.Namespaced(c.Context())
} else {
ctx = c.Context()
}
user := strings.ToLower(strings.TrimSpace(c.Query("user")))
if user == "" {
return http.Fail(c, 400, "user query parameter is required", nil)
}
cur := currency.Type(strings.ToLower(c.Query("currency")))
if cur == "" {
cur = "usd"
}
// Injected double-entry ledger (embedded-in-cloud path): the balance is
// authoritative from the SAME account POST /billing/credit writes and the AI gate
// reads — one ledger by construction.
//
// THE ORG AND THE SUBJECT ARE TWO VALUES, and passing one for both is what this
// fixes. `user` is a SUBJECT — the org's pool key, or the finer "<org>/<user>" of
// a member who spends from their own wallet, which is exactly what the datastore
// path below hands bucketedSplit and what this endpoint's own example documents. It
// was being passed as the LEDGER, so a person-scoped subject opened a namespace
// named after the person: a different ledger, holding nothing, reported as a real
// zero to a customer with money. The org is the request's own, the same one the
// datastore path reads, and the subject is the subject.
if org != nil {
if led := creditledger.Get(); led != nil {
avail, err := led.Balance(c.Context(), strings.ToLower(strings.TrimSpace(org.Name)), user, string(cur), org.TestMode())
if err != nil {
return http.Fail(c, 500, "failed to query balance", err)
}
return c.JSON(200, map[string]any{
"user": user,
"currency": cur,
"balance": avail,
"holds": int64(0),
"available": avail,
})
}
}
testMode := false
if org != nil {
testMode = org.TestMode()
}
split, err := bucketedSplit(ctx, user, cur, testMode)
if err != nil {
return http.Fail(c, 500, "failed to query balance", err)
}
isPaidEco := IsEcosystemAccount(org, user)
if isPaidEco {
if split.Available < currency.Cents(DefaultEcosystemCreditCents) {
split.CreditsRemaining = currency.Cents(DefaultEcosystemCreditCents)
split.CreditsGranted = currency.Cents(DefaultEcosystemCreditCents)
split.Available = currency.Cents(DefaultEcosystemCreditCents)
split.Balance = currency.Cents(DefaultEcosystemCreditCents)
}
}
card := getCardOnFile(datastore.New(ctx), user)
resp := map[string]any{
"user": user,
"currency": cur,
"balance": int64(split.Balance),
"holds": int64(split.Holds),
"available": int64(split.Available),
}
for k, v := range bucketFields(split, card) {
resp[k] = v
}
return c.JSON(200, resp)
}
// GetBalanceAll returns balances across all currencies for an IAM user.
//
// GET /v1/billing/balance/all?user=hanzo/alice
func GetBalanceAll(c *zip.Ctx) error {
org, _ := middleware.GetOrganizationOK(c)
var ctx context.Context
if org != nil {
ctx = org.Namespaced(c.Context())
} else {
ctx = c.Context()
}
user := strings.ToLower(strings.TrimSpace(c.Query("user")))
if user == "" {
return http.Fail(c, 400, "user query parameter is required", nil)
}
testMode := false
if org != nil {
testMode = org.TestMode()
}
datas, err := util.GetTransactions(ctx, user, "iam-user", testMode)
if err != nil {
return http.Fail(c, 500, "failed to query balance", err)
}
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 c.JSON(200, map[string]any{
"user": user,
"balances": balances,
})
}