Files
zeekay 380b6c21c9 gateway: decide the hold afresh on every swap, and answer one status code
A reserve that lost its swap kept the answer from the read it lost, so
eight payments accepted at once could reserve past the cap; the race
detector found it. Submit answers 200 with the payment's status, as
cloud's typed route does, and the options menu test takes the gateway
field.
2026-10-26 20:42:25 -07:00

121 lines
4.5 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package billing
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/gateway"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/middleware/iammiddleware"
"github.com/hanzoai/commerce/models/organization"
)
// The Circle Gateway top-up rail over HTTP. The rail itself — terms, checks,
// settlement, credit — is billing/gateway, and these handlers only resolve the
// caller and translate; cloud reaches the same functions over its internal
// plane, so the two transports answer one payment one way.
//
// Who is credited is the caller (payer, the shared account rule); nothing in a
// body names it.
// gatewayFail answers a rail refusal with its own status, sentence and
// reason. An error that is not a refusal is an internal failure: its text is
// logged and never sent.
func gatewayFail(c *zip.Ctx, err error) error {
status, reason, msg := gateway.StatusOf(err), gateway.ReasonOf(err), err.Error()
switch {
case reason == "":
slog.Error("gateway: request failed", "method", c.Method(), "path", c.Path(), "err", err)
msg = "the payment could not be recorded; present the same payment again"
case errors.Unwrap(err) != nil:
slog.Warn("gateway: refused", "method", c.Method(), "path", c.Path(), "status", status, "reason", reason, "cause", errors.Unwrap(err))
}
return c.JSON(status, map[string]string{"error": msg, "reason": reason})
}
// caller is who a request credits: the org it acts in and the billing subject
// it pays from, both from the verified identity. Either is empty when the
// request carries no identity, and the rail refuses that.
func caller(c *zip.Ctx) (*organization.Organization, string) {
org, _ := middleware.GetOrganizationOK(c)
return org, userBillingKey(c)
}
// GetBillingGatewayTerms answers the payment terms for a USDC top-up: the
// treasury to pay, the networks and USDC contracts, the EIP-712 domain and
// types to sign, the limits, and a nonce issued to this caller with its salt.
//
// GET /v1/billing/crypto/gateway
func GetBillingGatewayTerms(c *zip.Ctx) error {
org, payer := caller(c)
t, err := gateway.Default().Terms(c.Context(), org, payer)
if err != nil {
return gatewayFail(c, err)
}
return c.JSON(http.StatusOK, t)
}
// SubmitBillingGateway takes a signed USDC payment and answers what became of
// it: 200 with its status — accepted (credited, or deferred until Gateway
// confirms it), or pending (the outcome is not yet known; read it back) — or a
// refusal with its reason. Cloud's typed route answers the same 200, so the
// two transports answer one payment one way.
//
// POST /v1/billing/crypto/gateway { network, salt, authorization, signature }
func SubmitBillingGateway(c *zip.Ctx) error {
var sub gateway.Submission
dec := json.NewDecoder(bytes.NewReader(c.Body()))
dec.DisallowUnknownFields()
if err := dec.Decode(&sub); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body: " + err.Error(), "reason": gateway.ReasonPayload})
}
org, payer := caller(c)
v, err := gateway.Default().Submit(c.Context(), org, payer, sub)
if err != nil {
return gatewayFail(c, err)
}
return c.JSON(http.StatusOK, v)
}
// GetBillingGateway reads one of the caller's USDC top-ups back.
//
// GET /v1/billing/crypto/gateway/:id
func GetBillingGateway(c *zip.Ctx) error {
org, payer := caller(c)
v, err := gateway.Default().Get(c.Context(), org, payer, c.Param("id"))
if err != nil {
return gatewayFail(c, err)
}
return c.JSON(http.StatusOK, v)
}
// GatewayTreasury reports Hanzo's Gateway position — balances per domain,
// arriving deposits, and credit outstanding on payments not yet settled.
//
// GET /v1/commerce/treasury
//
// SUPERADMIN ONLY, decided here rather than by where it is mounted, for the
// reason DepositWatcherStatus gives: it is the deployment's money, not a
// tenant's, and no org admin has a view of it to be given. Read-only.
func GatewayTreasury(c *zip.Ctx) error {
if !iammiddleware.IsIAMAuthenticated(c) {
return c.JSON(http.StatusUnauthorized, map[string]any{"error": "authentication required"})
}
if !iammiddleware.GetIAMClaims(c).IsSuperAdmin() {
return c.JSON(http.StatusForbidden, map[string]any{"error": "superadmin role required"})
}
t, err := gateway.Default().Treasury(c.Context())
if err != nil {
return gatewayFail(c, err)
}
return c.JSON(http.StatusOK, t)
}