Files
zooqueenandHanzo Dev 6c4f4d660b A sandbox read takes Square's sandbox names only
thirdparty/square.FromEnv is the one reader of the deployment's Square
account: SQUARE_SANDBOX_* for sandbox, SQUARE_* for production, never
mixed. The five readers (the org processor, the public tenant config,
the unified provider's env seed, the standalone init and payment links)
call it, so an empty sandbox set is an unconfigured processor and a
sandbox token is never paired with a production location.

The resolver tests now set the sandbox names they expect and keep the
production ones as hostile values a sandbox org must not show.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 07:27:17 -07:00

175 lines
6.6 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 credentials from the KMS-hydrated Organization model.
// Call kms.Hydrate(cc, org) before calling ProcessorsForOrg.
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/stripe"
square "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 KMS-hydrated 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
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 (square.FromEnv).
if token == "" {
creds := square.FromEnv(useSandbox)
token, locationID = creds.AccessToken, creds.LocationID
if webhookKey == "" {
webhookKey = strings.TrimSpace(os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY"))
}
}
if token != "" && locationID != "" {
reg.Register(square.NewProcessor(square.Config{
AccessToken: token,
LocationID: locationID,
WebhookSecret: webhookKey,
WebhookURL: webhookURL,
Environment: env,
}))
}
}
// 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
}