mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
447 lines
13 KiB
Go
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
|
|
}
|