fix: review round 6 — P0 deposit charge, idempotency rotation, dev-safety guard, 2FA/webhook hardening

Sixth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). QA FAILED the deposit-required new-card flow; the P0 root
cause was backend + frontend, now fixed. All 20 packages green.

P0 money-safety:
- Deposit-required bookings now actually charge the deposit on new-card
  payment. Two-part fix: (1) CreateBookingHandler re-reads the
  trigger-maintained total_amount/total_duration_minutes from the DB after the
  booking_services insert (the INSERT..RETURNING row predates the recalc
  trigger, so TotalAmount serialized as 0 and DepositPaid computed TRUE on an
  unpaid booking — the frontend gate trusted deposit_paid:true, never charged,
  and confirmed the booking with zero payment rows); (2) BookingFlow.svelte
  gates the confirmation view on depositPaid and guards against re-creating a
  booking on retry. Regression test
  TestBookings_Create_DepositPaidFalseOnUnpaidBooking.

Payments (idempotency + money):
- deriveBookingPaymentIdempotencyKey: no-client-key fallback now advances a
  sequence for repeatable types (partial) and rotates past refunded completed
  rows, so refund-then-repay and equal-amount partials diverge onto distinct
  keys; an un-refunded completed row keeps its key (double-charge protection
  holds). Dedup hits on refunded rows now 409, never stale success.
- chargeFailureStatus default is 503 (ambiguous), never 402; table test.
- Flaky TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance fixed
  (ORDER BY payment_type).
- resolveChargeSource: orphaned card-on-file disabled via DeleteCardOnFile
  when SaveCardForUser fails (best-effort, redacted log); retry path preserved.

Square client:
- Dev builds HARD-FAIL (panic) on SQUARE_ENVIRONMENT=production without
  SQUARE_ALLOW_REAL_API=1; sandbox routes with a loud banner.
- Mock fault-injection FailAfterCommit (commit-then-5xx) exercises the exact
  lost-response same-key retry; SimulateCardTokenUsed; 45-char idempotency-key
  cap parity; SquareEnvironment/SquareLocationID shared env helpers used by
  the sweep (env contract no longer comment-only).
- listRefunds truncation now errors (money-sensitive reconcile retries
  instead of over-refunding); getCardsOnFile truncation loudly logged.

Webhooks + 2FA:
- square-environment header checked fail-closed (403) when configured env is
  production/sandbox; dispatch DB work bounded by 30s timeout contexts.
- 2FA codes HMAC-SHA256 pepper'd (TWO_FACTOR_PEPPER) with legacy-hash
  migration + upgrade-on-verify; disable-flow mint cooldown (1/min, 429) caps
  the brute-force loop; in-lockout records never LRU-evicted.

Repo hygiene:
- env-docs CI gate green again (FRONTEND_ORIGIN + SQUARE_ALLOW_REAL_API +
  TWO_FACTOR_PEPPER documented; Vite DEV built-in allowlisted).
- Dead square_deposits schema dropped; obsidian/README/legal-page drift fixed
  (consumeradvice.scot signposting, CORS allowlist, p11 R3/P13, T1).
- 2FA disable residual documented; P6 email/SMS delivery and P12 sandbox
  smoke test remain the pre-go-live gates.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs
