Files
commerce/payment/orgsetup.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

179 lines
6.8 KiB
Go

// Package payment provides per-org payment processor configuration.
//
// The payment/processor registry holds global singleton processors registered at
// init() time. These singletons are NOT safe for multi-tenant use because
// credentials would be shared across orgs.
//
// ProcessorsForOrg creates a FRESH registry with per-org processor instances,
// each configured with the credentials on the Organization: the org's own when
// a merchant path loaded them (Hydrate), else the deployment's environment.
package payment
import (
"os"
"strings"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/payment/processor"
"github.com/hanzoai/commerce/payment/providers/adyen"
"github.com/hanzoai/commerce/payment/providers/braintree"
"github.com/hanzoai/commerce/payment/providers/lemonsqueezy"
"github.com/hanzoai/commerce/payment/providers/paypal"
"github.com/hanzoai/commerce/payment/providers/recurly"
"github.com/hanzoai/commerce/payment/providers/square"
"github.com/hanzoai/commerce/payment/providers/stripe"
sqenv "github.com/hanzoai/commerce/thirdparty/square"
)
// defaultSquareWebhookURL is the canonical commerce API ingress URL that
// Square callbacks are delivered to. Square signs each delivery over this URL
// plus the raw body, so it must match the URL registered in the Square
// dashboard byte-for-byte. pay.hanzo.ai is the customer-facing payment host
// (the commerce API ingress); commerce.hanzo.ai serves the marketing SPA, not
// the API. Overridable per-org (org.Square.WebhookURL) or per-deployment
// (SQUARE_WEBHOOK_URL) for non-production hosts.
//
// It answers under COMMERCE's root because that is the capability that verifies
// it. /v1/billing is the billing capability's address and billing holds no
// signature key; this callback is commerce's own, and a processor posts it to
// the app that can check it.
//
// THIS VALUE IS PART OF THE SIGNATURE, not merely where the request lands, so
// changing it is a coordinated change and never a redirect: the Square
// dashboard's notification URL and any SQUARE_WEBHOOK_URL override must move
// with it, or every delivery fails verification with a valid signature.
const defaultSquareWebhookURL = "https://pay.hanzo.ai/v1/commerce/webhooks/square"
// ProcessorsForOrg returns a processor registry configured with the given
// organization's payment credentials. Each processor is a fresh instance —
// no shared state with other orgs or the global singleton registry.
//
// Processors without credentials in the org are still registered but marked
// as unavailable (IsAvailable returns false).
func ProcessorsForOrg(org *organization.Organization) *processor.Registry {
reg := processor.NewRegistry(processor.DefaultConfig())
// Stripe
sk := org.StripeToken()
if sk != "" {
reg.Register(stripe.NewProvider(stripe.Config{
SecretKey: sk,
PublishableKey: org.Stripe.Live.PublishableKey,
WebhookSecret: "", // webhook secret is global, not per-org
}))
}
// Square. The ORG is the single authority for sandbox-vs-production (see
// org.TestMode): it selects BOTH which credential set to use AND
// the Environment passed to the provider (which drives the Square API base
// URL). This used to be the deployment's SQUARE_ENVIRONMENT, which forced every
// tenant in the process into one mode; per-org resolution is what lets one
// replica serve a sandbox merchant and a live merchant. Falls back to process
// env vars when KMS is disabled or the org has no stored Square credentials —
// that fallback still reads the env and is the next thing to retire, since it
// lets a tenant transact on the deployment's own payment account.
{
useSandbox := org.TestMode()
env := org.SquareEnvironment()
sqCfg := org.SquareConfig(useSandbox)
token := sqCfg.AccessToken
locationID := sqCfg.LocationId
appID := sqCfg.ApplicationId
webhookKey := org.Square.WebhookSignatureKey
// Notification URL that Square signs deliveries against. Prefer the
// org's configured value, then the deployment env, then the canonical
// commerce ingress URL. Must match the URL registered in the Square
// dashboard byte-for-byte or HMAC verification fails.
webhookURL := org.Square.WebhookURL
if webhookURL == "" {
webhookURL = strings.TrimSpace(os.Getenv("SQUARE_WEBHOOK_URL"))
}
if webhookURL == "" {
webhookURL = defaultSquareWebhookURL
}
// Env-var credential fallback (KMS disabled or no stored Square creds).
// The SAME useSandbox authority picks the sandbox vs production env vars,
// so the credential set always matches `env`, and a sandbox org reads
// the sandbox names only (sqenv.FromEnv).
if token == "" {
creds := sqenv.FromEnv(useSandbox)
token, locationID, appID = creds.AccessToken, creds.LocationID, creds.ApplicationID
if webhookKey == "" {
webhookKey = strings.TrimSpace(os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY"))
}
}
if token != "" && locationID != "" {
reg.Register(square.NewProvider(square.Config{
ApplicationID: appID,
AccessToken: token,
LocationID: locationID,
WebhookSignatureKey: webhookKey,
WebhookURL: webhookURL,
Environment: env,
Org: org.Name,
}))
}
}
// Adyen
if org.Adyen.APIKey != "" {
reg.Register(adyen.NewProvider(adyen.Config{
APIKey: org.Adyen.APIKey,
MerchantAccount: org.Adyen.MerchantAccount,
HMACKey: org.Adyen.HMACKey,
Environment: adyen.Environment(org.Adyen.Environment),
LiveURLPrefix: org.Adyen.LiveURLPrefix,
}))
}
// Braintree
if org.Braintree.PublicKey != "" {
reg.Register(braintree.NewProvider(braintree.Config{
MerchantID: org.Braintree.MerchantID,
PublicKey: org.Braintree.PublicKey,
PrivateKey: org.Braintree.PrivateKey,
Environment: org.Braintree.Environment,
}))
}
// PayPal — use v2 REST API client credentials
// The legacy org.Paypal fields use Adaptive Payments (deprecated).
// Map securityUserId → clientID, securityPassword → clientSecret.
ppCreds := org.Paypal.Live
ppSandbox := false
if org.TestMode() {
ppCreds = org.Paypal.Test
ppSandbox = true
}
if ppCreds.SecurityUserId != "" {
reg.Register(paypal.NewProvider(paypal.Config{
ClientID: ppCreds.SecurityUserId,
ClientSecret: ppCreds.SecurityPassword,
Sandbox: ppSandbox,
}))
}
// Recurly
if org.Recurly.APIKey != "" {
reg.Register(recurly.NewProvider(recurly.Config{
APIKey: org.Recurly.APIKey,
Subdomain: org.Recurly.Subdomain,
}))
}
// LemonSqueezy
if org.LemonSqueezy.APIKey != "" {
reg.Register(lemonsqueezy.NewProvider(lemonsqueezy.Config{
APIKey: org.LemonSqueezy.APIKey,
StoreID: org.LemonSqueezy.StoreID,
WebhookSecret: org.LemonSqueezy.WebhookSecret,
DefaultVariantID: org.LemonSqueezy.DefaultVariantID,
}))
}
return reg
}