- mock: 400->402 for CARD_DECLINED_VERIFICATION_REQUIRED, cnon:sca- tokenize-result binding validated (prefix/amount/deny), RefundPayment exact-amount reconcile parity, ReplayPaymentByKey snapshot sanity, verify_mock_ legacy widening removed, listRefunds zero-time omits begin_time - main.go: SNAPSHOT_ENC_KEY log.Fatalf in non-mock, webhook key-set-URL-unset log.Fatalf, SQUARE_ACCESS_TOKEN/LOCATION startup validation, empty-env base URL matches 2FA production interpretation - mw: ClientIP rejects garbage/comma/port XFF values, documented trusted-proxy requirement Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
1364 lines
56 KiB
Go
1364 lines
56 KiB
Go
package square
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrCheckoutPending is returned by GetCheckout when a terminal checkout has
|
|
// not yet completed. Handlers use errors.Is(err, ErrCheckoutPending) rather
|
|
// than string comparison, so behaviour is identical across the mock and the
|
|
// real HTTP client.
|
|
var ErrCheckoutPending = errors.New("checkout pending")
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Square REST API constants.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
squareSandboxURL = "https://connect.squareupsandbox.com"
|
|
squareProductionURL = "https://connect.squareup.com"
|
|
squareAPIVersion = "2026-05-20"
|
|
defaultHTTPTimeout = 30 * time.Second
|
|
|
|
// maxResponseBody caps how many bytes doJSON reads from a response. Square
|
|
// responses are normally a few KB; the cap guards against an OOM from a
|
|
// compromised/proxied Square endpoint streaming unbounded data.
|
|
maxResponseBody = 1 << 20 // 1 MiB
|
|
|
|
// maxErrorBody caps the raw response body embedded in error messages.
|
|
// Handlers log these errors verbatim, so echoing more than a snippet risks
|
|
// leaking PII that Square may have mirrored from the request.
|
|
maxErrorBody = 500
|
|
|
|
// MaxIdempotencyKeyLength is Square's 45-character idempotency-key limit for
|
|
// /v2/payments, /v2/cards and /v2/refunds (64 only for
|
|
// /v2/terminals/checkouts). The square package is the client to Square, so
|
|
// THIS is the SINGLE SOURCE of the cap: square_dev.go's mock rejection and
|
|
// the card/customer key builders route through it, and the payments package
|
|
// aliases it (handlers/payments/idempotency_helpers.go's
|
|
// maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength) rather than
|
|
// declaring a second, drifting 45. Update this one constant if Square ever
|
|
// changes the limit.
|
|
MaxIdempotencyKeyLength = 45
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP client — shared by ProdClient (!dev) and devProdClient (dev).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// gbpCurrency is the currency sent in every Square money amount. UK-only app,
|
|
// so GBP is the only currency ever used; the named constant keeps the wire
|
|
// bodies (including the identical-body replay) consistent.
|
|
const gbpCurrency = "GBP"
|
|
|
|
type httpClient struct {
|
|
baseURL string
|
|
token string
|
|
locationID string
|
|
deviceID string
|
|
http *http.Client
|
|
}
|
|
|
|
// SquareEnvironment returns the resolved SQUARE_ENVIRONMENT value. It is the
|
|
// SINGLE code path by which this package reads which Square environment it
|
|
// talks to: newHTTPClient derives its base URL from it and the dev build's
|
|
// NewDevClient routes on it, so a dev mock vs real API decision is never a
|
|
// second, drifting env read. The payments sweep reads the same value through
|
|
// this helper so the sweep and the charge process share one environment source
|
|
// (the sweep env contract).
|
|
func SquareEnvironment() string {
|
|
return os.Getenv("SQUARE_ENVIRONMENT")
|
|
}
|
|
|
|
// SquareLocationID returns the SQUARE_LOCATION_ID value newHTTPClient embeds
|
|
// in payment requests. Exported so the payments sweep resolves the location
|
|
// through the same code path as the charge process: a location drift between a
|
|
// charge and its replay would change the replay body and break Square's
|
|
// identical-body idempotency dedup (the sweep env contract).
|
|
func SquareLocationID() string {
|
|
return os.Getenv("SQUARE_LOCATION_ID")
|
|
}
|
|
|
|
func newHTTPClient() *httpClient {
|
|
env := SquareEnvironment()
|
|
baseURL := squareSandboxURL
|
|
// Empty/unknown SQUARE_ENVIRONMENT is production-ENFORCED, mirroring
|
|
// payments.IsExplicitDevOrMockEnv()'s fail-closed interpretation (an
|
|
// unset/mistyped env var must never downgrade to a less safe default), so
|
|
// the production base URL is used for both "production" and "". Only an
|
|
// explicit sandbox or dev/mock value resolves to the sandbox base URL (the
|
|
// dev/mock values never construct a real HTTP client anyway).
|
|
switch env {
|
|
case "production", "":
|
|
baseURL = squareProductionURL
|
|
}
|
|
return &httpClient{
|
|
baseURL: baseURL,
|
|
token: os.Getenv("SQUARE_ACCESS_TOKEN"),
|
|
locationID: SquareLocationID(),
|
|
deviceID: os.Getenv("SQUARE_TERMINAL_DEVICE_ID"),
|
|
http: &http.Client{Timeout: defaultHTTPTimeout},
|
|
}
|
|
}
|
|
|
|
// ValidateCredentials is the fail-closed startup validation for the REAL
|
|
// Square API credentials. It is called from main.go's startup gate ONLY for
|
|
// non-dev/mock deployments (main.go checks payments.IsExplicitDevOrMockEnv
|
|
// first — the env interpretation stays in the payments package to avoid a
|
|
// drifting duplicate read). With a real deployment selected, a missing
|
|
// SQUARE_ACCESS_TOKEN or SQUARE_LOCATION_ID would make every Square API call
|
|
// fail at runtime (401 auth, or request bodies without location_id), breaking
|
|
// payments/refunds — so the process refuses to start, mirroring the
|
|
// SQUARE_WEBHOOK_SIGNATURE_KEY gate. Never includes the secret values.
|
|
func ValidateCredentials() {
|
|
if strings.TrimSpace(os.Getenv("SQUARE_ACCESS_TOKEN")) == "" {
|
|
log.Fatalf("FATAL: SQUARE_ACCESS_TOKEN environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) — every Square API call would be rejected (401) and online payment/refund processing is broken. Set the access token from the Square Developer Dashboard.", SquareEnvironment())
|
|
}
|
|
if strings.TrimSpace(SquareLocationID()) == "" {
|
|
log.Fatalf("FATAL: SQUARE_LOCATION_ID environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) — Square payment requests would carry no location_id and fail at runtime. Set the location ID from the Square Developer Dashboard.", SquareEnvironment())
|
|
}
|
|
}
|
|
|
|
func (c *httpClient) doJSON(ctx context.Context, method, path string, body, target any) error {
|
|
if c.token == "" {
|
|
return fmt.Errorf("square: SQUARE_ACCESS_TOKEN is not set")
|
|
}
|
|
var reqBody []byte
|
|
if body != nil {
|
|
var err error
|
|
reqBody, err = json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("square: marshal request: %w", err)
|
|
}
|
|
}
|
|
url := c.baseURL + path
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return fmt.Errorf("square: create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Square-Version", squareAPIVersion)
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("square: %s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read the response through a limit so a compromised/proxied Square
|
|
// endpoint cannot stream unbounded data into memory (OOM guard). If the
|
|
// limit is exceeded, the body is cut and callers get a truncation error
|
|
// rather than silently parsing a partial response.
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1))
|
|
if err != nil {
|
|
return fmt.Errorf("square: read response: %w", err)
|
|
}
|
|
truncated := len(respBody) > maxResponseBody
|
|
if truncated {
|
|
respBody = respBody[:maxResponseBody]
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
var errResp struct {
|
|
Errors []SquareError `json:"errors"`
|
|
}
|
|
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
|
se := errResp.Errors[0]
|
|
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, capBody(se.Detail), se.Field)
|
|
return &squareAPIError{
|
|
Code: se.Code,
|
|
Detail: se.Detail,
|
|
Category: se.Category,
|
|
Field: se.Field,
|
|
StatusCode: resp.StatusCode,
|
|
err: errors.New(msg),
|
|
}
|
|
}
|
|
if truncated {
|
|
return fmt.Errorf("square: %s %s: HTTP %d: response body exceeds %d bytes (truncated): %s", method, path, resp.StatusCode, maxResponseBody, capBody(string(respBody)))
|
|
}
|
|
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, capBody(string(respBody)))
|
|
}
|
|
if truncated {
|
|
// A truncated 2xx body must never be silently accepted as a partial
|
|
// success. Even when the first maxResponseBody bytes are still valid
|
|
// JSON (e.g. an array cut at an element boundary), Unmarshal succeeds
|
|
// and the caller would otherwise get a partial response with nil error.
|
|
// Real Square responses are a few KB, so this only fires against a
|
|
// compromised/proxied endpoint.
|
|
if target != nil && len(respBody) > 0 {
|
|
if err := json.Unmarshal(respBody, target); err != nil {
|
|
return fmt.Errorf("square: %s %s: response body exceeds %d bytes (truncated), cannot parse: %w", method, path, maxResponseBody, err)
|
|
}
|
|
}
|
|
return fmt.Errorf("square: %s %s: response body exceeds %d bytes (truncated)", method, path, maxResponseBody)
|
|
}
|
|
if target != nil && len(respBody) > 0 {
|
|
if err := json.Unmarshal(respBody, target); err != nil {
|
|
return fmt.Errorf("square: unmarshal response: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// capBody returns s truncated to maxErrorBody bytes with a truncation marker.
|
|
// Error messages are logged verbatim by handlers, so embedding more than a
|
|
// snippet of a (possibly echoed) response body risks leaking PII.
|
|
func capBody(s string) string {
|
|
if len(s) <= maxErrorBody {
|
|
return s
|
|
}
|
|
// Truncate on a rune boundary, not a raw byte slice: s[:maxErrorBody] can
|
|
// split a multi-byte UTF-8 sequence, producing invalid UTF-8 in an error
|
|
// message logged verbatim (mangles logs feeding UTF-8-sensitive tooling).
|
|
// ToValidUTF8 drops the partial rune at the cut.
|
|
return strings.ToValidUTF8(s[:maxErrorBody], "") + "... (truncated)"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Square JSON types — exact wire-format match with Square's REST API.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type sqMoney struct {
|
|
Amount int64 `json:"amount"`
|
|
Currency string `json:"currency"`
|
|
}
|
|
|
|
// --- Payment types ---
|
|
|
|
// sqCreatePaymentRequest is the exact POST /v2/payments wire body.
|
|
//
|
|
// SAVED-CARD SCA CONTRACT (CURRENT — Square's "Charge a Card on File" flow):
|
|
// the frontend runs card.tokenize(verificationDetails, cardId), which returns
|
|
// a fresh one-time cnon:-style tokenize-result minted ONLY after the buyer
|
|
// completed issuer verification for that card + amount. That tokenize-result
|
|
// is sent here as source_id, together with customer_id resolved from the saved
|
|
// card row — Square requires customer_id for a card-on-file source, and the
|
|
// token IS the buyer verification, so no verification_token is emitted on this
|
|
// path. verification_token is retained ONLY as the LEGACY verifyBuyer() field
|
|
// (Square is deprecating verifyBuyer(); see
|
|
// https://developer.squareup.com/docs/web-payments/take-card-payment) and is
|
|
// emitted only when a caller explicitly populates CreatePaymentReq.
|
|
// VerificationToken — no current handler does.
|
|
type sqCreatePaymentRequest struct {
|
|
SourceID string `json:"source_id"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
AmountMoney sqMoney `json:"amount_money"`
|
|
Autocomplete *bool `json:"autocomplete,omitempty"`
|
|
LocationID string `json:"location_id,omitempty"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
CustomerID string `json:"customer_id,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
TipMoney *sqMoney `json:"tip_money,omitempty"`
|
|
VerificationToken string `json:"verification_token,omitempty"` // LEGACY verifyBuyer() token — deprecated, kept as a fallback
|
|
BuyerEmailAddress string `json:"buyer_email_address,omitempty"`
|
|
// CustomerDetails carries customer_initiated so Square classifies the
|
|
// charge as cardholder-initiated (SCA applies) rather than defaulting to a
|
|
// merchant-initiated classification. Online card entry is always
|
|
// cardholder-initiated in this app, so the flag is sent as true when set.
|
|
CustomerDetails *CreateCustomerDetails `json:"customer_details,omitempty"`
|
|
}
|
|
|
|
type sqCreatePaymentResponse struct {
|
|
Payment sqPayment `json:"payment"`
|
|
}
|
|
|
|
type sqPayment struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
TotalMoney sqMoney `json:"total_money"`
|
|
TipMoney *sqMoney `json:"tip_money,omitempty"`
|
|
SourceType string `json:"source_type"`
|
|
CardDetails *sqCardDetails `json:"card_details,omitempty"`
|
|
LocationID string `json:"location_id"`
|
|
OrderID string `json:"order_id,omitempty"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
CustomerID string `json:"customer_id,omitempty"`
|
|
BuyerEmail string `json:"buyer_email_address,omitempty"`
|
|
ReceiptNumber string `json:"receipt_number,omitempty"`
|
|
ReceiptURL string `json:"receipt_url,omitempty"`
|
|
ProcessingFee []sqFee `json:"processing_fee,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
type sqCardDetails struct {
|
|
Card sqCard `json:"card"`
|
|
EntryMethod string `json:"entry_method"`
|
|
CVVStatus string `json:"cvv_status,omitempty"`
|
|
AVSStatus string `json:"avs_status,omitempty"`
|
|
}
|
|
|
|
type sqCard struct {
|
|
ID string `json:"id"`
|
|
CardBrand string `json:"card_brand"`
|
|
Last4 string `json:"last_4"`
|
|
ExpMonth int `json:"exp_month"`
|
|
ExpYear int `json:"exp_year"`
|
|
CardholderName string `json:"cardholder_name,omitempty"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
CustomerID string `json:"customer_id,omitempty"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// sqFee matches Square's processing_fee object. The fee amount lives in
|
|
// amount_money.amount, NOT a top-level amount field — reading the wrong shape
|
|
// made every PaymentResult.Fees 0 against the real API (N-9).
|
|
type sqFee struct {
|
|
AmountMoney sqMoney `json:"amount_money"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// --- Terminal Checkout types ---
|
|
|
|
type sqTerminalCheckoutRequest struct {
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
Checkout sqTerminalCheckoutPayload `json:"checkout"`
|
|
}
|
|
|
|
type sqTerminalCheckoutPayload struct {
|
|
AmountMoney sqMoney `json:"amount_money"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
CustomerID string `json:"customer_id,omitempty"`
|
|
DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"`
|
|
}
|
|
|
|
// sqTipSettings maps to Square's DeviceCheckoutOptions.tip_settings object
|
|
// (nested INSIDE device_options — a top-level tip_settings is silently ignored
|
|
// by Square's TerminalCheckout API, losing terminal tip revenue). Only
|
|
// allow_tipping is emitted — Square's wire field for enabling terminal tips.
|
|
type sqTipSettings struct {
|
|
AllowTipping bool `json:"allow_tipping"`
|
|
}
|
|
|
|
// sqDeviceOptions maps to Square's DeviceCheckoutOptions object inside the
|
|
// TerminalCheckout payload. device_id is REQUIRED; tip_settings lives here
|
|
// (not at the checkout top level) so terminal tips are actually collected.
|
|
type sqDeviceOptions struct {
|
|
DeviceID string `json:"device_id"`
|
|
TipSettings *sqTipSettings `json:"tip_settings,omitempty"`
|
|
}
|
|
|
|
type sqTerminalCheckoutResponse struct {
|
|
Checkout sqTerminalCheckout `json:"checkout"`
|
|
}
|
|
|
|
type sqTerminalCheckout struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
AmountMoney sqMoney `json:"amount_money"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
PaymentIDs []string `json:"payment_ids,omitempty"`
|
|
// Deadline (deadline_duration) is a LIVE TerminalCheckout field: an RFC 3339
|
|
// duration (e.g. "PT5M") telling the terminal how long the checkout stays
|
|
// active. Square defaults it to 5 minutes. It is NOT an absolute timestamp
|
|
// and NOT deprecated. Kept as a string because the app only copies it
|
|
// through to CheckoutResult.Deadline.
|
|
Deadline string `json:"deadline_duration,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type sqGetPaymentResponse struct {
|
|
Payment sqPayment `json:"payment"`
|
|
}
|
|
|
|
// --- Refund types ---
|
|
|
|
type sqRefundPaymentRequest struct {
|
|
PaymentID string `json:"payment_id"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
AmountMoney sqMoney `json:"amount_money"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type sqRefundPaymentResponse struct {
|
|
Refund sqRefund `json:"refund"`
|
|
}
|
|
|
|
type sqListRefundsResponse struct {
|
|
Refunds []sqRefund `json:"refunds"`
|
|
Cursor string `json:"cursor"`
|
|
}
|
|
|
|
type sqRefund struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
AmountMoney sqMoney `json:"amount_money"`
|
|
PaymentID string `json:"payment_id"`
|
|
LocationID string `json:"location_id"`
|
|
Reason string `json:"reason,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// --- Card types ---
|
|
|
|
type sqCreateCardRequest struct {
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
SourceID string `json:"source_id"`
|
|
Card sqCardPayload `json:"card"`
|
|
}
|
|
|
|
type sqCardPayload struct {
|
|
ExpMonth *int `json:"exp_month,omitempty"`
|
|
ExpYear *int `json:"exp_year,omitempty"`
|
|
CardholderName string `json:"cardholder_name,omitempty"`
|
|
CustomerID string `json:"customer_id,omitempty"`
|
|
ReferenceID string `json:"reference_id,omitempty"`
|
|
}
|
|
|
|
type sqCreateCardResponse struct {
|
|
Card sqCard `json:"card"`
|
|
}
|
|
|
|
type sqListCardsResponse struct {
|
|
Cards []sqCard `json:"cards"`
|
|
Cursor string `json:"cursor"`
|
|
}
|
|
|
|
type sqDisableCardResponse struct {
|
|
Card sqCard `json:"card"`
|
|
}
|
|
|
|
// --- Customer types ---
|
|
|
|
type sqCreateCustomerRequest struct {
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
EmailAddress string `json:"email_address"`
|
|
GivenName string `json:"given_name,omitempty"`
|
|
}
|
|
|
|
type sqCreateCustomerResponse struct {
|
|
Customer sqCustomer `json:"customer"`
|
|
}
|
|
|
|
// sqCustomer maps to Square's Customer object. Only fields this application
|
|
// consumes are included.
|
|
type sqCustomer struct {
|
|
ID string `json:"id"`
|
|
EmailAddress string `json:"email_address"`
|
|
GivenName string `json:"given_name"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Package-level HTTP functions — shared by ProdClient and devProdClient.
|
|
// Each builds a fresh httpClient from env vars and makes the Square API call.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// validSquareID reports whether id is safe to embed in a Square REST URL path
|
|
// segment. Square IDs are alphanumeric plus '_' and '-' and well under 64
|
|
// characters; anything else could produce a malformed URL or enable path
|
|
// traversal in a future caller.
|
|
func validSquareID(id string) bool {
|
|
if len(id) == 0 || len(id) > 64 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(id); i++ {
|
|
c := id[i]
|
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// validCardID reports whether a card ID is safe to embed in a Square REST URL
|
|
// path segment. Card IDs (ccof:xxx) carry a "ccof:" colon prefix that plain
|
|
// Square IDs do not, so the prefix is stripped before the standard
|
|
// validSquareID charset check (which rejects ":"); everything after the
|
|
// prefix must still pass the same alphanumeric/_/- rule.
|
|
func validCardID(id string) bool {
|
|
if strings.HasPrefix(id, "ccof:") {
|
|
return validSquareID(id[len("ccof:"):])
|
|
}
|
|
return validSquareID(id)
|
|
}
|
|
|
|
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces (new
|
|
// cards AND saved-card SCA tokenize-results — card.tokenize(verificationDetails,
|
|
// cardId) returns a fresh cnon:-style one-time token) and ccof:xxx card IDs.
|
|
// Raw PANs (all digits) are NOT token-like and are rejected. This is the single
|
|
// source of truth for token validation, shared by the real HTTP client and the
|
|
// dev mock so PCI-DSS parity holds in both builds.
|
|
func isTokenLike(s string) bool {
|
|
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
|
}
|
|
|
|
// tokenPrefix returns a PCI-safe abbreviation of a card token for error and
|
|
// log messages: the first 8 characters plus an ellipsis and the total length.
|
|
// The full token (a raw PAN, single-use nonce, or card reference) must NEVER
|
|
// be echoed — handlers log these errors, so embedding the raw value would land
|
|
// client-submitted card data verbatim in server logs.
|
|
func tokenPrefix(s string) string {
|
|
if len(s) > 8 {
|
|
return fmt.Sprintf("%s... (len %d)", s[:8], len(s))
|
|
}
|
|
return fmt.Sprintf("%s (len %d)", s, len(s))
|
|
}
|
|
|
|
// TokenPrefix is the exported form of tokenPrefix, for packages outside
|
|
// internal/square (e.g. handlers) that log ccof:/cnon: card tokens. The full
|
|
// token must never reach logs.
|
|
func TokenPrefix(s string) string {
|
|
return tokenPrefix(s)
|
|
}
|
|
|
|
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
|
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
|
}
|
|
|
|
func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *httpClient) (*PaymentResult, error) {
|
|
// PCI-DSS parity with the dev mock: reject raw PANs before they reach
|
|
// Square. source_id must be a cnon: nonce or ccof: card ID — anything else
|
|
// (e.g. a plain card number) is refused client-side so no card data is ever
|
|
// sent to the API in a non-token form.
|
|
if !isTokenLike(req.SourceID) {
|
|
return nil, fmt.Errorf("square: invalid card token %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID))
|
|
}
|
|
var resp sqCreatePaymentResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", buildCreatePaymentBody(req, hc), &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return paymentFromSquare(&resp.Payment), nil
|
|
}
|
|
|
|
// buildCreatePaymentBody converts a CreatePaymentReq into the exact POST
|
|
// /v2/payments wire body. Shared by createPaymentHTTPWithClient (the original
|
|
// charge) and replayPaymentByKeyHTTPWithClient (the identical-body replay), so
|
|
// a charge replayed from the stored snapshot produces BYTE-IDENTICAL JSON to
|
|
// the original — Square's idempotency dedup compares the full request body.
|
|
//
|
|
// Saved-card SCA charges ride this unchanged: the handler resolves the charge
|
|
// source to the fresh card.tokenize(verificationDetails, cardId) tokenize-result
|
|
// (a cnon:-style token) in SourceID and the saved card's customer in
|
|
// CustomerID — the CURRENT contract, where the token IS the buyer verification.
|
|
// VerificationToken is passed through untouched for the LEGACY verifyBuyer()
|
|
// path only. The dev mock validates the SAME wire shape via mockPaymentWireBody
|
|
// (square_dev.go); TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical
|
|
// asserts both constructors stay byte-identical.
|
|
func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymentRequest {
|
|
body := sqCreatePaymentRequest{
|
|
SourceID: req.SourceID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
AmountMoney: sqMoney{Amount: req.Amount, Currency: req.Currency},
|
|
Autocomplete: req.Autocomplete,
|
|
LocationID: firstNonEmpty(req.LocationID, hc.locationID),
|
|
ReferenceID: req.ReferenceID,
|
|
CustomerID: req.CustomerID,
|
|
Note: req.Note,
|
|
VerificationToken: req.VerificationToken,
|
|
BuyerEmailAddress: req.BuyerEmail,
|
|
CustomerDetails: req.CustomerDetails,
|
|
}
|
|
if req.TipMoney != nil {
|
|
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func createCheckoutHTTP(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
|
return createCheckoutHTTPWithClient(ctx, req, newHTTPClient())
|
|
}
|
|
|
|
func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc *httpClient) (*CheckoutResult, error) {
|
|
// device_options is REQUIRED by Square's TerminalCheckout API. Prefer the
|
|
// per-request device ID, falling back to the env-configured terminal
|
|
// (SQUARE_TERMINAL_DEVICE_ID) so the field is always present.
|
|
deviceID := req.DeviceID
|
|
if deviceID == "" {
|
|
deviceID = hc.deviceID
|
|
}
|
|
body := sqTerminalCheckoutRequest{
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
Checkout: sqTerminalCheckoutPayload{
|
|
AmountMoney: sqMoney{Amount: req.Amount, Currency: req.Currency},
|
|
ReferenceID: req.ReferenceID,
|
|
Note: req.Note,
|
|
CustomerID: req.CustomerID,
|
|
DeviceOptions: &sqDeviceOptions{
|
|
DeviceID: deviceID,
|
|
},
|
|
},
|
|
}
|
|
// AllowTipping must reach Square as device_options.tip_settings.allow_tipping
|
|
// — without it the terminal never prompts for a tip and tip revenue is
|
|
// silently lost. A top-level tip_settings would be ignored by Square.
|
|
if req.AllowTipping {
|
|
body.Checkout.DeviceOptions.TipSettings = &sqTipSettings{AllowTipping: true}
|
|
}
|
|
var resp sqTerminalCheckoutResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return checkoutFromSquare(&resp.Checkout), nil
|
|
}
|
|
|
|
func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
|
return getCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
|
}
|
|
|
|
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
|
if !validSquareID(checkoutID) {
|
|
return nil, fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
|
}
|
|
var tcResp sqTerminalCheckoutResponse
|
|
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
|
return nil, err
|
|
}
|
|
tc := tcResp.Checkout
|
|
if tc.Status != "COMPLETED" {
|
|
// PENDING / IN_PROGRESS / CANCEL_REQUESTED all mean the terminal
|
|
// hasn't finished — treat as still-polling. Anything else (e.g.
|
|
// CANCELED) is terminal but not a completed payment.
|
|
switch tc.Status {
|
|
case "PENDING", "IN_PROGRESS", "CANCEL_REQUESTED":
|
|
return nil, ErrCheckoutPending
|
|
}
|
|
return nil, fmt.Errorf("square: checkout %s is %s (not COMPLETED)", checkoutID, tc.Status)
|
|
}
|
|
if len(tc.PaymentIDs) == 0 {
|
|
return nil, fmt.Errorf("square: checkout %s has no payment IDs", checkoutID)
|
|
}
|
|
if len(tc.PaymentIDs) > 1 {
|
|
// Terminal checkouts are expected to produce a single payment. If a
|
|
// future checkout ever returns multiple, record only the first and
|
|
// surface the rest — silently dropping payments[1:] would under-record
|
|
// money taken at Square.
|
|
log.Printf("WARN: checkout %s returned %d payments — recording only the first (%s), manual review advised", checkoutID, len(tc.PaymentIDs), tc.PaymentIDs[0])
|
|
}
|
|
var payResp sqGetPaymentResponse
|
|
if err := hc.doJSON(ctx, http.MethodGet, "/v2/payments/"+tc.PaymentIDs[0], nil, &payResp); err != nil {
|
|
return nil, err
|
|
}
|
|
return paymentFromSquare(&payResp.Payment), nil
|
|
}
|
|
|
|
func getPaymentHTTP(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
|
return getPaymentHTTPWithClient(ctx, paymentID, newHTTPClient())
|
|
}
|
|
|
|
func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpClient) (*PaymentResult, error) {
|
|
if !validSquareID(paymentID) {
|
|
return nil, fmt.Errorf("square: invalid payment id %q", paymentID)
|
|
}
|
|
var resp sqGetPaymentResponse
|
|
if err := hc.doJSON(ctx, http.MethodGet, "/v2/payments/"+paymentID, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return paymentFromSquare(&resp.Payment), nil
|
|
}
|
|
|
|
func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
|
|
return replayPaymentByKeyHTTPWithClient(ctx, snapshotJSON, newHTTPClient())
|
|
}
|
|
|
|
// replayPaymentByKeyHTTPWithClient re-issues POST /v2/payments with an
|
|
// IDENTICAL body to the original charge: the square_request_snapshot stored on
|
|
// the pending row is the verbatim CreatePaymentReq JSON captured at charge
|
|
// time, and buildCreatePaymentBody reproduces the exact wire request the
|
|
// original charge sent (source_id, key, amount, customer_id, reference_id,
|
|
// note, buyer_email_address, verification_token, tip, location). Square's
|
|
// idempotency guarantee returns the ORIGINAL payment for a retained key (never
|
|
// a second charge); a key Square no longer retains makes Square attempt a real
|
|
// charge with the (expired/used) source, which Square rejects with a definitive
|
|
// 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never happened).
|
|
// A replay body missing fields the original charge carried would return
|
|
// IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever,
|
|
// so the snapshot is never reconstructed from partial row data.
|
|
//
|
|
// IDEMPOTENCY-KEY RETENTION ASSUMPTION (~24h). The identical-body replay's
|
|
// safety relies on Square retaining idempotency keys long enough that a
|
|
// stale-pending row's replay still dedups to the original charge. This
|
|
// codebase assumes ~24 hours, and the stale-pending sweeps (defined in
|
|
// handlers/payments, which own those constants) guard on that window with
|
|
// safety margins: the keyed reconcile cutoff stalePendingKeyedAge (22h,
|
|
// sweep.go), the pass-2 blind-fail cutoff stalePendingPaymentAge (24h,
|
|
// sweep.go), and the refund age guard stalePendingRefundAge (23h, refunds.go).
|
|
// Square's public docs do NOT state the exact retention window — the
|
|
// Idempotency guide
|
|
// (https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency)
|
|
// documents key semantics (same-key retry returns the original response;
|
|
// same-key + different body returns an error) but not how long keys are held.
|
|
// The assumption is SAFE IN BOTH DIRECTIONS:
|
|
// - If Square retains keys SHORTER than 24h, the pass-2 sweep at 24h replays
|
|
// a key Square no longer holds → Square attempts a REAL charge with the
|
|
// (expired/used) stored source → definitive 4xx → the row is failed as
|
|
// ErrReplayKeyNotRetained (proof the charge never happened). The rescue
|
|
// degrades, it never double-charges.
|
|
// - If Square retains keys LONGER than 24h, the sweeps simply hold rows a
|
|
// little longer before giving up — no money moves incorrectly.
|
|
//
|
|
// Revisit this comment and the sweep constants if Square ever documents a
|
|
// different window.
|
|
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
|
|
var req CreatePaymentReq
|
|
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
|
// An unparsable snapshot must never look like proof of no charge — the
|
|
// sweep leaves such rows pending for manual reconciliation.
|
|
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
|
|
}
|
|
if req.SourceID == "" || req.IdempotencyKey == "" {
|
|
return nil, fmt.Errorf("square: replay-by-key snapshot missing source_id/idempotency_key")
|
|
}
|
|
if req.Currency == "" {
|
|
req.Currency = gbpCurrency
|
|
}
|
|
var resp sqCreatePaymentResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", buildCreatePaymentBody(req, hc), &resp); err != nil {
|
|
if replayErrorProvesNoCharge(err) {
|
|
return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
return paymentFromSquare(&resp.Payment), nil
|
|
}
|
|
|
|
// replayErrorProvesNoCharge reports whether a ReplayPaymentByKey error
|
|
// definitively proves Square has no payment under the key. The replay carries
|
|
// the ORIGINAL source_id (identical-body retry), so a retained key makes Square
|
|
// return the original payment (HTTP 2xx); any definitive 4xx business rejection
|
|
// must therefore be Square attempting a REAL charge with the expired/used
|
|
// source — which can never succeed, so the charge never happened under that
|
|
// key. IDEMPOTENCY_KEY_REUSED is the exception: it can only occur when the
|
|
// stored source differs from the original charge's source (a data bug), so it
|
|
// proves NOTHING about whether the original charge landed — it is AMBIGUOUS,
|
|
// never proof of no charge. Auth (401/403 — affects every Square call, must
|
|
// not fail rows), rate-limit (429 — transient), 5xx and transport errors are
|
|
// ambiguous by definition.
|
|
func replayErrorProvesNoCharge(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
|
return false
|
|
}
|
|
switch ErrorStatusCode(err) {
|
|
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests:
|
|
return false
|
|
}
|
|
status := ErrorStatusCode(err)
|
|
return status >= 400 && status < 500
|
|
}
|
|
|
|
// squareAPIError wraps a formatted Square API error while exposing the
|
|
// structured Square error code and the HTTP status code so callers can
|
|
// classify definitive business rejections (e.g. ErrRefundDeclined) vs
|
|
// ambiguous transport/server errors, and distinguish 400/401/429/500
|
|
// structurally without parsing the message. Category/Field are captured from
|
|
// Square's error payload (the wire error carries them; they were previously
|
|
// dropped).
|
|
type squareAPIError struct {
|
|
Code string
|
|
Detail string
|
|
Category string
|
|
Field string
|
|
StatusCode int
|
|
err error
|
|
}
|
|
|
|
func (e *squareAPIError) Error() string { return e.err.Error() }
|
|
func (e *squareAPIError) Unwrap() error { return e.err }
|
|
|
|
// ErrorCode returns the Square error Code carried by err when err (or any
|
|
// error it wraps) is a *squareAPIError — i.e. a structured error parsed from
|
|
// Square's error response body. It returns "" for non-Square errors so callers
|
|
// can classify charge failures structurally instead of substring-matching the
|
|
// message. This is the exported Code accessor for the error type (a direct
|
|
// `(*SquareError).Code()` method is impossible: SquareError already declares a
|
|
// field named Code, and Go forbids a method colliding with a struct field).
|
|
func ErrorCode(err error) string {
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
return sqErr.Code
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ErrorDetail returns the Square error Detail carried by err when err (or any
|
|
// error it wraps) is a *squareAPIError, and "" otherwise.
|
|
func ErrorDetail(err error) string {
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
return sqErr.Detail
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ErrorStatusCode returns the HTTP status code of the Square response carried
|
|
// by err when err (or any error it wraps) is a *squareAPIError, and 0
|
|
// otherwise. Callers can distinguish 400/401/429/500 structurally instead of
|
|
// substring-matching "HTTP 400" etc.
|
|
func ErrorStatusCode(err error) int {
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
return sqErr.StatusCode
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ErrorCategory returns the Square error Category carried by err when err (or
|
|
// any error it wraps) is a *squareAPIError, and "" otherwise. This is the
|
|
// exported Category accessor for the error type (a direct
|
|
// `(*SquareError).Category()` method is impossible: SquareError already
|
|
// declares a field named Category, and Go forbids a method colliding with a
|
|
// struct field).
|
|
func ErrorCategory(err error) string {
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
return sqErr.Category
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ErrorField returns the Square error Field carried by err when err (or any
|
|
// error it wraps) is a *squareAPIError, and "" otherwise.
|
|
func ErrorField(err error) string {
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
return sqErr.Field
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsNotFound reports whether err is a Square "not found" condition: the
|
|
// structured NOT_FOUND error code, an HTTP 404 response status, or a plain
|
|
// non-JSON 404 body (doJSON's fallback error message embeds "HTTP 404").
|
|
// Callers use this to treat already-completed/unknown Square resources as
|
|
// idempotent no-ops instead of substring-matching the error message.
|
|
func IsNotFound(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
return sqErr.Code == "NOT_FOUND" || sqErr.StatusCode == http.StatusNotFound
|
|
}
|
|
return strings.Contains(err.Error(), "HTTP 404")
|
|
}
|
|
|
|
// definitivePaymentCodes are Square CreatePayment error codes that mean the
|
|
// charge can NEVER succeed as-is. This is the SINGLE authoritative list of
|
|
// definitive payment rejections — the exported
|
|
// IsDefinitivePaymentError/square.ErrorCode accessors classify through it, the
|
|
// dev mock (square_dev.go) emits the same codes so dev parity holds, and the
|
|
// payments package (handlers/payments/till.go) is being migrated to delegate
|
|
// to square.IsDefinitivePaymentError instead of its own legacy list. Do not
|
|
// maintain a second decline-code list anywhere else: add codes HERE.
|
|
//
|
|
// The list is the COMPLETE union of Square's decline/expiry codes (including
|
|
// the specific CARD_DECLINED_* decline reasons) and — critically for SCA —
|
|
// the buyer-verification codes (CARD_DECLINED_VERIFICATION_REQUIRED,
|
|
// VERIFICATION_TOKEN_EXPIRED, VERIFICATION_TOKEN_INVALID,
|
|
// CVV_VERIFICATION_REQUIRED, ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN,
|
|
// MISSING_VERIFICATION_TOKEN): those mean the user must re-verify (3DS/SCA)
|
|
// or re-tokenize the card, NOT that the same request should be retried. A
|
|
// same-request retry with the same source/token can never succeed, so the
|
|
// failure is DEFINITIVE.
|
|
var definitivePaymentCodes = map[string]bool{
|
|
"CARD_DECLINED": true,
|
|
"CARD_EXPIRED": true,
|
|
"INVALID_EXPIRATION": true,
|
|
"INVALID_EXPIRATION_DATE": true,
|
|
"CARD_NOT_SUPPORTED": true,
|
|
"VERIFY_CVV_FAILURE": true,
|
|
"AVS_FAILURE": true,
|
|
"PAYMENT_CARD_DECLINED": true,
|
|
"GENERIC_DECLINE": true,
|
|
"INSUFFICIENT_FUNDS": true,
|
|
"ADDRESS_VERIFICATION_FAILURE": true,
|
|
"TRANSACTION_LIMIT": true,
|
|
// Square's specific CARD_DECLINED_* decline reasons — each is a definitive
|
|
// rejection of the charge as-is (the issuer declined for a specific
|
|
// reason), so retrying the same request is pointless.
|
|
"CARD_DECLINED_CALL_ISSUER": true,
|
|
"CARD_DECLINED_AVS_FAILURE": true,
|
|
"CARD_DECLINED_CVV_FAILURE": true,
|
|
"CARD_DECLINED_INSUFFICIENT_FUNDS": true,
|
|
"CARD_DECLINED_INVALID_ACCOUNT": true,
|
|
"CARD_DECLINED_INVALID_AMOUNT": true,
|
|
"CARD_DECLINED_CARD_EXPIRED": true,
|
|
"CARD_DECLINED_PIN_RETRIES_EXCEEDED": true,
|
|
// SCA / buyer-verification codes — the buyer must re-verify or the card be
|
|
// re-tokenized before the charge can succeed; retrying is pointless.
|
|
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
|
|
"VERIFICATION_TOKEN_EXPIRED": true,
|
|
"VERIFICATION_TOKEN_INVALID": true,
|
|
"CVV_VERIFICATION_REQUIRED": true,
|
|
"ADDRESS_VERIFICATION_REQUIRED": true,
|
|
"MISSING_PIN": true,
|
|
"MISSING_VERIFICATION_TOKEN": true,
|
|
}
|
|
|
|
// IsDefinitivePaymentError reports whether err is a definitive CreatePayment
|
|
// rejection (declined card, expired source, or an SCA/verification failure the
|
|
// buyer must resolve) rather than an ambiguous transport/server error. Handlers
|
|
// use this to avoid retrying a request that can never succeed as-is.
|
|
func IsDefinitivePaymentError(err error) bool {
|
|
return definitivePaymentCodes[ErrorCode(err)]
|
|
}
|
|
|
|
// Definitive Square refund rejection codes — the refund was declined and can
|
|
// never succeed, so retrying is pointless and the refund record should be
|
|
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
|
// callers leave the refund 'pending' for a scheduler retry. Codes match
|
|
// Square's documented Refunds error list (REFUND_DECLINED, REFUND_AMOUNT_INVALID,
|
|
// PAYMENT_NOT_REFUNDABLE). REFUND_AMOUNT_INVALID is special: Square returns it
|
|
// BOTH for a genuinely invalid refund amount AND for an already-refunded
|
|
// payment, so refundPaymentHTTP reconciles it via PaymentWasRefunded before
|
|
// classifying (an existing refund → ErrRefundAlreadyProcessed, otherwise
|
|
// ErrRefundDeclined). REFUND_ALREADY_PENDING maps to ErrRefundAlreadyProcessed
|
|
// (money in flight); PAYMENT_ALREADY_REFUNDED is no longer emitted by Square
|
|
// but is kept as a defensive fallback for the same outcome.
|
|
var definitiveRefundCodes = map[string]bool{
|
|
"REFUND_DECLINED": true,
|
|
"REFUND_AMOUNT_INVALID": true,
|
|
"PAYMENT_NOT_REFUNDABLE": true,
|
|
}
|
|
|
|
func refundPaymentHTTP(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
|
return refundPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
|
}
|
|
|
|
func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *httpClient) (*RefundResult, error) {
|
|
body := sqRefundPaymentRequest{
|
|
PaymentID: req.PaymentID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
AmountMoney: sqMoney{Amount: req.Amount, Currency: gbpCurrency},
|
|
Reason: req.Reason,
|
|
}
|
|
var resp sqRefundPaymentResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
|
|
var sqErr *squareAPIError
|
|
if errors.As(err, &sqErr) {
|
|
switch sqErr.Code {
|
|
case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING":
|
|
// Money is in flight or has already moved at Square — never
|
|
// mark 'failed' (that would let the guard over-refund).
|
|
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
|
|
case "REFUND_AMOUNT_INVALID":
|
|
// Square returns REFUND_AMOUNT_INVALID both for a genuinely
|
|
// invalid refund amount AND for an already-refunded payment
|
|
// (Square no longer emits PAYMENT_ALREADY_REFUNDED). Reconcile
|
|
// against the refund list to tell the two apart: money already
|
|
// moved → ErrRefundAlreadyProcessed (resolve 'completed');
|
|
// nothing moved → ErrRefundDeclined (mark 'failed', never
|
|
// retry). The reconciliation is amount-aware: only an EXACT-
|
|
// amount COMPLETED refund proves THIS requested amount already
|
|
// moved. A smaller partial refund does NOT cover the requested
|
|
// amount — resolving the row 'completed' against a partial
|
|
// refund would claim the full amount was returned when only
|
|
// part of it was, permanently blocking the remaining refund
|
|
// (the over-refund guard excludes completed rows). If the
|
|
// reconciliation itself fails, return the error unwrapped so
|
|
// the caller keeps the refund pending rather than making a
|
|
// money decision on partial data.
|
|
exactRefund, rErr := paymentRefundedExactlyWithClient(ctx, req.PaymentID, req.Amount, hc)
|
|
if rErr != nil {
|
|
return nil, rErr
|
|
}
|
|
if exactRefund {
|
|
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
|
|
}
|
|
if definitiveRefundCodes[sqErr.Code] {
|
|
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
return refundFromSquare(&resp.Refund), nil
|
|
}
|
|
|
|
// PaymentWasRefunded reports whether Square holds any refund for the payment
|
|
// (status COMPLETED, APPROVED, or PENDING). It is the reconciliation source for
|
|
// deciding whether a REFUND_AMOUNT_INVALID rejection means "already refunded"
|
|
// (money has already moved) vs "amount invalid" (nothing happened).
|
|
func PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
|
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
|
|
}
|
|
|
|
func paymentWasRefundedWithClient(ctx context.Context, paymentID string, hc *httpClient) (bool, error) {
|
|
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, r := range refunds {
|
|
switch r.Status {
|
|
case "COMPLETED", "APPROVED", "PENDING":
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// paymentRefundedExactlyWithClient reports whether Square holds a COMPLETED
|
|
// refund for the EXACT amount requested. Unlike paymentWasRefundedWithClient
|
|
// (any refund counts), an exact-match is required so a REFUND_AMOUNT_INVALID
|
|
// rejection can only resolve to "already refunded" when THIS requested amount
|
|
// provably moved — a partial refund does not cover it.
|
|
func paymentRefundedExactlyWithClient(ctx context.Context, paymentID string, amount int64, hc *httpClient) (bool, error) {
|
|
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, r := range refunds {
|
|
if r.Status == "COMPLETED" && r.Amount == amount {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
|
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
|
|
}
|
|
|
|
func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime time.Time, hc *httpClient) ([]RefundResult, error) {
|
|
// begin_time is optional at Square; a ZERO time must OMIT the param
|
|
// entirely. Formatting the zero time would send Square a year-1 timestamp
|
|
// (0001-01-01T00:00:00Z) that Square may reject. The reconciliation callers
|
|
// (PaymentWasRefunded / paymentRefundedExactlyWithClient) pass time.Time{}
|
|
// deliberately — they want ALL refunds, so the param must be absent, not
|
|
// "before year 1".
|
|
base := "/v2/refunds?limit=100"
|
|
if !beginTime.IsZero() {
|
|
base = "/v2/refunds?begin_time=" + url.QueryEscape(beginTime.UTC().Format(time.RFC3339)) + "&limit=100"
|
|
}
|
|
path := base
|
|
results := []RefundResult{}
|
|
for page := 0; page < 20; page++ {
|
|
var resp sqListRefundsResponse
|
|
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range resp.Refunds {
|
|
r := &resp.Refunds[i]
|
|
if r.PaymentID == paymentID {
|
|
results = append(results, *refundFromSquare(r))
|
|
}
|
|
}
|
|
if resp.Cursor == "" {
|
|
return results, nil
|
|
}
|
|
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
|
|
}
|
|
// 20 pages fetched and a cursor is still present — the infinite-loop
|
|
// guard. Returning the partial results would be silently wrong for the
|
|
// money-sensitive reconcile caller: a refund sitting in the truncated tail
|
|
// would look like "no COMPLETED refund exists", letting the sweep mark the
|
|
// rows failed and over-refund. Error instead — reconcileRefundAtSquare
|
|
// treats any error as "leave the rows pending, retry later", so no money
|
|
// decision is made on partial data.
|
|
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite-loop guard) — refusing partial results for payment %s", paymentID)
|
|
}
|
|
|
|
func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
|
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, customerID, newHTTPClient())
|
|
}
|
|
|
|
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, customerID string, hc *httpClient) (*CardOnFile, error) {
|
|
// PCI-DSS parity with the dev mock: source_id must be a cnon: nonce or
|
|
// ccof: card ID. A raw PAN is refused client-side before it reaches Square.
|
|
if !isTokenLike(cardToken) {
|
|
return nil, fmt.Errorf("square: invalid card token %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
|
|
}
|
|
|
|
// Deterministic idempotency key derived from user + card (not time-based)
|
|
// so that retries with the same details don't create duplicate cards.
|
|
// SHA-256 hash prevents recovering the card token from the key itself.
|
|
// The 38-hex tail (2-char margin: 5 prefix chars + 38 hex = 43, under the
|
|
// 45-char limit via MaxIdempotencyKeyLength) is byte-identical to the
|
|
// historical fixed slice — Square's limit for /v2/cards, /v2/payments, and
|
|
// /v2/refunds is 45 chars (64 only for /v2/terminals/checkouts).
|
|
ikHash := sha256.Sum256([]byte(userID + "|" + cardToken))
|
|
body := sqCreateCardRequest{
|
|
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-2-len("card-")],
|
|
SourceID: cardToken,
|
|
Card: sqCardPayload{
|
|
// reference_id is Square's free-form client reference, used to link
|
|
// the card to the local user for client-side filtering. customer_id
|
|
// is sent when the app has provisioned a Square customer for the
|
|
// user (Square marks customer_id Required on the Card object for
|
|
// saved-card flows) and omitted otherwise.
|
|
ReferenceID: userID,
|
|
CustomerID: customerID,
|
|
},
|
|
}
|
|
var resp sqCreateCardResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/cards", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return cardFromSquare(&resp.Card, userID), nil
|
|
}
|
|
|
|
func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error) {
|
|
return getCardsOnFileHTTPWithClient(ctx, userID, newHTTPClient())
|
|
}
|
|
|
|
func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpClient) ([]CardOnFile, error) {
|
|
// Filter by reference_id natively: Square's List Cards API supports the
|
|
// reference_id query param, and cards are created with reference_id = the
|
|
// local user ID. customer_id is not used for the filter because a user may
|
|
// have no provisioned Square customer. List Cards has NO limit param and
|
|
// pages at 25 cards per page, so loop on the cursor to avoid silently
|
|
// truncating a large saved-card list (N-10). The 20-page guard therefore
|
|
// caps out at 500 cards before warning.
|
|
var cards []CardOnFile
|
|
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
|
truncated := false
|
|
for page := 0; page < 20; page++ {
|
|
var resp sqListCardsResponse
|
|
if err := hc.doJSON(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range resp.Cards {
|
|
cards = append(cards, *cardFromSquare(&resp.Cards[i], userID))
|
|
}
|
|
if resp.Cursor == "" {
|
|
break
|
|
}
|
|
if page == 19 {
|
|
truncated = true
|
|
}
|
|
path = "/v2/cards?reference_id=" + url.QueryEscape(userID) + "&cursor=" + url.QueryEscape(resp.Cursor)
|
|
}
|
|
if truncated {
|
|
// 20 pages fetched and a cursor is still present — the infinite-loop
|
|
// guard. Unlike listRefunds (where partial data can drive an over-refund
|
|
// decision and therefore ERRORS), cards are deliberately returned as
|
|
// partial: GetCardsOnFile has no money-sensitive caller, and erroring
|
|
// would break a "show my cards" feature for a user with >500 saved
|
|
// cards. The correctness gap (oldest card silently missing) is accepted
|
|
// and surfaced loudly in the log so it is not a silent truncation.
|
|
log.Printf("[SQUARE] list cards for %s exceeded 20 pages (infinite-loop guard) — TRUNCATED: returning partial results: %d of 500+ cards", userID, len(cards))
|
|
}
|
|
if cards == nil {
|
|
cards = []CardOnFile{}
|
|
}
|
|
return cards, nil
|
|
}
|
|
|
|
func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
|
if !validCardID(cardID) {
|
|
// cardID is a DB-stored ccof: token — never echo the full value in an
|
|
// error (handlers log it verbatim).
|
|
return fmt.Errorf("square: invalid card id %s", tokenPrefix(cardID))
|
|
}
|
|
hc := newHTTPClient()
|
|
var resp sqDisableCardResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/cards/"+cardID+"/disable", nil, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deleteCustomerHTTP(ctx context.Context, customerID string) error {
|
|
return deleteCustomerHTTPWithClient(ctx, customerID, newHTTPClient())
|
|
}
|
|
|
|
func deleteCustomerHTTPWithClient(ctx context.Context, customerID string, hc *httpClient) error {
|
|
if !validSquareID(customerID) {
|
|
return fmt.Errorf("square: invalid customer id %q", customerID)
|
|
}
|
|
if err := hc.doJSON(ctx, http.MethodDelete, "/v2/customers/"+customerID, nil, nil); err != nil {
|
|
// Square returns 404 / NOT_FOUND when the customer is already deleted —
|
|
// that is a no-op, not a failure (idempotent re-deletion on GDPR erasure).
|
|
if IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResult, error) {
|
|
return createCustomerHTTPWithClient(ctx, name, email, newHTTPClient())
|
|
}
|
|
|
|
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
|
|
// Deterministic idempotency key derived from the email (not time-based)
|
|
// so retries with the same email don't create duplicate customers. SHA-256
|
|
// prevents recovering the email from the key. The 35-hex tail (1-char
|
|
// margin: 9 prefix chars + 35 hex = 44, under the 45-char limit via
|
|
// MaxIdempotencyKeyLength) is byte-identical to the historical fixed slice —
|
|
// Square's limit for /v2/cards, /v2/payments, and /v2/refunds is 45 chars
|
|
// (64 only for /v2/terminals/checkouts).
|
|
ikHash := sha256.Sum256([]byte(email))
|
|
body := sqCreateCustomerRequest{
|
|
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-1-len("customer-")],
|
|
EmailAddress: email,
|
|
GivenName: name,
|
|
}
|
|
var resp sqCreateCustomerResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/customers", body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &CustomerResult{
|
|
ID: resp.Customer.ID,
|
|
Email: resp.Customer.EmailAddress,
|
|
CreatedAt: resp.Customer.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func cancelCheckoutHTTP(ctx context.Context, checkoutID string) error {
|
|
return cancelCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
|
}
|
|
|
|
func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) error {
|
|
if !validSquareID(checkoutID) {
|
|
return fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
|
}
|
|
var resp sqTerminalCheckoutResponse
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts/"+checkoutID+"/cancel", nil, &resp); err != nil {
|
|
// Square returns 404 / NOT_FOUND when the checkout is already
|
|
// completed or canceled — that is a no-op, not a failure.
|
|
if IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Conversion helpers — Square JSON → domain types.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// paymentFromSquare maps Square's Payment object into the domain PaymentResult.
|
|
// Status is copied FAITHFULLY (COMPLETED/APPROVED/PENDING/FAILED/CANCELED are
|
|
// all surfaced verbatim) — the client deliberately does NOT turn a non-terminal
|
|
// status into an error. doJSON already parses 2xx bodies without dropping them,
|
|
// so a 200-with-FAILED payment reaches the caller with nil error and Status
|
|
// "FAILED". This is intentional: the payments sweep reconciles by id via
|
|
// GetPayment/ReplayPaymentByKey and SWITCHES on pr.Status
|
|
// (reconcileStalePaymentAtSquare marks a FAILED/CANCELED payment
|
|
// definitively-failed, leaves APPROVED/PENDING pending). If the client returned
|
|
// an error for a FAILED payment, the sweep would classify it as an ambiguous
|
|
// "leave pending" instead — strictly worse. Handlers must therefore check
|
|
// Status, not nil-error alone; the mock's ForcePaymentStatus toggle exists so a
|
|
// status-blind handler regression is exercisable in dev.
|
|
func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
|
r := &PaymentResult{
|
|
ID: sq.ID,
|
|
Status: sq.Status,
|
|
Amount: sq.TotalMoney.Amount,
|
|
ReceiptURL: sq.ReceiptURL,
|
|
ReceiptNumber: sq.ReceiptNumber,
|
|
SquarePayID: sq.ID,
|
|
BuyerEmail: sq.BuyerEmail,
|
|
CustomerID: sq.CustomerID,
|
|
LocationID: sq.LocationID,
|
|
CreatedAt: sq.CreatedAt,
|
|
UpdatedAt: sq.UpdatedAt,
|
|
OrderID: sq.OrderID,
|
|
ReferenceID: sq.ReferenceID,
|
|
}
|
|
if sq.TipMoney != nil {
|
|
r.TipAmount = sq.TipMoney.Amount
|
|
}
|
|
// Square reports processing_fee amounts as NEGATIVE (money withheld from the
|
|
// gross charge) or zero — never positive. PaymentResult.Fees is the POSITIVE
|
|
// magnitude the handlers store into p.fees (accounting sums a positive
|
|
// total_square_fees), so the raw negative Square amounts are negated at this
|
|
// boundary. The dev mock fabricates the same positive magnitude directly, so
|
|
// mock and real client agree on the sign convention (see
|
|
// TestProcessingFeeSign_Parity_MockAndRealClientAgree).
|
|
for _, f := range sq.ProcessingFee {
|
|
r.Fees += -f.AmountMoney.Amount
|
|
}
|
|
if sq.CardDetails != nil {
|
|
cd := sq.CardDetails
|
|
r.EntryMethod = cd.EntryMethod
|
|
r.CVVStatus = cd.CVVStatus
|
|
r.AVSStatus = cd.AVSStatus
|
|
r.CardBrand = cd.Card.CardBrand
|
|
r.CardLast4 = cd.Card.Last4
|
|
// exp_month/exp_year/fingerprint ride on the card object. When the
|
|
// card object is empty (ID == "") they are meaningless, so leave ALL of
|
|
// them nil — nil then consistently means "no card details present"
|
|
// (previously ExpMonth/ExpYear became 0/0 pointers while Fingerprint
|
|
// stayed nil, which was inconsistent).
|
|
if cd.Card.ID != "" {
|
|
expMonth := cd.Card.ExpMonth
|
|
expYear := cd.Card.ExpYear
|
|
r.ExpMonth = &expMonth
|
|
r.ExpYear = &expYear
|
|
r.CardFingerprint = cd.Card.Fingerprint
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func checkoutFromSquare(sq *sqTerminalCheckout) *CheckoutResult {
|
|
return &CheckoutResult{
|
|
ID: sq.ID,
|
|
Status: sq.Status,
|
|
AmountMoney: sq.AmountMoney.Amount,
|
|
Currency: sq.AmountMoney.Currency,
|
|
ReferenceID: sq.ReferenceID,
|
|
Note: sq.Note,
|
|
PaymentIDs: sq.PaymentIDs,
|
|
Deadline: sq.Deadline,
|
|
CreatedAt: sq.CreatedAt,
|
|
UpdatedAt: sq.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func refundFromSquare(sq *sqRefund) *RefundResult {
|
|
return &RefundResult{
|
|
ID: sq.ID,
|
|
Status: sq.Status,
|
|
Amount: sq.AmountMoney.Amount,
|
|
PaymentID: sq.PaymentID,
|
|
LocationID: sq.LocationID,
|
|
Reason: sq.Reason,
|
|
CreatedAt: sq.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func cardFromSquare(sq *sqCard, userID string) *CardOnFile {
|
|
return &CardOnFile{
|
|
ID: sq.ID,
|
|
CardID: sq.ID,
|
|
Brand: sq.CardBrand,
|
|
Last4: sq.Last4,
|
|
ExpMonth: sq.ExpMonth,
|
|
ExpYear: sq.ExpYear,
|
|
Fingerprint: sq.Fingerprint,
|
|
CardholderName: sq.CardholderName,
|
|
CustomerID: sq.CustomerID,
|
|
ReferenceID: sq.ReferenceID,
|
|
Enabled: sq.Enabled,
|
|
Version: sq.Version,
|
|
CreatedAt: sq.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|