gate OK, live deposit-required flow re-verified end-to-end (deposit £11
charged, square_payment_id recorded).
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 39cc42b239
commit 67cf5b9a45
31 changed files with 1946 additions and 192 deletions
+133 -16
View File
@@ -19,6 +19,28 @@ package square
// test as evidence of how prod treats a retained key after a restart. If a
// test needs retained-key behaviour, it must re-seed the payment under the key
// into the same mock instance (see TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued).
//
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateCardTokenUsed) that let dev/tests drive Square
// failure modes that are otherwise only reachable against the real API.
// FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
// and source in the ledgers exactly like a successful charge) and THEN returns
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
// key + SAME source dedups to the committed payment, proving no double charge.
// SimulateCardTokenUsed simulates Square's CARD_TOKEN_USED rejection of a card
// token (cnon: nonce) reused after a previous save.
//
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
// the real PRODUCTION Square API on an env-string match alone — a typo'd or
// leftover SQUARE_ENVIRONMENT=production in a dev shell would otherwise create
// REAL charges from test bookings. NewDevClient therefore HARD-FAILS (panics
// with errDevRealAPIRequiresOverride) when SQUARE_ENVIRONMENT=production
// unless the explicit override SQUARE_ALLOW_REAL_API=1 is set, and logs a loud
// banner before routing a dev build to the SANDBOX. The non-dev build
// (square.go, `//go:build !dev`) is untouched: NewProdClient always uses the
// real client path selected by the normal non-dev wiring.
import (
"context"
@@ -81,6 +103,26 @@ type MockClient struct {
// post-insert CreateCheckout-failure path (marking the provisional
// terminal_checkouts row failed) can be exercised in dev/tests.
FailCreateCheckout bool
// FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge internally
// (retaining the key + source in paymentByKey/paymentSource exactly like a
// successful charge) and THEN returns a 5xx-style error to the caller. A
// subsequent CreatePayment with the SAME key + SAME source dedups to the
// committed payment — never a second charge — exercising the retry path
// devs hit in prod when Square processes a charge but the response is lost.
FailAfterCommit bool
// SimulateCardTokenUsed makes CreateCardOnFile enforce Square's
// CARD_TOKEN_USED rejection: a card token (cnon: nonce) already used to
// create a card on this mock instance is rejected with the same structured
// 400 CARD_TOKEN_USED error real Square returns. Off by default — dev/test
// flows reuse plain "cnon:test-card"-style tokens across requests, so
// enforcement is enabled only in tests that exercise the reused-token
// rejection. UsedCardTokens() reports the tokens consumed so far.
SimulateCardTokenUsed bool
// usedCardTokens records card tokens consumed by CreateCardOnFile while
// SimulateCardTokenUsed is enabled (Square consumes a cnon: nonce on card
// creation, so reusing it is rejected with CARD_TOKEN_USED).
usedCardTokens map[string]bool
}
type devProdClient struct{}
@@ -130,24 +172,47 @@ func NewClient() SquareClient {
return NewDevClient()
}
// errDevRealAPIRequiresOverride is the hard-fail error NewDevClient panics
// with when a dev build is asked to route to the real PRODUCTION Square API
// without the explicit SQUARE_ALLOW_REAL_API=1 override. A dev build must
// never silently charge real money on an env-string match alone.
var errDevRealAPIRequiresOverride = errors.New("square: dev build refuses SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1 (would route to the REAL Square API)")
func NewDevClient() SquareClient {
env := os.Getenv("SQUARE_ENVIRONMENT")
if env == "sandbox" || env == "production" {
log.Printf("[SQUARE-PROD] SQUARE_ENVIRONMENT=%s — making real API calls to %s", env, realBaseURL(env))
env := SquareEnvironment()
switch env {
case "production":
// A `//go:build dev` build routing to the real production API is an
// explicit safety boundary, not a string-match convenience. Without
// the override, a typo'd or leftover SQUARE_ENVIRONMENT=production in
// a dev shell would make test bookings create REAL charges and payouts.
// Fail fast so the misconfiguration is impossible to miss.
if os.Getenv("SQUARE_ALLOW_REAL_API") != "1" {
log.Printf("[SQUARE-PROD] REFUSING to construct the real production Square client in a dev build: SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1 — set SQUARE_ALLOW_REAL_API=1 to override, or SQUARE_ENVIRONMENT=sandbox/mock for safe dev traffic")
panic(errDevRealAPIRequiresOverride)
}
log.Printf("[SQUARE-PROD] SQUARE_ENVIRONMENT=production WITH SQUARE_ALLOW_REAL_API=1 — dev build making REAL API calls to %s (explicit override, real money)", realBaseURL(env))
return &devProdClient{}
}
log.Println("[SQUARE-MOCK] Using in-memory mock client")
return &MockClient{
cards: make(map[string]map[string]*CardOnFile),
cardByToken: make(map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
paymentSource: make(map[string]string),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
case "sandbox":
// Sandbox never moves real money, so a dev build may route there — but
// loudly, so no-one mistakes a sandbox for the mock.
log.Printf("[SQUARE-PROD] *** DEV BUILD ROUTING TO SQUARE SANDBOX %s — test credentials only, NO real charges — this is NOT the mock client ***", realBaseURL(env))
return &devProdClient{}
default:
log.Println("[SQUARE-MOCK] Using in-memory mock client")
return &MockClient{
cards: make(map[string]map[string]*CardOnFile),
cardByToken: make(map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
paymentSource: make(map[string]string),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
usedCardTokens: make(map[string]bool),
}
}
}
@@ -205,6 +270,20 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
err: errors.New("square: customer_id required for card-on-file source"),
}
}
// Square's idempotency-key limit for POST /v2/payments is 45 characters
// (64 only for /v2/terminals/checkouts). Real Square rejects an oversized
// key with a 400 VALUE_TOO_LONG; the mock mirrors the rejection with the
// same structured error so dev parity catches over-length keys (the real
// client always derives ≤45-char keys, so this only fires on a caller bug).
if len(req.IdempotencyKey) > 45 {
return nil, &squareAPIError{
Code: "VALUE_TOO_LONG",
Detail: "idempotency_key must be 45 characters or fewer",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: idempotency_key %s is %d chars, exceeds Square's 45-char limit", tokenPrefix(req.IdempotencyKey), len(req.IdempotencyKey)),
}
}
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
// card reference (ccof:) that could be replayed. Log only its prefix and
// length for debugging (S-2).
@@ -309,6 +388,15 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
m.paymentSource[req.IdempotencyKey] = req.SourceID
}
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
if m.FailAfterCommit {
// The charge is already committed above (payment + key + source are in
// the ledgers exactly like a successful charge) — now simulate the lost
// response: the caller sees a 5xx-style error while Square holds the
// payment under the key. A same-key + same-source retry dedups to the
// committed payment instead of charging twice, exactly like prod.
log.Printf("[SQUARE-MOCK] FailAfterCommit: payment %s committed under key=%s but returning simulated 503 (response lost)", paymentID, req.IdempotencyKey)
return nil, fmt.Errorf("square: charge %s committed but response lost (simulated HTTP 503) — retry with the same idempotency key to receive the committed payment", paymentID)
}
return result, nil
}
@@ -609,6 +697,19 @@ func (m *MockClient) RefundKeyCount() int {
return len(m.refundByKey)
}
// UsedCardTokens returns the card tokens consumed by CreateCardOnFile while
// SimulateCardTokenUsed is enabled. Test accessor for asserting that a reused
// token is rejected with CARD_TOKEN_USED after a previous save.
func (m *MockClient) UsedCardTokens() []string {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.usedCardTokens))
for tok := range m.usedCardTokens {
out = append(out, tok)
}
return out
}
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
@@ -637,6 +738,19 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
m.mu.Lock()
defer m.mu.Unlock()
if m.SimulateCardTokenUsed && m.usedCardTokens[cardToken] {
// Real Square consumes a cnon: nonce on card creation — reusing it to
// create another card is rejected with CARD_TOKEN_USED. The mock
// mirrors that structured 400 rejection (opt-in, see the struct doc).
return nil, &squareAPIError{
Code: "CARD_TOKEN_USED",
Detail: "The card token has already been used.",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card token %s has already been used", tokenPrefix(cardToken)),
}
}
if m.cards[userID] == nil {
m.cards[userID] = make(map[string]*CardOnFile)
}
@@ -665,6 +779,9 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
}
m.cards[userID][cardID] = card
m.cardByToken[card.CardID] = card
if m.SimulateCardTokenUsed {
m.usedCardTokens[cardToken] = true
}
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
return card, nil
}
+247
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
@@ -1479,3 +1480,249 @@ func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) {
require.NoError(t, err)
assert.Len(t, results, 8)
}
// TestDevClient_ProductionEnvWithoutOverride_HardFails locks the dev-safety
// boundary: a `//go:build dev` build must NEVER silently route to the real
// PRODUCTION Square API on an env-string match alone (a typo'd/leftover
// SQUARE_ENVIRONMENT=production in a dev shell would create REAL charges from
// test bookings). NewDevClient hard-fails unless the explicit
// SQUARE_ALLOW_REAL_API=1 override is set; with the override it proceeds.
func TestDevClient_ProductionEnvWithoutOverride_HardFails(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
t.Setenv("SQUARE_ALLOW_REAL_API", "")
require.PanicsWithError(t, errDevRealAPIRequiresOverride.Error(), func() {
NewDevClient()
}, "a dev build must refuse SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1")
// The explicit override is the opt-in that lets a dev build route to the
// real production API.
t.Setenv("SQUARE_ALLOW_REAL_API", "1")
client := NewDevClient()
require.IsType(t, &devProdClient{}, client, "SQUARE_ALLOW_REAL_API=1 must allow the dev build to route to the real production API")
}
// TestDevClient_SandboxEnv_RoutesToRealClient locks the sandbox routing
// banner: a dev build may route to the Square SANDBOX (no real money), but
// only with a loud banner so the sandbox is never mistaken for the mock.
func TestDevClient_SandboxEnv_RoutesToRealClient(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "sandbox")
t.Setenv("SQUARE_ALLOW_REAL_API", "")
var buf bytes.Buffer
log.SetOutput(&buf)
defer log.SetOutput(os.Stderr)
client := NewDevClient()
require.IsType(t, &devProdClient{}, client, "a dev build may route to the Square sandbox (no real money)")
logs := buf.String()
assert.Contains(t, logs, "SANDBOX", "routing a dev build to the sandbox must log a loud banner")
assert.Contains(t, logs, squareSandboxURL, "the banner must name the sandbox endpoint, not a mock")
}
// TestDevClient_CreatePayment_FailAfterCommit locks the FailAfterCommit
// fault-injection: CreatePayment COMMITS the charge (retaining key + source in
// the ledgers exactly like a successful charge) and THEN returns a 5xx-style
// error — the "charged but response lost" prod scenario. A same-key +
// same-source retry must dedup to the committed payment, never issue a second
// charge.
func TestDevClient_CreatePayment_FailAfterCommit_ErrorThenSameKeyRetryDedups(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
req := CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "fail-after-commit-key",
ReferenceID: "booking-lost-response",
}
client.FailAfterCommit = true
got, err := client.CreatePayment(ctx, req)
require.Error(t, err, "FailAfterCommit must return an error to the caller (the response was lost)")
assert.Nil(t, got)
assert.Contains(t, err.Error(), "503", "the lost-response error must read as a 5xx for ambiguous classification")
// The charge was committed: the key + source are retained exactly like a
// successful charge, and the payment resolves by SquarePayID.
client.mu.RLock()
committed := client.paymentByKey["fail-after-commit-key"]
storedSource := client.paymentSource["fail-after-commit-key"]
client.mu.RUnlock()
require.NotNil(t, committed, "FailAfterCommit must COMMIT the charge under the idempotency key")
assert.Equal(t, "cnon:test-card", storedSource, "FailAfterCommit must retain the source under the key")
byID, err := client.GetPayment(ctx, committed.SquarePayID)
require.NoError(t, err)
assert.Equal(t, committed.ID, byID.ID, "the committed payment must be resolvable by SquarePayID")
// A same-key + same-source retry dedups to the committed payment — the
// exact prod 503-retry semantics (no double charge).
client.FailAfterCommit = false
retry, err := client.CreatePayment(ctx, req)
require.NoError(t, err)
assert.Equal(t, committed.ID, retry.ID, "same-key retry must return the committed payment, not a second charge")
client.mu.RLock()
payCount := len(client.payments)
client.mu.RUnlock()
assert.Equal(t, 1, payCount, "FailAfterCommit + same-key retry must store exactly ONE charge")
}
// TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey locks the mock's
// 45-char idempotency-key cap: real Square rejects an over-length key for
// POST /v2/payments with a 400 VALUE_TOO_LONG, and the mock must mirror that
// structured rejection so dev parity catches a caller bug.
func TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
longKey := strings.Repeat("k", 46)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: longKey,
})
require.Error(t, err, "an idempotency key over Square's 45-char limit must be rejected")
assert.Nil(t, result)
assert.Equal(t, "VALUE_TOO_LONG", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
// A 45-char key is the boundary and must be accepted.
ok, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: strings.Repeat("k", 45),
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", ok.Status)
}
// TestDevClient_CreateCardOnFile_SimulateCardTokenUsed locks the CARD_TOKEN_USED
// simulation: when SimulateCardTokenUsed is enabled, a card token (cnon: nonce)
// reused after a previous save is rejected with Square's structured 400
// CARD_TOKEN_USED error. Off by default (dev/test flows reuse plain test
// tokens across requests), so the toggle must not reject reuse when disabled.
func TestDevClient_CreateCardOnFile_SimulateCardTokenUsed(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateCardTokenUsed = true
ctx := context.Background()
card, err := client.CreateCardOnFile(ctx, "user-token-used", "cnon:single-use-nonce", "cus_test123")
require.NoError(t, err)
assert.NotEmpty(t, card.ID)
// Reusing the same token → Square's CARD_TOKEN_USED rejection.
_, err = client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:single-use-nonce", "cus_test123")
require.Error(t, err)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedCardTokens())
// A fresh token still works.
fresh, err := client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:fresh-nonce", "cus_test123")
require.NoError(t, err)
assert.NotEmpty(t, fresh.ID)
// With the toggle OFF (default), reusing a token is allowed — dev/test
// flows reuse plain "cnon:test-card"-style tokens across requests.
client.SimulateCardTokenUsed = false
_, err = client.CreateCardOnFile(ctx, "user-token-reuse", "cnon:reused-token", "cus_test123")
require.NoError(t, err)
_, err = client.CreateCardOnFile(ctx, "user-token-reuse-2", "cnon:reused-token", "cus_test123")
require.NoError(t, err, "with SimulateCardTokenUsed off, token reuse must be allowed")
}
// TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and
// the real HTTP client AGREE on the over-length idempotency key rejection:
// both surface the same structured code (VALUE_TOO_LONG) and HTTP status (400).
func TestIdempotencyKeyLength_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
longKey := strings.Repeat("k", 46)
// Real client: Square's 400 VALUE_TOO_LONG response surfaces as a
// structured squareAPIError (the doJSON error-parsing path).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"VALUE_TOO_LONG","detail":"idempotency_key too long"}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, realErr := createPaymentHTTPWithClient(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey,
}, hc)
require.Error(t, realErr)
// Mock: rejects the same key client-side with the identical structured
// error (code + status), so dev parity holds.
mock := NewDevClient().(*MockClient)
_, mockErr := mock.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey,
})
require.Error(t, mockErr)
assert.Equal(t, "VALUE_TOO_LONG", ErrorCode(realErr))
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for an over-length key")
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for an over-length key")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
}
// TestCardTokenUsed_Parity_MockAndRealClientAgree asserts the mock and the
// real HTTP client AGREE on the reused-card-token rejection: both surface the
// same structured code (CARD_TOKEN_USED) and HTTP status (400).
func TestCardTokenUsed_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
token := "cnon:reused-nonce"
// Real client: Square's 400 CARD_TOKEN_USED response surfaces as a
// structured squareAPIError.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"CARD_TOKEN_USED","detail":"The card token has already been used."}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", token, "cus_1", hc)
require.Error(t, realErr)
// Mock: with the simulation enabled, reusing a consumed token surfaces the
// identical structured error.
mock := NewDevClient().(*MockClient)
mock.SimulateCardTokenUsed = true
_, err := mock.CreateCardOnFile(ctx, "user_1", token, "cus_1")
require.NoError(t, err)
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", token, "cus_1")
require.Error(t, mockErr)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(realErr))
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card token")
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card token")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
}
// TestEnvResolution_HelperMatchesHTTPClient locks the shared env-resolution
// contract (finding 4): the sweep and the charge path read SQUARE_ENVIRONMENT /
// SQUARE_LOCATION_ID through the SAME helpers the HTTP client uses, so the two
// deployables can never drift to independent env reads.
func TestEnvResolution_HelperMatchesHTTPClient(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "sandbox")
t.Setenv("SQUARE_LOCATION_ID", "L_TEST_ENV")
assert.Equal(t, "sandbox", SquareEnvironment())
assert.Equal(t, "L_TEST_ENV", SquareLocationID())
// newHTTPClient derives base URL + location from the SAME helpers.
hc := newHTTPClient()
assert.Equal(t, squareSandboxURL, hc.baseURL, "sandbox env must resolve the sandbox base URL")
assert.Equal(t, "L_TEST_ENV", hc.locationID, "the HTTP client must read the location through SquareLocationID")
// Production resolves the production base URL; anything else resolves the
// sandbox base URL — never the production URL.
t.Setenv("SQUARE_ENVIRONMENT", "production")
assert.Equal(t, squareProductionURL, newHTTPClient().baseURL, "production env must resolve the production base URL")
t.Setenv("SQUARE_ENVIRONMENT", "mock")
assert.Equal(t, squareSandboxURL, newHTTPClient().baseURL, "any non-production env resolves the sandbox base URL (never the production URL)")
}
+38 -11
View File
@@ -60,8 +60,28 @@ type httpClient struct {
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 := os.Getenv("SQUARE_ENVIRONMENT")
env := SquareEnvironment()
baseURL := squareSandboxURL
if env == "production" {
baseURL = squareProductionURL
@@ -69,7 +89,7 @@ func newHTTPClient() *httpClient {
return &httpClient{
baseURL: baseURL,
token: os.Getenv("SQUARE_ACCESS_TOKEN"),
locationID: os.Getenv("SQUARE_LOCATION_ID"),
locationID: SquareLocationID(),
deviceID: os.Getenv("SQUARE_TERMINAL_DEVICE_ID"),
http: &http.Client{Timeout: defaultHTTPTimeout},
}
@@ -797,11 +817,14 @@ func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime
}
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
}
// 20 pages fetched and a cursor is still present — return what we
// collected rather than discarding partial results (the previous
// infinite-loop guard dropped everything and returned an error).
log.Printf("[SQUARE] list refunds exceeded 20 pages (infinite-loop guard) — returning partial results: %d refunds for %s", len(results), paymentID)
return results, nil
// 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) {
@@ -874,10 +897,14 @@ func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpCl
path = "/v2/cards?reference_id=" + url.QueryEscape(userID) + "&cursor=" + url.QueryEscape(resp.Cursor)
}
if truncated {
// 20 pages fetched and a cursor is still present — return what we
// collected rather than discarding partial results (mirrors the
// listRefunds 20-page guard's behavior).
log.Printf("[SQUARE] list cards for %s exceeded 20 pages (infinite-loop guard) — returning partial results: %d cards", userID, len(cards))
// 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{}
@@ -736,10 +736,11 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
}
})
t.Run("page_guard_returns_partial_results", func(t *testing.T) {
// The 20-page guard must not discard what was already collected: it
// logs a truncation warning and returns the partial results instead
// of failing the reconcile with an error.
t.Run("page_guard_errors_instead_of_partial", func(t *testing.T) {
// The 20-page guard must ERROR rather than return partial results: a
// refund in the truncated tail would otherwise look like "no COMPLETED
// refund exists", letting the reconcile mark rows failed and over-refund.
// The reconcile caller treats any error as "leave rows pending, retry".
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
@@ -750,14 +751,17 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc)
if err != nil {
t.Fatalf("expected partial results (nil error), got %v", err)
if err == nil {
t.Fatalf("expected a truncation error after 20 pages, got %d partial refunds with nil error", len(refunds))
}
if !strings.Contains(err.Error(), "exceeded 20 pages") {
t.Errorf("expected error to name the 20-page guard, got %v", err)
}
if calls != 20 {
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
}
if len(refunds) != 20 {
t.Errorf("expected 20 refunds collected across pages (one per page), got %d", len(refunds))
if len(refunds) != 0 {
t.Errorf("expected no partial results on truncation error, got %d", len(refunds))
}
})
}
@@ -909,6 +913,33 @@ func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
}
}
// TestGetCardsOnFileHTTP_PageGuard_ReturnsPartial verifies the 20-page guard
// keeps returning partial results (nil error) for card listing, unlike
// listRefunds which errors: GetCardsOnFile has no money-sensitive caller, and
// erroring would break a "show my cards" feature for a user with >500 saved
// cards. The truncation is surfaced in the log, not by an error.
func TestGetCardsOnFileHTTP_PageGuard_ReturnsPartial(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"cards":[{"id":"ccof_t","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp_t","reference_id":"user_big","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_big", hc)
if err != nil {
t.Fatalf("expected partial cards with nil error, got %v", err)
}
if calls != 20 {
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
}
if len(cards) != 20 {
t.Errorf("expected 20 cards collected across pages (one per page), got %d", len(cards))
}
}
// TestCreateCheckoutHTTP_TipSettings verifies AllowTipping is emitted as
// checkout.device_options.tip_settings.allow_tipping (Square's wire shape for
// enabling terminal tips) and omitted entirely when not set.
+7
View File
@@ -40,6 +40,13 @@ type CreatePaymentReq struct {
IdempotencyKey string
ReferenceID string // booking ID or other reference
Note string
// Autocomplete and TipMoney are valid Square wire fields that are
// intentionally NOT populated by any current handler: online payments are
// completed immediately (Autocomplete nil = Square default true, no
// approve-then-capture) and tips are handled locally as separate tip
// payments rather than split inside Square's CreatePayment (TipMoney nil).
// They are wired through the client into the request body for completeness
// and future use — do not remove them.
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
TipMoney *int64 // optional tip amount in pence
CustomerID string // Square customer ID for card-on-file payments