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 "" }