mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans, usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0. Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
182 lines
6.8 KiB
Go
182 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 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`.
|
|
if token == "" {
|
|
token = strings.TrimSpace(os.Getenv("SQUARE_ACCESS_TOKEN"))
|
|
locationID = strings.TrimSpace(os.Getenv("SQUARE_LOCATION_ID"))
|
|
if useSandbox {
|
|
if t := strings.TrimSpace(os.Getenv("SQUARE_SANDBOX_ACCESS_TOKEN")); t != "" {
|
|
token = t
|
|
}
|
|
if l := strings.TrimSpace(os.Getenv("SQUARE_SANDBOX_LOCATION_ID")); l != "" {
|
|
locationID = l
|
|
}
|
|
}
|
|
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.Live {
|
|
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
|
|
}
|