Files
commerce/api/billing/wire.go
zeekay 73b7290e7f square: one webhook route and one verifier; secrets come from the host's KMS
Square keeps an event's created_at across every retry, and the receiver
refused anything older than five minutes, so every retry of an event
first sent while cloud was down (Recreate, ten rollouts a day) was refused
until Square gave up. The verifier now lives in payment/providers/square
and needs only the key and the URL; thirdparty/square is the API client
and no longer registers itself or verifies anything, so every registry
holds one Square type.

Replay is stopped by the event id instead: each verified event is
recorded once for the whole deployment, keyed on the verifying processor
and the event id with an insert-if-absent swap, so a repeat is a
duplicate under any path or org. The path names the one processor that
verifies, and only one that holds its webhook secret may: an unknown name,
or a verifier that needs no secret, is refused rather than tried.
The books an event lands in are the verifying account's, else the
deployment's brand — never a header or session, which on a replay are the
replayer's. An effect that did not land (a store or the ledger did not
answer) leaves the delivery open with a 503, so the provider redelivers;
it used to be acknowledged and never retried. A settlement reads the
payment index before the org's books, because an in-session charge's
receipt is in the payer's org.

POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so
standalone and embedded commerce serve the same address; the
/v1/billing/webhooks spelling is gone.

thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer
serves, and was off in production anyway, so per-org payment credentials
never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through
the host's reader, the caller re-pointed at that org with zip.ActingAs so
the KMS tenancy check admits it, and payment.Hydrate loads an org's
credentials where the org receives the money: storefront checkout,
storefront subscriptions and wire, and the brand's bank for the billing
wire rail. Hanzo's own billing no longer reads the paying org's
credentials; it charges the deployment's account, as it did. The
standalone binary reads through hanzoai/kms/sdk/go.
2026-10-19 09:12:56 -07:00

152 lines
6.1 KiB
Go

package billing
import (
"context"
"errors"
"fmt"
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/checkout"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/payment"
jsonhttp "github.com/hanzoai/commerce/util/json/http"
)
// WireInstructions is the shape the pay SPA's WireInstructionsCard renders
// (pay/src/lib/api.ts WireInstructions). Field names are that contract.
type WireInstructions struct {
BankName string `json:"bankName"`
BankAddress string `json:"bankAddress,omitempty"`
AccountNumber string `json:"accountNumber"`
RoutingNumber string `json:"routingNumber,omitempty"`
SwiftCode string `json:"swiftCode,omitempty"`
IBAN string `json:"iban,omitempty"`
AccountName string `json:"accountName"`
Memo string `json:"memo,omitempty"`
Reference string `json:"reference"`
}
// errNoWire is the rail's ONE refusal, and every failure on the way to a set of
// bank details wraps it: to the customer they are a single fact — there is
// nowhere to send the money. A half-empty form is not an alternative, since
// nobody can wire to three fields out of five.
var errNoWire = errors.New("wire: no receiving account for this brand")
// IsWireUnconfigured reports that refusal. It is the only error class the wire
// rail has, so a caller outside this module answers the same 503 the endpoint does
// without knowing anything about how the details are stored.
func IsWireUnconfigured(err error) bool { return errors.Is(err, errNoWire) }
// WireFor is the whole wire read for a customer paying on a given host: which
// brand's bank they are sending to, that brand's org row, its secrets, and the
// reference that names them when the money lands.
//
// The host→brand reduction lives HERE rather than at the caller because the
// brand table is this module's; a caller doing it would keep a second copy of
// which hostnames are whose, and the two would drift the first time a domain is
// added. Everything a request holds that this needs — the host string and the
// caller's billing key — arrives as a value, so a caller with no request can
// still ask.
func WireFor(ctx context.Context, host, payer string) (WireInstructions, error) {
// The receiving side is a property of the HOST the customer is paying on,
// resolved by the same brand table the tenant endpoint uses.
brand := checkout.BrandSlugForHost(host)
recv := organization.New(datastore.New(ctx))
// Get reports a miss as (false, nil) — check found, not just err.
found, err := recv.Query().Filter("Name=", brand).Get()
if err != nil {
return WireInstructions{}, fmt.Errorf("%w: reading the org serving %q: %s", errNoWire, brand, err)
}
if !found {
return WireInstructions{}, fmt.Errorf("%w: no org serves %q", errNoWire, brand)
}
// The brand is the org the money is sent to, so its own bank details are read:
// orgs/<brand>/wire/<NAME>, through the host's secret plane.
payment.Hydrate(ctx, recv, "wire")
return GetWireInstructions(recv, payer)
}
// GetWireInstructions assembles the RECEIVING bank details for a wire top-up —
// the serving brand's own account (hanzo for pay.hanzo.ai, lux for
// pay.lux.network), never the caller's. The caller's identity goes into the
// payment reference so an arriving wire names who it credits; ops settle it with
// the admin wire/credit verb once the bank confirms receipt. No balance is ever
// minted here.
//
// It takes the RESOLVED, hydrated receiving org rather than looking one up.
//
// The payer must be named. The reference is the only thing that links arriving
// money to an account, so an anonymous one would be a destination that credits
// nobody — the exact failure a wire reference exists to prevent.
func GetWireInstructions(recv *organization.Organization, payer string) (WireInstructions, error) {
if payer == "" {
return WireInstructions{}, fmt.Errorf("%w: no payer, so an arriving wire would name nobody", errNoWire)
}
if recv == nil {
return WireInstructions{}, errNoWire
}
w := recv.Wire
if w.AccountNumber == "" && w.IBAN == "" {
return WireInstructions{}, errNoWire
}
return WireInstructions{
BankName: w.BankName,
BankAddress: w.BankAddress,
AccountNumber: w.AccountNumber,
RoutingNumber: w.RoutingNumber,
SwiftCode: w.SWIFT,
IBAN: w.IBAN,
AccountName: w.AccountHolder,
// BOTH carry the payer reference, and that is the point: banks label the
// field differently (memo / reference / "message to beneficiary"), and a
// customer types the value into whichever box their bank shows. Memo used
// to carry the ORG's static WIRE_REFERENCE, which is unset — so the field
// that links the money to an account rendered EMPTY, and an arriving wire
// would have to be reconciled by hand from the amount and the sender name.
Memo: wireReference(payer),
Reference: wireReference(payer),
}, nil
}
// GetBillingWireInstructions is the customer's endpoint for those details. It
// resolves the two things only a request knows — who is asking, and which host
// they are paying on — and hands them to the read.
//
// GET /v1/billing/wire
func GetBillingWireInstructions(c *zip.Ctx) error {
payer := userBillingKey(c)
if payer == "" {
return jsonhttp.Fail(c, 401, "Authentication required", nil)
}
// Forward carries the request's caller, which the host's KMS reads to admit
// the brand's bank read; a raw handler's Context carries none.
out, err := WireFor(c.Forward(), checkout.RequestHost(c), payer)
if err != nil {
return jsonhttp.Fail(c, 503, "Wire transfer not configured", err)
}
return c.JSON(200, out)
}
// wireReference renders the payer's billing key as a bank-memo-safe payment
// reference ("hanzo/z" → "TOPUP-HANZO-Z"). Wire memo fields routinely strip
// punctuation, so only [A-Z0-9-] survives the trip and the reference must be
// recoverable from that alphabet alone.
func wireReference(payer string) string {
var b strings.Builder
b.WriteString("TOPUP-")
for _, r := range strings.ToUpper(payer) {
switch {
case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
return b.String()
}