Files
commerce/thirdparty/square/processor.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

447 lines
13 KiB
Go

package square
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/money"
"github.com/google/uuid"
square "github.com/square/square-go-sdk/v3"
"github.com/square/square-go-sdk/v3/cards"
"github.com/square/square-go-sdk/v3/core"
"github.com/square/square-go-sdk/v3/customers/client"
"github.com/square/square-go-sdk/v3/option"
"github.com/square/square-go-sdk/v3/payments"
"github.com/square/square-go-sdk/v3/refunds"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/payment/processor"
)
// SquareProcessor is the Square API client: payments, refunds, customers and
// cards. payment/providers/square is the processor the registries hold; it
// delegates every call here and verifies webhooks itself.
type SquareProcessor struct {
*processor.BaseProcessor
accessToken string
locationID string
environment string // "sandbox" or "production"
paymentsClient *payments.Client
refundsClient *refunds.Client
customersClient *client.Client
cardsClient *cards.Client
}
// Config holds the Square API credentials.
type Config struct {
AccessToken string
LocationID string
Environment string // "sandbox" or "production"
}
// NewProcessor creates a new Square processor
func NewProcessor(cfg Config) *SquareProcessor {
sp := &SquareProcessor{
BaseProcessor: processor.NewBaseProcessor(processor.Square, SquareSupportedCurrencies()),
accessToken: cfg.AccessToken,
locationID: cfg.LocationID,
environment: cfg.Environment,
}
if cfg.AccessToken != "" {
sp.initClient()
sp.SetConfigured(true)
}
return sp
}
// initClient initializes the Square client
func (sp *SquareProcessor) initClient() {
opts := []option.RequestOption{
option.WithToken(sp.accessToken),
}
if sp.environment == "sandbox" {
opts = append(opts, option.WithBaseURL("https://connect.squareupsandbox.com"))
}
reqOpts := core.NewRequestOptions(opts...)
sp.paymentsClient = payments.NewClient(reqOpts)
sp.refundsClient = refunds.NewClient(reqOpts)
sp.customersClient = client.NewClient(reqOpts)
sp.cardsClient = cards.NewClient(reqOpts)
}
// SquareSupportedCurrencies returns currencies Square supports
func SquareSupportedCurrencies() []currency.Type {
return []currency.Type{
currency.USD, currency.CAD, currency.GBP, currency.EUR,
currency.AUD, currency.JPY,
}
}
// Type returns the processor type
func (sp *SquareProcessor) Type() processor.ProcessorType {
return processor.Square
}
// Environment returns "sandbox" or "production" — the resolved Square API
// environment this processor talks to (it drives the API base URL). Non-secret.
func (sp *SquareProcessor) Environment() string { return sp.environment }
// LocationID returns the configured Square merchant location id. Non-secret
// (it is also returned by the public /v1/billing/settings endpoint).
func (sp *SquareProcessor) LocationID() string { return sp.locationID }
// Charge processes a payment
func (sp *SquareProcessor) Charge(ctx context.Context, req processor.PaymentRequest) (*processor.PaymentResult, error) {
if err := processor.ValidateRequest(req); err != nil {
return nil, err
}
// Honor the caller's idempotency key so a retried/concurrent charge with the
// same key is de-duplicated AT SQUARE (the definitive double-charge backstop);
// fall back to a random key only when the caller supplies none.
idempotencyKey := req.IdempotencyKey
if idempotencyKey == "" {
idempotencyKey = uuid.New().String()
}
paymentReq := &square.CreatePaymentRequest{
SourceID: req.Token,
IdempotencyKey: fitKey(idempotencyKey),
AmountMoney: &square.Money{
Amount: square.Int64(int64(req.Amount)),
Currency: squareCurrency(req.Currency),
},
LocationID: square.String(sp.locationID),
Autocomplete: square.Bool(true), // Capture immediately
}
if req.CustomerID != "" {
paymentReq.CustomerID = square.String(req.CustomerID)
}
if req.Description != "" {
paymentReq.Note = square.String(req.Description)
}
if req.OrderID != "" {
paymentReq.ReferenceID = square.String(req.OrderID)
}
resp, err := sp.paymentsClient.Create(ctx, paymentReq)
if err != nil {
return &processor.PaymentResult{
Success: false,
Error: err,
ErrorMessage: err.Error(),
}, err
}
payment := resp.Payment
fee := currency.Cents(0)
if len(payment.ProcessingFee) > 0 && payment.ProcessingFee[0].AmountMoney != nil {
fee = currency.Cents(*payment.ProcessingFee[0].AmountMoney.Amount)
}
paymentStatus := ""
if payment.Status != nil {
paymentStatus = *payment.Status
}
// Only a CAPTURED payment is a real success. Autocomplete=true (above)
// requests immediate capture, so a healthy charge settles; APPROVED
// (authorized, not captured) or PENDING (risk hold / delayed capture) is a 200
// with NO money captured. Treating those as success would confer a paid
// subscription + mint included credits against an uncaptured charge — so the
// collect/subscribe path must see them as a decline (Success=false).
//
// processor.Settled is the ONE definition, shared with the inbound webhook
// settlement path so a charge and a callback can never disagree about what
// counts as money.
completed := processor.Settled(paymentStatus)
errMsg := ""
if !completed {
errMsg = "payment not completed (status: " + paymentStatus + ")"
}
return &processor.PaymentResult{
Success: completed,
TransactionID: *payment.ID,
ProcessorRef: *payment.ID,
Fee: fee,
Status: paymentStatus,
ErrorMessage: errMsg,
Metadata: map[string]interface{}{
"receipt_url": payment.ReceiptURL,
"order_id": payment.OrderID,
},
}, nil
}
// Authorize authorizes a payment without capturing it (Autocomplete=false), and
// says which card stands behind the token (`paidWith`): a wallet's payment carries
// its card's fingerprint, brand, last four and expiry, which is what a caller that
// must know the card before money moves reads. The caller's idempotency key is
// honoured, so Square de-duplicates a repeated authorization; a random key is the
// fallback for a caller that has none.
//
// Only an APPROVED payment is an authorization. Anything else is a 200 with no
// hold on the buyer's money, and reads as a decline.
func (sp *SquareProcessor) Authorize(ctx context.Context, req processor.PaymentRequest) (*processor.PaymentResult, error) {
if err := processor.ValidateRequest(req); err != nil {
return nil, err
}
idempotencyKey := req.IdempotencyKey
if idempotencyKey == "" {
idempotencyKey = uuid.New().String()
}
paymentReq := &square.CreatePaymentRequest{
SourceID: req.Token,
IdempotencyKey: fitKey(idempotencyKey),
AmountMoney: &square.Money{
Amount: square.Int64(int64(req.Amount)),
Currency: squareCurrency(req.Currency),
},
LocationID: square.String(sp.locationID),
Autocomplete: square.Bool(false), // Authorize only
}
if req.CustomerID != "" {
paymentReq.CustomerID = square.String(req.CustomerID)
}
if req.Description != "" {
paymentReq.Note = square.String(req.Description)
}
if req.OrderID != "" {
paymentReq.ReferenceID = square.String(req.OrderID)
}
resp, err := sp.paymentsClient.Create(ctx, paymentReq)
if err != nil {
err = said(err)
return &processor.PaymentResult{
Success: false,
Error: err,
ErrorMessage: err.Error(),
}, err
}
payment := resp.Payment
status := ""
if payment.Status != nil {
status = *payment.Status
}
approved := strings.EqualFold(status, "APPROVED")
errMsg := ""
if !approved {
errMsg = "payment not authorized (status: " + status + ")"
}
card, wallet := paidWith(payment)
return &processor.PaymentResult{
Success: approved,
TransactionID: *payment.ID,
ProcessorRef: *payment.ID,
Status: status,
ErrorMessage: errMsg,
Card: card,
Wallet: wallet,
Metadata: map[string]interface{}{
"order_id": payment.OrderID,
},
}, nil
}
// said marks an error that is Square's own answer, a 4xx other than a timeout,
// as a processor.Answer: Square was reached and did nothing. Anything else may be
// a call that never got an answer.
func said(err error) error {
var api *core.APIError
if errors.As(err, &api) && api.StatusCode >= 400 && api.StatusCode < 500 && api.StatusCode != 408 {
return &processor.Answer{Err: err}
}
return err
}
// paidWith is what a payment was made with, as far as Square says: the card for a
// card or a card-backed wallet (Apple Pay, Google Pay) — its fingerprint, brand,
// last four and expiry — and the wallet's brand for a wallet that names no card
// (Cash App Pay). Nil and "" where Square said nothing.
func paidWith(payment *square.Payment) (*processor.Card, string) {
if payment == nil {
return nil, ""
}
var card *processor.Card
if d := payment.CardDetails; d != nil && d.Card != nil {
c := cardOf(d.Card)
card = &c
}
wallet := ""
if w := payment.WalletDetails; w != nil && w.Brand != nil {
wallet = *w.Brand
}
return card, wallet
}
// Capture completes a previously authorized payment. Only a payment Square says
// is COMPLETED is captured (processor.Settled); anything else is an answer that
// moved no money.
func (sp *SquareProcessor) Capture(ctx context.Context, transactionID string, amount money.Amount) (*processor.PaymentResult, error) {
resp, err := sp.paymentsClient.Complete(ctx, &square.CompletePaymentRequest{
PaymentID: transactionID,
})
if err != nil {
err = said(err)
return &processor.PaymentResult{
Success: false,
Error: err,
ErrorMessage: err.Error(),
}, err
}
payment := resp.Payment
fee := currency.Cents(0)
if len(payment.ProcessingFee) > 0 && payment.ProcessingFee[0].AmountMoney != nil {
fee = currency.Cents(*payment.ProcessingFee[0].AmountMoney.Amount)
}
status := ""
if payment.Status != nil {
status = *payment.Status
}
settled := processor.Settled(status)
errMsg := ""
if !settled {
errMsg = "payment not captured (status: " + status + ")"
}
card, wallet := paidWith(payment)
return &processor.PaymentResult{
Success: settled,
TransactionID: *payment.ID,
ProcessorRef: *payment.ID,
Fee: fee,
Status: status,
ErrorMessage: errMsg,
Card: card,
Wallet: wallet,
}, nil
}
// Refund processes a refund
func (sp *SquareProcessor) Refund(ctx context.Context, req processor.RefundRequest) (*processor.RefundResult, error) {
// Prefer a caller-supplied DETERMINISTIC idempotency key so a retried refund
// (same key) is de-duplicated by Square — the money move is idempotent even
// across pods/mutexes. Fall back to a random key only for legacy callers that
// don't supply one (non-idempotent — a retry would double-refund).
idempotencyKey := req.IdempotencyKey
if idempotencyKey == "" {
idempotencyKey = uuid.New().String()
}
refundReq := &square.RefundPaymentRequest{
IdempotencyKey: fitKey(idempotencyKey),
PaymentID: square.String(req.TransactionID),
AmountMoney: &square.Money{
Amount: square.Int64(req.Amount.Minor().Int64()),
Currency: squareCurrency(currency.USD), // Would get from transaction
},
}
if req.Reason != "" {
refundReq.Reason = square.String(req.Reason)
}
resp, err := sp.refundsClient.RefundPayment(ctx, refundReq)
if err != nil {
return &processor.RefundResult{
Success: false,
Error: err,
ErrorMessage: err.Error(),
}, err
}
return &processor.RefundResult{
Success: true,
RefundID: resp.Refund.ID,
ProcessorRef: resp.Refund.ID,
}, nil
}
// GetTransaction retrieves transaction details
func (sp *SquareProcessor) GetTransaction(ctx context.Context, txID string) (*processor.Transaction, error) {
resp, err := sp.paymentsClient.Get(ctx, &square.GetPaymentsRequest{
PaymentID: txID,
})
if err != nil {
return nil, err
}
payment := resp.Payment
fee := currency.Cents(0)
if len(payment.ProcessingFee) > 0 && payment.ProcessingFee[0].AmountMoney != nil {
fee = currency.Cents(*payment.ProcessingFee[0].AmountMoney.Amount)
}
createdAt := int64(0)
if payment.CreatedAt != nil {
if t, err := time.Parse(time.RFC3339, *payment.CreatedAt); err == nil {
createdAt = t.Unix()
}
}
customerID := ""
if payment.CustomerID != nil {
customerID = *payment.CustomerID
}
paymentStatus := ""
if payment.Status != nil {
paymentStatus = *payment.Status
}
return &processor.Transaction{
ID: *payment.ID,
ProcessorRef: *payment.ID,
Type: "charge",
Amount: currency.Cents(*payment.AmountMoney.Amount),
Currency: currency.Type(*payment.AmountMoney.Currency),
Status: paymentStatus,
Fee: fee,
CustomerID: customerID,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}, nil
}
// CancelAuthorization voids a previously authorized (uncaptured) payment.
// Call this immediately after a successful pre-auth to release the hold.
func (sp *SquareProcessor) CancelAuthorization(ctx context.Context, paymentID string) error {
_, err := sp.paymentsClient.Cancel(ctx, &square.CancelPaymentsRequest{
PaymentID: paymentID,
})
if err != nil {
return fmt.Errorf("cancel authorization %s: %w", paymentID, err)
}
return nil
}
// IsAvailable checks if the processor is configured and available
func (sp *SquareProcessor) IsAvailable(ctx context.Context) bool {
return sp.accessToken != "" && sp.locationID != "" && sp.paymentsClient != nil
}
// squareCurrency converts currency.Type to Square currency pointer.
// Square requires uppercase ISO 4217 codes (e.g. "USD" not "usd").
func squareCurrency(c currency.Type) *square.Currency {
curr := square.Currency(strings.ToUpper(string(c)))
return &curr
}