Files
Crussell/backend/internal/square/square_http_client.go
T
popertots 5cc5a7f6d2 fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change
Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
  no-booking (gift-card purchase) payment are rejected with a clear message
  in the direct handler AND never re-issued by the sweep-resume path
  (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
  advisory lock (pending-row reuse fixes lost-response double-charge;
  completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
  recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
  COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
  left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
  (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
  ambiguous → leave pending) instead of blind-failing a possibly-live
  checkout. recordUntrackedTerminalPayment re-checks the booking status
  (FOR UPDATE) and refuses to record on a cancelled booking, inserting a
  critical_payment_log admin notification instead. Till-sale post-charge
  UPDATE now requires status='pending' (no resurrection of a clawed-back sale).

Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
  ref + Square iframe survive the loyalty/tokenize awaits) — new-card
  payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
  re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
  refetches the booking and reconciles depositPaid so the confirmation gate
  opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.

Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
  completed terminal checkout's payment resolvable by id.

Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
  scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
  known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
  fails at config time when missing.

Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
  blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
  in both by-key and by-id paths), resolveChargeSource Square-failure branches,
  structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
  deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
  findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
  dispute.evidence / terminal.checkout dispatch.

Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
  (previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
  gained the missing build tag.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
2026-08-22 00:34:49 +01:00

1117 lines
43 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
)
// ---------------------------------------------------------------------------
// 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
if env == "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},
}
}
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 ---
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"`
BuyerEmailAddress string `json:"buyer_email_address,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 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.
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,
}
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.
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.
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.
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")
}
// 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); note PAYMENT_ALREADY_REFUNDED and
// REFUND_ALREADY_PENDING are intentionally absent — money is in flight or has
// moved, so they map to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
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) && definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if errors.As(err, &sqErr) && (sqErr.Code == "PAYMENT_ALREADY_REFUNDED" || sqErr.Code == "REFUND_ALREADY_PENDING") {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
}
return nil, err
}
return refundFromSquare(&resp.Refund), 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) {
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.
// Truncated to ≤45 chars — Square's idempotency-key limit is 45 chars for
// /v2/cards, /v2/payments, and /v2/refunds (64 only for
// /v2/terminals/checkouts).
ikHash := sha256.Sum256([]byte(userID + "|" + cardToken))
body := sqCreateCardRequest{
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
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. Truncated to ≤45 chars —
// Square's idempotency-key limit is 45 chars for /v2/cards, /v2/payments,
// and /v2/refunds (64 only for /v2/terminals/checkouts).
ikHash := sha256.Sum256([]byte(email))
body := sqCreateCustomerRequest{
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
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 ""
}