Files
Zach Kelling 098f89edfc billing: retire the Circle Gateway top-up rail
The owner's call, 2026-10-30: USDC through Circle Gateway is Circle's
own SDK (@circle-fin/x402-batching) in Hanzo's x402 gateway in front of
cloud, a deposit into Gateway once and a payment per request. The Go
rail reimplemented that protocol, so it goes now rather than later:
billing/gateway, payment/providers/circle, models/remittance, the three
/v1/billing/crypto/gateway routes, the commerce treasury route, and the
gateway networks in /v1/billing/crypto/options. Crypto top-ups by
authorization pause until the gateway takes them; the deposit-address
rail is unchanged. Hashid kinds 292 and 293 stay taken.

Testing: go build ./... and vet clean; api/billing's crypto tests pass
(TestPlanAtInterval and TestSubscribeTrial_NoChargeCardVaultedTrialing
fail on main without this change too).
Risk: removes a live payment route; cloud must drop its plane ops in
the release that pins this.
How to revert: revert this commit.
2026-10-30 07:44:06 -07:00

287 lines
11 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
// Package commerce (pkg/commerce) is the embedded Commerce server. One
// backend, one HTTP handler. Mirrors pkg/tasks/embed.go shape:
//
// cfg := commerce.EmbedConfig{DataDir: "/var/lib/commerce", HTTPAddr: ":8090"}
// srv, err := commerce.Embed(ctx, cfg)
// defer srv.Stop(ctx)
//
// The legacy App in commerce.go is the bootstrap — server.go wires it
// into commerced/main.go cleanly. The /v1/commerce/* surface stays
// behind hanzoai/gateway and is gated by COMMERCED_REQUIRE_IDENTITY.
package commerce
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/hanzoai/commerce/secrets"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/hanzoai/commerce/billing/depositledger"
"github.com/hanzoai/commerce/mail"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/models/webhook/census"
)
// legacyWebhooks logs the census of commerce's legacy webhook rows: one line per
// row (store, org, host, a digest of the URL, the events it takes — never a
// credential) and one line with the totals. See models/webhook/census.
func legacyWebhooks(l Log) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
subs, err := census.Subscribers(ctx)
if err != nil {
l.Warn("commerce: legacy webhook census failed", "err", err.Error(), "read", len(subs))
return
}
stores := map[string]int{}
for _, s := range subs {
stores[s.Store]++
l.Warn("commerce: legacy webhook subscriber", "store", s.Store, "org", s.Org, "id", s.ID,
"name", s.Name, "host", s.Host, "url_hash", s.URLHash, "all", s.All,
"events", strings.Join(s.Events, ","), "enabled", s.Enabled, "live", s.Live)
}
l.Info("commerce: legacy webhook census", "rows", len(subs), "system", stores["system"], "org", stores["org"])
}
// EmbedConfig configures the in-process Commerce server. Empty values
// fall through to commerce.DefaultConfig (env-based) so commerced binds
// the same env contract the legacy commerce binary did.
// SecretReader is the host's secret plane, as commerce needs it: one read, by
// reference, returning the value or an error. Deliberately narrower than any
// KMS client — commerce never writes, never lists, and never learns the
// transport, so a host can satisfy this over UDS, in-process, or from a test
// map without commerce knowing which.
type SecretReader interface {
GetSecret(ctx context.Context, ref string) ([]byte, error)
}
// Log is the host's logger as commerce needs it: two levels and key/value
// pairs. It is an interface rather than *slog.Logger because the hosts differ —
// the standalone boot has an *slog.Logger, the cloud binary has a
// luxfi/log.Logger — and both already have these two methods, so neither side
// writes a shim and commerce learns nothing about either logger's package.
type Log interface {
Info(msg string, kv ...any)
Warn(msg string, kv ...any)
}
type EmbedConfig struct {
DataDir string // "" → COMMERCE_DIR or ./commerce_data
HTTPAddr string // "" → COMMERCE_HTTP or 127.0.0.1:8090
Dev bool // dev mode — reload-friendly logging
RequireIdentity bool // gateway trust: refuse requests without X-Org-Id/X-User-Id
Logger Log // nil → slog.Default()
AllowedOrigins []string // CORS — usually ["*"] behind gateway
// App is the NATIVE co-residence contract: when set, commerce registers
// its routes directly on this shared zip app (one router, one specificity
// space — no handler adaptation, no second engine) and skips the
// standalone-only surfaces (/healthz, the legacy /admin SPA, the checkout
// SPA root catch-all, Listen). nil → commerce builds its own app and
// serves standalone.
App *zip.App
// MasterKey is the 32-byte at-rest encryption master key. When set it is used
// verbatim and COMMERCE_KMS_MASTER_KEY is never read — so a host that already
// holds a KEK passes the one it has instead of a second key being provisioned
// for the same process. nil ⇒ the env var decides, unchanged, which is the
// standalone path.
MasterKey []byte
// Ledger is the host-injected double-entry credit ledger. When the cloud
// binary embeds commerce it passes a ledger-backed impl here, so
// POST /v1/billing/credit and GET /v1/billing/balance route to the SAME
// per-org account the AI spend-gate reads (one ledger, no split). nil →
// commerce falls back to its own datastore (standalone-safe).
Ledger creditledger.CreditLedger
// Secrets is commerce's one secret reader: the host's KMS handle (the cloud
// binary passes deps.KMS) or, for the standalone binary, hanzoai/kms/sdk/go.
// Commerce holds no KMS client of its own. An org's payment credentials are
// read through it at orgs/<org>/<provider>/<NAME> (payment.Hydrate). The same
// inversion Ledger uses: a plugin reads secrets through its host.
//
// nil reads nothing: every credential is then absent.
Secrets SecretReader
// Mail delivers commerce's outbound mail — the sale notice to the operator
// (BILLING_SALE_NOTIFY_TO). The cloud binary passes its notify rail, whose
// provider credentials are in KMS, so commerce holds no mail credential of
// its own. nil sends nothing (mail.ErrNoSender, logged by the caller).
Mail mail.Sender
// Born is the kind a new org's record is created in (organization.Born):
// Live where the host's network takes real money, Test where it does not.
// Only the host knows which world it runs in, so it declares it. Anything
// but Live, "" included, is Test.
Born organization.Kind
// Ceiling is the most any org transacts in (organization.Ceiling): Test
// where the host's network holds no real money, so even a record marked
// Live charges sandbox credentials and writes the test books. "" and Live
// are no cap; any other value caps at Test.
Ceiling organization.Kind
}
// ceilingOf is the cap a host's declaration puts on every org: none for "" or
// Live, Test for anything else, so a value nobody recognises caps rather than
// lifts.
func ceilingOf(k organization.Kind) organization.Kind {
if k == "" || k == organization.Live {
return organization.Live
}
return organization.Test
}
// Embedded is the handle to a running in-process Commerce server. The
// underlying *App owns the heavy lifting (DB, infra, KMS,
// hooks, cron) — Embedded wraps it for clean Stop/Zip access.
type Embedded struct {
cfg EmbedConfig
app *App
}
// Embed bootstraps the Commerce app and returns a handle. Call Stop
// before the process exits.
func Embed(ctx context.Context, cfg EmbedConfig) (*Embedded, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
appCfg := DefaultConfig()
if cfg.DataDir != "" {
appCfg.DataDir = cfg.DataDir
}
if cfg.HTTPAddr != "" {
appCfg.HTTPAddr = cfg.HTTPAddr
}
if cfg.Dev {
appCfg.Dev = true
}
if len(cfg.MasterKey) > 0 {
appCfg.MasterKey = cfg.MasterKey
}
if len(cfg.AllowedOrigins) > 0 {
appCfg.AllowedOrigins = cfg.AllowedOrigins
}
// Bool override mirrors the cfg.Dev pattern: an explicit true from the
// caller strengthens the env-derived default; an unset (false) cfg falls
// through to DefaultConfig's COMMERCED_REQUIRE_IDENTITY read.
if cfg.RequireIdentity {
appCfg.RequireIdentity = true
}
appCfg.SharedApp = cfg.App
// Install the host-injected credit ledger (process-wide) BEFORE routes are
// registered, so the billing credit + balance handlers resolve it. nil is a
// no-op: commerce keeps its datastore path (standalone-safe).
creditledger.Set(cfg.Ledger)
// The host's secret plane, installed before routes register — same shape as
// the ledger above, and for the same reason: a plugin asks its host.
if cfg.Secrets != nil {
secrets.Set(cfg.Secrets)
}
// The host's mail rail, installed like the two above. Each embed states its
// whole world, so nil clears whatever an earlier embed in the process set.
mail.Set(cfg.Mail)
// The kind new tenants are born in, declared before any route can create
// one. Each embed states its whole world, so one that declares nothing is
// Test, never whatever an earlier embed in the process left behind.
organization.Born = organization.Test
if cfg.Born == organization.Live {
organization.Born = organization.Live
}
// The cap every org transacts under, declared in the same act and for the
// same reason. Stated whole each embed: one that declares no cap clears a
// cap an earlier embed in the process set.
organization.Ceiling = ceilingOf(cfg.Ceiling)
app := NewWithConfig(appCfg)
// Run Bootstrap synchronously so the returned Embedded is fully
// ready: Router populated (the shared app in co-resident mode), DB
// connected, hooks fired. setupRoutes is called inside Bootstrap.
// Bootstrap installs the identity trust boundary (EdgeAuth + auth.Gin)
// BEFORE registering any route group and mounts the admin SPA after — so
// /_/commerce/*, /v1/commerce/* and the post-Bootstrap /v1 api.Route()
// bundle all inherit it. (Previously this binary mounted the boundary
// here, AFTER Bootstrap had already registered the setupRoutes groups, so
// gin left those groups unguarded — the in-cluster header-forge hole.)
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("commerce.Embed: bootstrap: %w", err)
}
// Start the crypto deposit schedule. This is the PRODUCTION entry point — no
// standalone commerce backend is deployed, so Embed is the only path by
// which the rail ever runs — and it is the right owner because this is the
// call that produces the handle whose Stop() stops it. Start and stop now
// sit on the same object; before, Bootstrap started it and only Shutdown
// stopped it, so a caller that took the handle never had the pair.
//
// A no-op when no CRYPTO_DEPOSIT_* asset is configured, which is why the
// tests that Embed a bare commerce spin up nothing.
depositWatcher := depositledger.Default()
depositWatcher.Start()
// Who commerce's legacy webhook rows deliver to, logged once per boot so the
// move to /v1/webhook names every subscriber before the rows are deleted.
// Detached and bounded: it reads every org's store, and a boot waits for none.
go legacyWebhooks(cfg.Logger)
cfg.Logger.Info("commerce.Embed ready",
"http", appCfg.HTTPAddr,
"data", appCfg.DataDir,
"dev", appCfg.Dev,
"require_identity", cfg.RequireIdentity,
"version", Version,
// The boot fact that used to go to stderr and could not be read back off
// a running pod. It is here because a structured log line is greppable,
// and — the part that matters — it is also served live at
// GET /v1/commerce/deposits, so the answer does not depend on catching it.
"deposit_watcher", depositWatcher.Running(),
"deposit_assets", len(depositWatcher.Assets()),
)
return &Embedded{cfg: cfg, app: app}, nil
}
// Zip returns the zip app commerce's routes live on — its own in
// standalone mode, the shared app in co-resident mode.
func (e *Embedded) Zip() *zip.App {
if e == nil || e.app == nil {
return nil
}
return e.app.Router
}
// App exposes the underlying App for tests and hook registration.
func (e *Embedded) App() *App {
if e == nil {
return nil
}
return e.app
}
// Stop shuts the server down. Idempotent.
func (e *Embedded) Stop(ctx context.Context) error {
if e == nil || e.app == nil {
return nil
}
if err := e.app.Shutdown(); err != nil {
return err
}
_ = ctx
return nil
}