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

143 lines
5.0 KiB
Go

// Package square is commerce's one Square payment processor: the type every
// registry holds for processor.Square. processor.Global() holds one seeded from
// the deployment environment, which is what verifies webhooks; a per-org registry
// (payment.ProcessorsForOrg) holds one configured with the charging org's
// credentials.
//
// thirdparty/square is the Square API client underneath. It moves money and
// manages customers and cards; it does not verify webhooks and does not register
// itself anywhere. The lifecycle — configuration, availability, verification —
// is here.
package square
import (
"context"
"os"
"strings"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/payment/processor"
thirdparty "github.com/hanzoai/commerce/thirdparty/square"
)
// Config holds Square credentials and environment settings.
type Config struct {
// ApplicationID is the Square Developer Dashboard application ID.
// Required for the Web Payments SDK (browser-side tokenization).
ApplicationID string
// AccessToken is the server-side bearer that authenticates calls to the
// Square API.
AccessToken string
// LocationID pins charges to a specific Square merchant location.
LocationID string
// WebhookSignatureKey is the signature key of the Square webhook
// subscription; ValidateWebhook keys its HMAC with it.
WebhookSignatureKey string
// WebhookURL is the EXACT notification URL of that subscription. Square
// signs HMAC over notificationURL+body, so this is part of the proof.
WebhookURL string
// Environment must be "sandbox" or "production".
Environment string
// Org is the org this Square account belongs to: a delivery it verifies is
// applied to that org's books. Empty is the deployment's own account.
Org string
}
// Provider implements processor.PaymentProcessor and
// processor.CustomerProcessor for Square, delegating every API call to one
// *thirdparty.SquareProcessor.
type Provider struct {
*processor.BaseProcessor
config Config
inner *thirdparty.SquareProcessor
}
func init() {
// The global registry's Square slot, seeded from the deployment. It is the
// sessionless verifier HandleProviderWebhook reaches with no tenant resolver
// in front, so it is configured here or not at all.
p := &Provider{
BaseProcessor: processor.NewBaseProcessor(processor.Square, thirdparty.SquareSupportedCurrencies()),
}
p.Configure(configFromEnv())
processor.Register(p)
}
// configFromEnv is the deployment's own Square account: SQUARE_ENVIRONMENT picks
// the sandbox or production credential set, and only that set
// (thirdparty.FromEnv), plus the webhook subscription's key and URL.
func configFromEnv() Config {
env := strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT")))
sandbox := thirdparty.SandboxEnv()
creds := thirdparty.FromEnv(sandbox)
if sandbox {
env = "sandbox"
} else if env == "" {
env = "production"
}
return Config{
ApplicationID: creds.ApplicationID,
AccessToken: creds.AccessToken,
LocationID: creds.LocationID,
WebhookSignatureKey: strings.TrimSpace(os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY")),
WebhookURL: strings.TrimSpace(os.Getenv("SQUARE_WEBHOOK_URL")),
Environment: env,
}
}
// NewProvider builds and configures a Provider in one shot.
func NewProvider(cfg Config) *Provider {
p := &Provider{
BaseProcessor: processor.NewBaseProcessor(processor.Square, thirdparty.SquareSupportedCurrencies()),
}
p.Configure(cfg)
return p
}
// Configure replaces the provider's configuration. The webhook half is kept
// whether or not charge credentials are present; the API client exists only
// with an access token and a location.
func (p *Provider) Configure(cfg Config) {
p.config = cfg
if cfg.AccessToken == "" || cfg.LocationID == "" {
p.inner = nil
p.SetConfigured(false)
return
}
p.inner = thirdparty.NewProcessor(thirdparty.Config{
AccessToken: cfg.AccessToken,
LocationID: cfg.LocationID,
Environment: cfg.Environment,
})
p.SetConfigured(true)
}
// Type returns the processor type.
func (p *Provider) Type() processor.ProcessorType {
return processor.Square
}
// IsAvailable reports whether the provider can charge: an API client exists.
func (p *Provider) IsAvailable(ctx context.Context) bool {
return p.inner != nil && p.config.AccessToken != "" && p.config.LocationID != ""
}
// SupportedCurrencies returns the currencies Square supports.
func (p *Provider) SupportedCurrencies() []currency.Type {
return thirdparty.SquareSupportedCurrencies()
}
// ApplicationID returns the browser-facing application ID.
func (p *Provider) ApplicationID() string { return p.config.ApplicationID }
// LocationID returns the configured merchant location.
func (p *Provider) LocationID() string { return p.config.LocationID }
// Environment returns "sandbox" or "production".
func (p *Provider) Environment() string { return p.config.Environment }
var (
_ processor.PaymentProcessor = (*Provider)(nil)
_ processor.CustomerProcessor = (*Provider)(nil)
)