The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails, cardId)) returns a one-time tokenize-result that must be sent as the charge SOURCE (source_id), not a separate verification_token. - square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an independently assembled copy of buildCreatePaymentBody) so it accepts exactly the request shape the real client emits. SimulateSavedCardVerificationRequired now demands SCA on every saved-card charge in both wire shapes: (a) a genuine tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id + customer_id is ACCEPTED (the token IS the buyer verification); a RAW card.tokenize() nonce in the tokenize-result slot is REJECTED CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement point that stops the forged shape); (b) legacy ccof: + verification_token is kept for backward-compat. - square_http_client.go: byte-identical body assembly shared with the mock, so TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the real client emit identical CreatePayment bodies (a wire drift fails the test before reaching prod).
1330 lines
54 KiB
Go
1330 lines
54 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
|
|
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 ---
|
|
|
|
// 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) {
|
|
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 ""
|
|
}
|