Files
zooqueenandHanzo Dev c91ebb4a47 billing: a wallet sale asks Square again instead of guessing, and seats are bounded
An authorization with no answer is sent again under its key; a capture with
no answer is settled by reading the payment back, taking one still held. Only
a payment Square says it did not take lets the attempt go; one that stays
unknown seals the attempt (502), so its key never pays twice, and is named
for reconciliation. Everything after the authorization runs without the
buyer's request. Square's own 4xx is a processor.Answer; anything else may be
a call that never got one. A first invoice that is not written is tried twice
and reconciled. Nothing charged and the sale cannot go on now is 503.

Seats are at most a per-seat plan's maxMembers, else 10000, on subscribe,
create and PATCH, so price times seats never wraps.

A subscription's plan snapshot carries its catalog slug, so a reader can
match it to GET /v1/billing/plans.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-15 22:26:31 -07:00

590 lines
19 KiB
Go

package square
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"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"
)
// webhookMaxAge bounds how old a Square webhook may be before we reject it
// as a replay. Square stamps each notification with created_at; anything
// outside this window is refused even if the HMAC is valid. Mirrors the
// Stripe processor's 5-minute tolerance so both providers behave the same.
const webhookMaxAge = 5 * time.Minute
// SquareProcessor implements the processor.PaymentProcessor interface
type SquareProcessor struct {
*processor.BaseProcessor
accessToken string
locationID string
webhookSecret string
// webhookURL is the EXACT notification URL configured in the Square
// dashboard webhook subscription. Square signs HMAC-SHA256 over
// notificationURL + rawBody, so we must verify against the configured
// URL — never one derived from the inbound request Host, which a proxy
// can rewrite or an attacker can spoof.
webhookURL string
environment string // "sandbox" or "production"
paymentsClient *payments.Client
refundsClient *refunds.Client
customersClient *client.Client
cardsClient *cards.Client
}
// Config holds Square processor configuration
type Config struct {
AccessToken string
LocationID string
WebhookSecret string
// WebhookURL is the notification URL registered with Square's webhook
// subscription (e.g. https://api.hanzo.ai/v1/commerce/webhooks/square).
// Required to validate live Square signatures; see SquareProcessor.webhookURL.
WebhookURL 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,
webhookSecret: cfg.WebhookSecret,
webhookURL: cfg.WebhookURL,
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
}
// ValidateWebhook validates an incoming webhook and parses the event.
//
// Square signs each notification as
//
// base64(HMAC-SHA256(signatureKey, notificationURL + rawBody))
//
// where notificationURL is the EXACT URL configured in the dashboard webhook
// subscription — not the body alone, and not a URL derived from the inbound
// request (which a proxy can rewrite). The signature arrives base64-encoded in
// the x-square-hmacsha256-signature header. We decode both the header and our
// computed digest to raw bytes and compare with hmac.Equal for a clean
// constant-time check.
//
// In addition to the existing event_id idempotency, events older than
// webhookMaxAge are rejected as replays.
func (sp *SquareProcessor) ValidateWebhook(ctx context.Context, payload []byte, signature string) (*processor.WebhookEvent, error) {
if sp.webhookSecret == "" {
return nil, processor.ErrWebhookValidationFailed
}
if sp.webhookURL == "" {
// Square's documented signature is HMAC over notificationURL+body. With
// no configured URL we cannot reproduce it and must refuse rather than
// silently fall back to a body-only scheme that live Square would never
// match. Configure SQUARE_WEBHOOK_URL to the exact dashboard URL.
return nil, fmt.Errorf("%w: square webhook URL not configured", processor.ErrWebhookValidationFailed)
}
// Square signs notificationURL + rawBody (NOT the body alone).
// See https://developer.squareup.com/docs/webhooks/step3validate
mac := hmac.New(sha256.New, []byte(sp.webhookSecret))
mac.Write([]byte(sp.webhookURL))
mac.Write(payload)
expected := mac.Sum(nil)
// Constant-time comparison defeats timing analysis on the signature.
// Square sends the signature base64-encoded; decode to compare raw bytes.
provided, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signature))
if err != nil {
return nil, processor.ErrWebhookValidationFailed
}
if !hmac.Equal(provided, expected) {
return nil, processor.ErrWebhookValidationFailed
}
// Parse the webhook event JSON
var evt struct {
MerchantID string `json:"merchant_id"`
Type string `json:"type"`
EventID string `json:"event_id"`
CreatedAt string `json:"created_at"`
Data struct {
Type string `json:"type"`
ID string `json:"id"`
Object map[string]interface{} `json:"object"`
} `json:"data"`
}
if err := json.Unmarshal(payload, &evt); err != nil {
return nil, fmt.Errorf("failed to parse Square webhook: %w", err)
}
// Replay protection: reject events whose created_at is older than the
// tolerance. Mirrors the Stripe processor's timestamp check.
ts := time.Now().Unix()
if evt.CreatedAt != "" {
t, perr := time.Parse(time.RFC3339, evt.CreatedAt)
if perr != nil {
return nil, fmt.Errorf("%w: unparseable created_at %q", processor.ErrWebhookValidationFailed, evt.CreatedAt)
}
if time.Since(t) > webhookMaxAge {
return nil, fmt.Errorf("%w: event %s older than %s", processor.ErrWebhookValidationFailed, evt.EventID, webhookMaxAge)
}
ts = t.Unix()
}
return &processor.WebhookEvent{
ID: evt.EventID,
Type: mapSquareEventType(evt.Type),
Processor: processor.Square,
Data: evt.Data.Object,
Timestamp: ts,
}, nil
}
// mapSquareEventType converts Square event types to normalized types.
func mapSquareEventType(sqType string) string {
switch sqType {
case "payment.completed":
return "payment.completed"
case "payment.created":
return "payment.created"
case "payment.updated":
return "payment.updated"
case "refund.created":
return "refund.created"
case "refund.updated":
return "refund.updated"
case "invoice.payment_made":
return "invoice.paid"
case "subscription.created":
return "subscription.created"
case "subscription.updated":
return "subscription.updated"
case "dispute.created":
return "dispute.created"
case "dispute.state.updated", "dispute.state.changed":
return "dispute.updated"
case "customer.created":
return "customer.created"
case "customer.updated":
return "customer.updated"
case "customer.deleted":
return "customer.deleted"
default:
return sqType
}
}
// 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
}
// Ensure SquareProcessor implements PaymentProcessor
var _ processor.PaymentProcessor = (*SquareProcessor)(nil)