Files
commerce/thirdparty/square/processor.go

572 lines
18 KiB
Go

package square
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"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 failed(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 := ""
var refused error
if !completed {
errMsg = "payment not completed (status: " + paymentStatus + ")"
// A payment Square answered and did not settle is named by its status: a
// refusal when it will not settle (FAILED, CANCELED), and still processing
// when it may (PENDING, APPROVED; see processor.Decline.Processing).
refused = &processor.Decline{Processor: processor.Square, Category: processor.StatusCategory, Code: paymentStatus}
}
return &processor.PaymentResult{
Success: completed,
Error: refused,
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
}
// failed is the answer to a payment Square did not take, its error scrubbed ([scrub]).
func failed(err error) (*processor.PaymentResult, error) {
err = scrub(err)
return &processor.PaymentResult{Success: false, Error: err, ErrorMessage: err.Error()}, err
}
// scrub is a Square API error as this package lets it leave, applied where every SDK
// call returns. The SDK's error text is Square's whole response, and for a refused
// payment that response carries the payment's card_details (brand, last four digits,
// expiry, BIN, fingerprint), so none of it is kept:
//
// a card refusal (Square's PAYMENT_METHOD_ERROR) becomes a processor.Decline
// carrying the category and the code;
// any other Square error becomes a processor.PaymentError naming the HTTP status,
// the category and the code.
//
// An error that is not a Square API error (a context deadline, a failed dial) carries
// no response and is returned unchanged.
func scrub(err error) error {
var api *core.APIError
if !errors.As(err, &api) {
return err
}
category, code := classify(api)
if category == paymentMethodError {
return &processor.Decline{Processor: processor.Square, Category: category, Code: code}
}
if code == "" {
code = "HTTP_" + strconv.Itoa(api.StatusCode)
}
pe := processor.NewPaymentError(processor.Square, code,
fmt.Sprintf("square answered %d %s %s", api.StatusCode, category, code), nil)
pe.Status = api.StatusCode
return pe
}
// classify reads the first error's category and code out of a Square API error's
// response, or answers two empty strings when the response is not Square's error list.
func classify(api *core.APIError) (category, code string) {
if api.Unwrap() == nil {
return "", ""
}
var body struct {
Errors []struct {
Category string `json:"category"`
Code string `json:"code"`
} `json:"errors"`
}
if json.Unmarshal([]byte(api.Unwrap().Error()), &body) != nil || len(body.Errors) == 0 {
return "", ""
}
for _, e := range body.Errors {
if e.Category == paymentMethodError {
return e.Category, e.Code
}
}
return body.Errors[0].Category, body.Errors[0].Code
}
// paymentMethodError is Square's error category for a card it refused.
const paymentMethodError = "PAYMENT_METHOD_ERROR"
// Authorize authorizes a payment without capturing
func (sp *SquareProcessor) Authorize(ctx context.Context, req processor.PaymentRequest) (*processor.PaymentResult, error) {
if err := processor.ValidateRequest(req); err != nil {
return nil, err
}
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)
}
resp, err := sp.paymentsClient.Create(ctx, paymentReq)
if err != nil {
return failed(err)
}
return &processor.PaymentResult{
Success: true,
TransactionID: *resp.Payment.ID,
ProcessorRef: *resp.Payment.ID,
Status: "authorized",
Metadata: map[string]interface{}{
"order_id": resp.Payment.OrderID,
},
}, nil
}
// Capture captures a previously authorized payment
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 {
return failed(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)
}
return &processor.PaymentResult{
Success: true,
TransactionID: *payment.ID,
ProcessorRef: *payment.ID,
Fee: fee,
Status: "captured",
}, 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 {
err = scrub(err)
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, scrub(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.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, scrub(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)