Files
commerce/api/billing/husd_mint.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

149 lines
5.4 KiB
Go

package billing
import (
"context"
"crypto/rand"
"encoding/hex"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/bucket"
"github.com/hanzoai/commerce/billing/husdledger"
"github.com/hanzoai/commerce/mintauth"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/treasury"
"github.com/hanzoai/commerce/util/json/http"
)
// This file is the Step-4 rewire seam: the billing money-in handlers (deposit,
// welcome/starter grant, top-up) call chainMintCredit INSTEAD of writing a DB
// credit row WHEN the chain-backed ledger is enabled. A credit then exists only
// as HUSD minted by the treasury key (which commerce does not hold) and projected
// back into the ledger the balance endpoint reads. When the chain path is not
// configured, chainCreditEnabled() is false and each handler keeps its existing
// DB write — so a deploy without HUSD configured is byte-for-byte unchanged, and
// the migration (Step 6) flips it deliberately.
// chainCreditEnabled reports whether the on-chain HUSD credit path is live.
func chainCreditEnabled() bool { return husdledger.Default().Enabled() }
// bucketForTags maps a deposit's ledger tags to the on-chain mint bucket, using
// the SAME classifier the balance split uses (so the mint bucket and the read
// bucket can never disagree): a grant tag → Credit, anything else → Prepaid.
func bucketForTags(tags string) treasury.Bucket {
if bucket.DepositKind(tags) == bucket.Credit {
return treasury.BucketCredit
}
return treasury.BucketPrepaid
}
// chainMintCredit performs the authorized on-chain HUSD mint for a credit and
// returns the receipt. The caller MUST already have established mint authority
// (a PlatformOnly route, a settled payment, or a server-fixed grant) — exactly
// as before a DB deposit — so wrapping the ctx authorized here is faithful: it is
// the SAME capability that satisfies the ledger sink on the DB path.
func chainMintCredit(ctx context.Context, org *organization.Organization, subject string, cents int64, b treasury.Bucket, reason, idemKey string) (*treasury.Receipt, error) {
authorized := mintauth.WithAuthorized(org.Namespaced(ctx))
return husdledger.Default().MintCredit(authorized, treasury.MintRequest{
OrgID: org.Namespace(),
Subject: subject,
AmountCents: cents,
Bucket: b,
Reason: reason,
IdemKey: idemKey,
Test: org.TestMode(),
})
}
// randomIdemKey returns a fresh idempotency key for a mint that carries no
// caller-supplied key, so distinct deposits (e.g. repeated settlements to the
// same user) each mint exactly once and never collapse onto one on-chain tx.
func randomIdemKey(prefix string) string {
var b [16]byte
_, _ = rand.Read(b[:])
return prefix + hex.EncodeToString(b[:])
}
// SyncHUSD runs one indexer pass — the backfill + reconcile safety net behind the
// synchronous mint-time projection. Driven by an external CronJob (mirrors the
// contributor-payout execute endpoint). Platform-only (touches the money ledger).
//
// POST /v1/billing/husd/sync
func SyncHUSD(c *zip.Ctx) error {
svc := husdledger.Default()
if !svc.Enabled() {
return http.Fail(c, 503, "chain-backed credit ledger not enabled", nil)
}
n, err := svc.SyncOnce(c.Context())
if err != nil {
return http.Fail(c, 500, "husd indexer sync failed", err)
}
return c.JSON(200, map[string]any{"projected": n})
}
// SettleHUSD sweeps every org's on-chain drift back to the treasury (metered
// usage settled org→treasury). Platform-only, CronJob-driven. Idempotent
// (self-correcting drift). Returns the per-org settlement outcomes.
//
// POST /v1/billing/husd/settle
func SettleHUSD(c *zip.Ctx) error {
svc := husdledger.Default()
if !svc.Enabled() {
return http.Fail(c, 503, "chain-backed credit ledger not enabled", nil)
}
results, err := svc.Settle(c.Context())
if err != nil {
return http.Fail(c, 500, "husd settlement failed", err)
}
settled := 0
for _, r := range results {
if r.Settled {
settled++
}
}
return c.JSON(200, map[string]any{"orgs": len(results), "settled": settled, "results": results})
}
// MigrateHUSD moves every org's existing DB-ledger balance onto chain with zero
// drift (one-time). Dry-run by default (reports the snapshot + reconcile without
// minting); pass ?execute=true to mint. Platform-only.
//
// POST /v1/billing/husd/migrate?execute=true
func MigrateHUSD(c *zip.Ctx) error {
svc := husdledger.Default()
if !svc.Enabled() {
return http.Fail(c, 503, "chain-backed credit ledger not enabled", nil)
}
dryRun := c.Query("execute") != "true"
results, err := svc.Migrate(c.Context(), dryRun)
if err != nil {
return http.Fail(c, 500, "husd migration failed", err)
}
migrated, reconciled := 0, 0
for _, r := range results {
if !r.DryRun && r.MintedCents > 0 {
migrated++
}
if r.Reconciled {
reconciled++
}
}
return c.JSON(200, map[string]any{"orgs": len(results), "dryRun": dryRun, "migrated": migrated, "reconciled": reconciled, "results": results})
}
// StatusHUSD reports the chain-ledger configuration + whether it is enabled — a
// read-only observability surface (no secrets: the treasury key is never exposed).
//
// GET /v1/billing/husd/status
func StatusHUSD(c *zip.Ctx) error {
svc := husdledger.Default()
cfg := svc.Config()
return c.JSON(200, map[string]any{
"enabled": svc.Enabled(),
"chainId": cfg.ChainID,
"token": cfg.TokenAddress,
"decimals": cfg.Decimals,
"rpc": cfg.RPCURL,
})
}