fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change

Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

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

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

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

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

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

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

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 67cf5b9a45
commit 5cc5a7f6d2
28 changed files with 3328 additions and 208 deletions
+98 -33
View File
@@ -22,15 +22,18 @@ package square
//
// 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
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) 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.
// SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
// (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
// CreatePayment's payment status while returning nil error — the "Square
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
// handler (records 'completed' on nil error alone) is caught in dev.
//
// 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
@@ -111,18 +114,29 @@ type MockClient struct {
// 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
// SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
// rejection: a card source (cnon: nonce) already used to create a card on
// this mock instance is rejected with the same structured 400 SOURCE_USED
// error real Square's CreateCard API returns (SOURCE_USED — NOT the
// CreatePayment code CARD_TOKEN_USED). 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-source rejection.
// UsedSources() reports the sources consumed so far.
SimulateSourceUsed bool
// usedSources records card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
// creation, so reusing it is rejected with SOURCE_USED).
usedSources map[string]bool
// ForcePaymentStatus forces CreatePayment's payment status instead of the
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
// CreatePayment returns a payment carrying the forced status with nil
// error — the "Square returned 200 with a non-terminal payment" prod
// scenario. It proves a status-blind handler (one that records 'completed'
// on nil error alone) is a regression: the client surfaces Status
// faithfully (paymentFromSquare never errors on a non-terminal status —
// see square_http_client.go), so only the handler's own status check can
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
ForcePaymentStatus string
}
type devProdClient struct{}
@@ -211,7 +225,7 @@ func NewDevClient() SquareClient {
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
usedCardTokens: make(map[string]bool),
usedSources: make(map[string]bool),
}
}
}
@@ -326,6 +340,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
if req.Autocomplete != nil && !*req.Autocomplete {
status = "APPROVED"
}
if m.ForcePaymentStatus != "" {
// Drive the "Square returned 200 with a non-terminal payment" prod
// scenario: the payment comes back with a non-default status and nil
// error, so a status-blind caller (records 'completed' on nil error
// alone) is exposed as a regression.
status = m.ForcePaymentStatus
}
amount := req.Amount
tipAmount := int64(0)
@@ -343,6 +364,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano())
// Sign-convention parity (finding A): Square reports processing_fee amounts
// as NEGATIVE on the wire, and paymentFromSquare negates them so
// PaymentResult.Fees is POSITIVE — the value handlers store as p.fees. The
// mock fabricates the same positive magnitude directly: online rate 1.4% +
// 25p (amount*14/1000+25). Mock and real client must agree on the sign;
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
locationID := req.LocationID
@@ -404,6 +431,26 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
if m.FailCreateCheckout {
return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)")
}
// Real Square's TerminalCheckout API REQUIRES device_options.device_id: a
// checkout with an empty device id is rejected with a 400. The real client
// resolves the per-request device ID with an env fallback
// (SQUARE_TERMINAL_DEVICE_ID, square_http_client.go:516); the mock mirrors
// the SAME resolution and rejects when neither is set — so a missing
// terminal misconfiguration is caught in dev instead of silently
// "succeeding" where prod 400s.
deviceID := req.DeviceID
if deviceID == "" {
deviceID = os.Getenv("SQUARE_TERMINAL_DEVICE_ID")
}
if deviceID == "" {
return nil, &squareAPIError{
Code: "INVALID_REQUEST_ERROR",
Detail: "device_options.device_id is required to create a terminal checkout",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"),
}
}
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
now := clock.Now().UTC()
@@ -488,6 +535,15 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
ReferenceID: req.ReferenceID,
}
m.completed[checkoutID] = paymentResult
// Real Square registers the terminal payment under its own ID:
// GET /v2/payments/{id} succeeds on a completed terminal
// checkout's payment in prod, but failed in dev because the
// payment was never added to m.payments (finding I). Mirror prod
// by registering it under both the ID and SquarePayID keys, exactly
// like CreatePayment, so the reconcile/sweep GetPayment path
// behaves identically.
m.payments[paymentID] = paymentResult
m.payments[paymentResult.SquarePayID] = paymentResult
m.checkouts[checkoutID].Status = "COMPLETED"
m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339)
m.checkouts[checkoutID].PaymentIDs = []string{paymentID}
@@ -697,15 +753,15 @@ 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 {
// UsedSources returns the card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled. Test accessor for asserting that a reused
// source is rejected with SOURCE_USED after a previous save.
func (m *MockClient) UsedSources() []string {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.usedCardTokens))
for tok := range m.usedCardTokens {
out = append(out, tok)
out := make([]string, 0, len(m.usedSources))
for src := range m.usedSources {
out = append(out, src)
}
return out
}
@@ -738,16 +794,18 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
m.mu.Lock()
defer m.mu.Unlock()
if m.SimulateCardTokenUsed && m.usedCardTokens[cardToken] {
if m.SimulateSourceUsed && m.usedSources[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).
// create another card is rejected with SOURCE_USED (the CreateCard
// error; CARD_TOKEN_USED is a CreatePayment code and would be wrong
// here). 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.",
Code: "SOURCE_USED",
Detail: "The provided source id was already used to create a card",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card token %s has already been used", tokenPrefix(cardToken)),
err: fmt.Errorf("square: card source %s has already been used to create a card", tokenPrefix(cardToken)),
}
}
@@ -779,8 +837,8 @@ 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
if m.SimulateSourceUsed {
m.usedSources[cardToken] = true
}
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
return card, nil
@@ -799,6 +857,13 @@ func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
var cards []CardOnFile
for _, card := range userCards {
// Real Square's List Cards API EXCLUDES disabled cards by default
// (the client sends no include_disabled param) — a disabled/deleted
// card disappears from GetCardsOnFile. Mirror that so dev parity
// matches prod (finding C).
if !card.Enabled {
continue
}
cards = append(cards, *card)
}
return cards, nil
+197 -33
View File
@@ -83,6 +83,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
IdempotencyKey: "checkout-key-1",
ReferenceID: "booking-456",
AllowTipping: true,
DeviceID: "dvc_test",
}
result, err := client.CreateCheckout(ctx, req)
@@ -126,6 +127,7 @@ func TestDevClient_CreateCheckout_DeadlineDurationFormat(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "checkout-deadline",
ReferenceID: "deadline-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "PT5M", result.Deadline, "deadline_duration must be an RFC 3339 duration, not a timestamp")
@@ -146,6 +148,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
IdempotencyKey: "checkout-key-notip",
ReferenceID: "booking-789",
AllowTipping: false,
DeviceID: "dvc_test",
}
result, err := client.CreateCheckout(ctx, req)
@@ -267,11 +270,11 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) {
err = client.DeleteCardOnFile(ctx, card.ID)
require.NoError(t, err)
// Square's List Cards API excludes disabled cards by default — a
// disabled/deleted card is no longer returned by GetCardsOnFile.
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
require.Len(t, cards, 1)
assert.False(t, cards[0].Enabled)
assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards")
}
func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) {
@@ -724,10 +727,11 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) {
err = client.DeleteCardOnFile(ctx, card.ID)
require.NoError(t, err)
// Square's List Cards API excludes disabled cards by default — the
// disabled card is no longer returned by GetCardsOnFile.
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
require.Len(t, cards, 1)
assert.False(t, cards[0].Enabled)
assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards")
}
func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
@@ -900,6 +904,7 @@ func TestDevClient_GetCheckout_StillPending(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "pending-checkout",
ReferenceID: "pending-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", result.Status)
@@ -919,6 +924,7 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "hold-checkout",
ReferenceID: "hold-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", result.Status)
@@ -940,6 +946,7 @@ func TestDevClient_GetCheckout_ForceInProgress(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "in-progress-checkout",
ReferenceID: "in-progress-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "IN_PROGRESS", result.Status)
@@ -971,6 +978,7 @@ func TestDevClient_GetCheckout_ForceCancelRequested(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "cancel-requested-checkout",
ReferenceID: "cancel-requested-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "CANCEL_REQUESTED", result.Status)
@@ -1000,6 +1008,7 @@ func TestDevClient_GetCheckout_ForceCanceled(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "canceled-checkout",
ReferenceID: "canceled-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "CANCELED", result.Status)
@@ -1228,6 +1237,7 @@ func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "cancel-checkout",
ReferenceID: "cancel-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", result.Status)
@@ -1261,6 +1271,7 @@ func TestDevClient_CancelCheckout_CompletedIsNoOp(t *testing.T) {
Currency: "GBP",
IdempotencyKey: "cancel-completed",
ReferenceID: "cancel-comp-ref",
DeviceID: "dvc_test",
})
require.NoError(t, err)
@@ -1598,39 +1609,41 @@ func TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey(t *testing.T) {
assert.Equal(t, "COMPLETED", ok.Status)
}
// TestDevClient_CreateCardOnFile_SimulateCardTokenUsed locks the CARD_TOKEN_USED
// simulation: when SimulateCardTokenUsed is enabled, a card token (cnon: nonce)
// TestDevClient_CreateCardOnFile_SimulateSourceUsed locks the SOURCE_USED
// simulation: when SimulateSourceUsed is enabled, a card source (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) {
// SOURCE_USED error (the CreateCard error for a reused source — NOT the
// CreatePayment code CARD_TOKEN_USED). Off by default (dev/test flows reuse
// plain test tokens across requests), so the toggle must not reject reuse when
// disabled.
func TestDevClient_CreateCardOnFile_SimulateSourceUsed(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateCardTokenUsed = true
client.SimulateSourceUsed = 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.
// Reusing the same source → Square's SOURCE_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, "SOURCE_USED", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedCardTokens())
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedSources())
// A fresh token still works.
// A fresh source 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
// With the toggle OFF (default), reusing a source is allowed — dev/test
// flows reuse plain "cnon:test-card"-style tokens across requests.
client.SimulateCardTokenUsed = false
client.SimulateSourceUsed = 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")
require.NoError(t, err, "with SimulateSourceUsed off, source reuse must be allowed")
}
// TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and
@@ -1668,37 +1681,38 @@ func TestIdempotencyKeyLength_Parity_MockAndRealClientAgree(t *testing.T) {
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) {
// TestSourceUsed_Parity_MockAndRealClientAgree asserts the mock and the
// real HTTP client AGREE on the reused-card-source rejection: both surface the
// same structured code (SOURCE_USED — Square's CreateCard error, NOT the
// CreatePayment code CARD_TOKEN_USED) and HTTP status (400).
func TestSourceUsed_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
token := "cnon:reused-nonce"
source := "cnon:reused-nonce"
// Real client: Square's 400 CARD_TOKEN_USED response surfaces as a
// Real client: Square's 400 SOURCE_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."}]}`))
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"SOURCE_USED","detail":"The provided source id was already used to create a card"}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", token, "cus_1", hc)
_, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", source, "cus_1", hc)
require.Error(t, realErr)
// Mock: with the simulation enabled, reusing a consumed token surfaces the
// Mock: with the simulation enabled, reusing a consumed source surfaces the
// identical structured error.
mock := NewDevClient().(*MockClient)
mock.SimulateCardTokenUsed = true
_, err := mock.CreateCardOnFile(ctx, "user_1", token, "cus_1")
mock.SimulateSourceUsed = true
_, err := mock.CreateCardOnFile(ctx, "user_1", source, "cus_1")
require.NoError(t, err)
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", token, "cus_1")
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", source, "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, "SOURCE_USED", ErrorCode(realErr))
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card source")
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card source")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
}
@@ -1726,3 +1740,153 @@ func TestEnvResolution_HelperMatchesHTTPClient(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock")
assert.Equal(t, squareSandboxURL, newHTTPClient().baseURL, "any non-production env resolves the sandbox base URL (never the production URL)")
}
// TestProcessingFeeSign_Parity_MockAndRealClientAgree locks the
// processing-fee sign convention: Square reports processing_fee amounts as
// NEGATIVE on the wire and paymentFromSquare negates them so PaymentResult.Fees
// is POSITIVE — the magnitude the handlers store as p.fees. The mock
// fabricates the same positive magnitude directly, so mock and real client must
// agree on the value (finding A).
func TestProcessingFeeSign_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
amount := int64(5000)
wantFees := int64(5000*14/1000 + 25) // 1.4% + 25p on £50.00 = 95p
// Real client: Square's wire processing_fee is negative; paymentFromSquare
// must surface the positive magnitude.
pr := paymentFromSquare(&sqPayment{
ID: "pay_fee",
Status: "COMPLETED",
TotalMoney: sqMoney{Amount: amount, Currency: "GBP"},
ProcessingFee: []sqFee{
{AmountMoney: sqMoney{Amount: -wantFees, Currency: "GBP"}, Type: "INITIAL"},
},
})
assert.Equal(t, wantFees, pr.Fees, "real client must negate Square's negative processing_fee into a positive PaymentResult.Fees")
// Mock: fabricates the same positive fee.
mock := NewDevClient().(*MockClient)
got, err := mock.CreatePayment(ctx, CreatePaymentReq{
Amount: amount, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "fee-parity-key",
})
require.NoError(t, err)
assert.Equal(t, wantFees, got.Fees, "mock and real client must agree on the positive fee magnitude")
}
// TestDevClient_GetCardsOnFile_ExcludesDisabled locks the List Cards parity:
// real Square's List Cards API EXCLUDES disabled cards by default (the client
// sends no include_disabled param), so a card disabled via DeleteCardOnFile
// must disappear from GetCardsOnFile — exactly like prod (finding C).
func TestDevClient_GetCardsOnFile_ExcludesDisabled(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
userID := "user-disabled-exclusion"
c1, err := client.CreateCardOnFile(ctx, userID, "cnon:enabled-card", "cus_test123")
require.NoError(t, err)
c2, err := client.CreateCardOnFile(ctx, userID, "cnon:to-be-disabled", "cus_test123")
require.NoError(t, err)
require.NoError(t, client.DeleteCardOnFile(ctx, c2.ID))
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
require.Len(t, cards, 1, "only the enabled card must be listed")
assert.Equal(t, c1.ID, cards[0].ID)
}
// TestDevClient_CreatePayment_ForcePaymentStatus drives the "Square returned
// 200 with a non-terminal payment" prod scenario: with ForcePaymentStatus set,
// CreatePayment returns a payment carrying a non-default status with NIL error
// (the client never errors on a status — see paymentFromSquare). A status-blind
// handler that records 'completed' on nil error alone would mis-record these;
// the toggle makes that regression exercisable in dev (finding D).
func TestDevClient_CreatePayment_ForcePaymentStatus(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
for _, status := range []string{"FAILED", "CANCELED", "APPROVED", "PENDING"} {
t.Run(status, func(t *testing.T) {
client.ForcePaymentStatus = status
res, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "forced-status-" + status,
})
require.NoError(t, err, "a forced status must still return nil error (the client is status-transparent)")
assert.Equal(t, status, res.Status, "the payment must carry the forced status")
got, err := client.GetPayment(ctx, res.ID)
require.NoError(t, err)
assert.Equal(t, status, got.Status, "GetPayment must surface the same status")
})
}
}
// TestDevClient_CreateCheckout_RequiresDeviceID locks the TerminalCheckout
// device_id parity: real Square REQUIRES device_options.device_id (400 on
// empty). The mock resolves the per-request device ID with the same env
// fallback as the real client (SQUARE_TERMINAL_DEVICE_ID) and rejects when
// neither is set — a missing terminal misconfiguration is caught in dev
// (finding H).
func TestDevClient_CreateCheckout_RequiresDeviceID(t *testing.T) {
ctx := context.Background()
t.Run("empty_device_id_is_rejected", func(t *testing.T) {
t.Setenv("SQUARE_TERMINAL_DEVICE_ID", "")
client := NewDevClient().(*MockClient)
res, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-no-device",
})
require.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
})
t.Run("request_device_id_is_accepted", func(t *testing.T) {
client := NewDevClient().(*MockClient)
res, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-req-device", DeviceID: "dvc_req",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", res.Status)
})
t.Run("env_device_id_fallback_is_accepted", func(t *testing.T) {
t.Setenv("SQUARE_TERMINAL_DEVICE_ID", "dvc_env")
client := NewDevClient().(*MockClient)
res, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-env-device",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", res.Status)
})
}
// TestDevClient_CreateCheckout_CompletedPaymentResolvableByID locks the
// terminal-payment registration parity: a completed terminal checkout's payment
// must be resolvable via GetPayment (GET /v2/payments/{id} in prod), not just
// via GetCheckout. The mock previously never stored it in m.payments, so
// GetPayment failed in dev where prod succeeded (finding I).
func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
checkout, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 7500, Currency: "GBP", IdempotencyKey: "chk-payment-resolvable", DeviceID: "dvc_test",
})
require.NoError(t, err)
var completed *PaymentResult
assert.Eventually(t, func() bool {
var getErr error
completed, getErr = client.GetCheckout(ctx, checkout.ID)
return getErr == nil && completed.Status == "COMPLETED"
}, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
// The completed terminal payment must resolve by ID — the sweep's
// reconcile-by-id GetPayment path, which prod supports.
got, err := client.GetPayment(ctx, completed.ID)
require.NoError(t, err)
assert.Equal(t, completed.ID, got.ID)
assert.Equal(t, "COMPLETED", got.Status)
}
+21 -1
View File
@@ -996,6 +996,19 @@ func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *ht
// 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,
@@ -1015,8 +1028,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
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
r.Fees += -f.AmountMoney.Amount
}
if sq.CardDetails != nil {
cd := sq.CardDetails
@@ -1686,3 +1686,100 @@ func TestDeleteCustomerHTTP(t *testing.T) {
}
})
}
// TestPaymentFromSquare_NegatesProcessingFees locks the processing-fee sign
// convention (finding A): Square reports processing_fee amounts as NEGATIVE on
// the wire, and paymentFromSquare must surface a POSITIVE PaymentResult.Fees
// (the magnitude handlers store as p.fees). A fee of -95 on the wire must
// become Fees == 95.
func TestPaymentFromSquare_NegatesProcessingFees(t *testing.T) {
p := &sqPayment{
ID: "pay_fee",
Status: "COMPLETED",
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
ProcessingFee: []sqFee{
{AmountMoney: sqMoney{Amount: -95, Currency: "GBP"}, Type: "INITIAL"},
{AmountMoney: sqMoney{Amount: -20, Currency: "GBP"}, Type: "SECONDARY"},
},
}
result := paymentFromSquare(p)
if result.Fees != 115 {
t.Errorf("expected Fees 115 (sum of negated Square fees), got %d", result.Fees)
}
// A zero fee stays zero.
zero := paymentFromSquare(&sqPayment{ID: "pay_zero", Status: "COMPLETED", ProcessingFee: []sqFee{{AmountMoney: sqMoney{Amount: 0, Currency: "GBP"}}}})
if zero.Fees != 0 {
t.Errorf("expected Fees 0 for a zero fee, got %d", zero.Fees)
}
}
// TestPaymentFromSquare_StatusMapping verifies paymentFromSquare maps every
// documented Square payment status FAITHFULLY (finding D): APPROVED, PENDING,
// FAILED, CANCELED and COMPLETED all flow through into PaymentResult.Status.
// The client never downgrades or drops a non-terminal status.
func TestPaymentFromSquare_StatusMapping(t *testing.T) {
for _, status := range []string{"APPROVED", "PENDING", "FAILED", "CANCELED", "COMPLETED"} {
t.Run(status, func(t *testing.T) {
p := &sqPayment{ID: "pay_" + status, Status: status, TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}}
result := paymentFromSquare(p)
if result.Status != status {
t.Errorf("expected Status %q mapped verbatim, got %q", status, result.Status)
}
})
}
}
// TestDoJSON_200WithFailedPayment_NotDropped verifies doJSON does NOT silently
// drop a 200-with-FAILED payment (finding D): a 2xx body carrying a FAILED
// payment is parsed into a PaymentResult with Status "FAILED" and nil error —
// the client surfaces the status faithfully instead of erroring, so a
// status-blind handler (records 'completed' on nil error alone) is exposed.
func TestDoJSON_200WithFailedPayment_NotDropped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"payment":{"id":"pay_failed_200","status":"FAILED","total_money":{"amount":5000,"currency":"GBP"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z"}}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-failed-200",
}, hc)
if err != nil {
t.Fatalf("a 200-with-FAILED payment must NOT error (the client is status-transparent), got %v", err)
}
if res.Status != "FAILED" {
t.Errorf("expected Status FAILED surfaced faithfully, got %q", res.Status)
}
if res.ID != "pay_failed_200" {
t.Errorf("expected the failed payment returned, got %+v", res)
}
}
// TestDoJSON_CardProcessingNotEnabled403 verifies a 403 CARD_PROCESSING_NOT_ENABLED
// response surfaces the structured Square error with StatusCode 403 (finding E)
// so the handlers agent can special-case it (errors.go, not owned here).
func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"CARD_PROCESSING_NOT_ENABLED","detail":"Card processing is not enabled for this account."}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil)
if err == nil {
t.Fatal("expected error for 403")
}
if got := ErrorStatusCode(err); got != http.StatusForbidden {
t.Errorf("expected ErrorStatusCode 403, got %d", got)
}
if got := ErrorCode(err); got != "CARD_PROCESSING_NOT_ENABLED" {
t.Errorf("expected ErrorCode CARD_PROCESSING_NOT_ENABLED, got %q", got)
}
if got := ErrorCategory(err); got != "PAYMENT_METHOD_ERROR" {
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
}
}