Files
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

119 lines
5.4 KiB
Go

package billing
import (
"os"
"strconv"
"strings"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/middleware"
jsonhttp "github.com/hanzoai/commerce/util/json/http"
)
// topupTokenRequest is the wire body. There is deliberately NO userId field: the
// credited subject is resolved from the caller's own signed identity
// (userBillingKey → account.Payer), never from the request, so a body value can
// not steer where the money lands. This endpoint takes a FRESH nonce only; a card the
// subject already saved goes through POST /v1/billing/topup (paymentMethodId), the
// one saved-card endpoint.
type topupTokenRequest struct {
SourceID string `json:"sourceId"` // Square Web Payments SDK nonce
AmountCents int64 `json:"amountCents"`
Currency string `json:"currency,omitempty"`
}
// The default top-up floor and ceiling, in cents. They are named constants and
// not literals because the test asserts them twice — once as the boundary a
// caller may charge, once as the value a bad override falls back to — and those
// two spellings drifted: the floor moved to $5 while the fallback assertion
// still read $1, so the suite contradicted itself and failed. One name, one
// value, and no way for the two readings to disagree again.
const (
defaultTopupMinCents int64 = 500 // $5 — the pay-as-you-go floor, owner directive 2026-08
defaultTopupMaxCents int64 = 500000 // $5,000
)
// topupBounds returns the inclusive [min,max] card top-up amount in cents. A
// server-side bound is the authoritative enforcement of the console's own
// MIN_TOPUP_CENTS/MAX_TOPUP_CENTS policy (a client cap is advisory — a scripted
// request bypasses it). Defaults: min 500 ($5) — the pay-as-you-go floor, owner
// directive 2026-08 — max 500000 ($5,000). Override per-deploy for risk tuning
// with COMMERCE_TOPUP_MIN_CENTS / COMMERCE_TOPUP_MAX_CENTS.
func topupBounds() (int64, int64) {
return envCents("COMMERCE_TOPUP_MIN_CENTS", defaultTopupMinCents), envCents("COMMERCE_TOPUP_MAX_CENTS", defaultTopupMaxCents)
}
// envCents reads a positive-int-cents override from the environment, falling
// back to def when unset, non-numeric, or non-positive (fail-safe to the default
// bound rather than an unbounded/zero limit).
func envCents(key string, def int64) int64 {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return n
}
}
return def
}
// TopupWithToken charges a Square Web Payments SDK nonce and credits the org's
// canonical balance. Use this for one-time card top-ups without saving a payment
// method first — the cold-customer "add credits" path.
//
// POST /v1/billing/topup/token
//
// Body: { sourceId, amountCents, currency? }
// Header (optional): X-Idempotency-Key — a retry/double-submit with the same key
// (or, absent a key, the same (subject, amount) inside a 15-minute window) never
// double-charges or double-credits; it replays the first result. The same key is
// forwarded to Square, so the charge is exactly-once at the processor even if the
// local guard store is down.
// Returns: { transactionId, balanceCents, status }
//
// This is the BROWSER projection of TakePayment (payment_core.go), which holds the
// whole money move. Everything below the Bind is reading values off the request —
// the subject from the caller's signed identity, the retry key from the header —
// and handing them to the one core. The
// typed op an agent calls is the other projection of the same core, so the
// bounds, the idempotency derivation and the ledger write are shared rather than
// reimplemented.
//
// There is deliberately NO core beside TakePayment for this endpoint to own. A second
// one would be a second set of bounds to drift, a second idempotency derivation
// to disagree, and eventually a card charged twice for one top-up — which is the
// exact reason the money move was lifted out of this handler in the first place.
func TopupWithToken(c *zip.Ctx) error {
// The OK form: IAMTokenRequired falls through WITHOUT setting the
// "organization" local when the gateway named no principal, and the MustGet
// form panics there — a 500 with no body, after the money has moved. Refuse
// before touching anything. See SubscribeWithCard, where this cost a $99
// charge with no subscription behind it.
org, ok := middleware.GetOrganizationOK(c)
if !ok || org == nil {
return jsonhttp.Fail(c, 401, "sign in to add funds", nil)
}
var req topupTokenRequest
if err := c.Bind(&req); err != nil {
return jsonhttp.Fail(c, 400, "invalid request body", err)
}
// Credit the SAME account the LLM gate reads out of and debits — the payer
// resolved from the caller's signed identity (userBillingKey → account.Payer),
// the ONE rule shared with GetMyBalance and the ai gate. Never ?user= and never
// a request-supplied userId: a client- or proxy-set selector is what let the
// same customer top up `hanzo` on one charge and `hanzo/<user>` on the next,
// stranding the credit off the key their usage draws from. One identity, one key.
out, f := TakePayment(c.Context(), org, TakePaymentIn{
SourceID: req.SourceID,
AmountCents: req.AmountCents,
Currency: req.Currency,
Subject: userBillingKey(c),
IdempotencyKey: strings.TrimSpace(c.Header("X-Idempotency-Key")),
})
if f != nil {
return jsonhttp.Fail(c, f.Status, f.Message, f.Err)
}
return c.JSON(200, out)
}