mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
119 lines
5.4 KiB
Go
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)
|
|
}
|