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

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

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

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

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

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

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

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

693 lines
32 KiB
Go

//go:build test && dev
package payments
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/require"
)
// =============================================================================
// R7 — CreateBookingPayment refunded-dedup 409 guard (paymentHasLiveRefund)
// =============================================================================
// TestCreateBookingPayment_RefundedDedup_409 locks the money-safety guard on
// the idempotent dedup path: a client-supplied idempotency_key that matches a
// COMPLETED payment which has since been refunded must NOT be reported as
// success (that would silently swallow a new equal-amount charge — the booking
// shows paid with no money collected). The replay is rejected with 409 and no
// new payment row is created.
func TestCreateBookingPayment_RefundedDedup_409(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
const key = "client-uuid-refunded-dedup"
cardToken := "cnon:refunded-dedup-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: key,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w1.Code, "initial charge must succeed, body: %s", w1.Body.String())
// A £25 deposit on the £50 fixture booking leaves the booking confirmed
// (not fully paid), so the retry reaches the GENERAL dedup path (with the
// refund re-validation) rather than the completed-booking short-circuit.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status))
require.Equal(t, "confirmed", status, "the replay must hit the general dedup path")
var paymentID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingID, key).Scan(&paymentID))
// The admin refunds the payment — a live (completed) refund row now makes
// the payment's money no longer collectable.
_, err := tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 25.00, 'completed', 'admin refund', NOW())
`, paymentID, bookingID)
require.NoError(t, err)
// Same key + same amount retry: MUST NOT return the refunded payment as
// success.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusConflict, w2.Code, "a refunded-payment replay must 409, body: %s", w2.Body.String())
require.Contains(t, w2.Body.String(), "refunded and can no longer be replayed")
// No new payment row may be created by the rejected replay.
var keyCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&keyCount))
require.Equal(t, 1, keyCount, "the rejected replay must not create a second payment row")
}
// =============================================================================
// R7 — sweep keyed blind-fail for rows past Square's key-retention window
// =============================================================================
// TestSweepStalePendingPayments_KeyedPastRetention_BlindFails locks the
// payments-table blind-fail: a stale pending row with a stored idempotency_key
// but no square_payment_id that is ALREADY older than Square's ~24h key
// retention window when swept is marked 'failed' WITHOUT a replay reconcile
// (replaying an expired key would misread the probe rejection as "never
// charged"), and it counts toward the unverifiable WARN accounting.
func TestSweepStalePendingPayments_KeyedPastRetention_BlindFails(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
require.NoError(t, err)
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
require.NoError(t, err)
// 25h old: past stalePendingPaymentAge (24h), so replayExpired (now-24h) is
// already in the past even though the replay mock is FRESH — the row must be
// blind-failed, never replayed.
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-past-retention-pay', square_source_id = 'cnon:test-card' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
// Fresh mock: has no payment under the key, but the blind-fail must happen
// WITHOUT any replay because the retention window already closed.
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
// The past-retention blind-fail must be counted in the unverifiable
// accounting (resolved, NOT completed).
resolved, completed, unverifiable, err := sweepKeyedStaleRows(context.Background(), "payments", clock.Now().Add(-stalePendingKeyedAge))
require.NoError(t, err)
require.Equal(t, 1, resolved, "the past-retention keyed row must be resolved by the sweep")
require.Equal(t, 0, completed, "a past-retention keyed row can never be rescued to completed")
require.Equal(t, 1, unverifiable, "the past-retention blind-fail must count toward the unverifiable WARN accounting")
var rowStatus string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
require.Equal(t, "failed", rowStatus, "a keyed row past Square's retention window must be blind-failed")
// No reconcile happened: the row must not carry a square_payment_id.
var sqPayID *string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&sqPayID))
require.Nil(t, sqPayID, "a blind-failed row must never receive a square_payment_id")
}
// TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback
// locks the same blind-fail for a KEYED till sale: the sale is marked 'failed'
// and its funded gift card is NOT clawed back — unlike the proven-failure path,
// the blind-fail's charge outcome is unknown, so the funding must stay put.
func TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
pool := context.Background()
// seedStaleTillSaleWithCard already ages both the sale and its created gift
// card to 25h (create) — past Square's 24h retention window. Add the stored
// idempotency key to put it on the keyed pass.
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
if _, err := tx.Exec(ctx, "UPDATE till_sales SET idempotency_key = 'key-past-retention-till' WHERE id = $1", saleID); err != nil {
t.Fatalf("failed to set the till sale idempotency key: %v", err)
}
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
require.NoError(t, db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status))
require.Equal(t, "failed", status, "a keyed till sale past retention must be blind-failed")
var cardCount int
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "the blind-failed till sale's funded gift card must NOT be clawed back (charge outcome unknown)")
}
// =============================================================================
// R7 — reconcile tri-state status outcomes (by key AND by square_payment_id)
// =============================================================================
// TestSweepStalePendingPayments_ReconcileByKey_TriState locks the status
// switch in reconcileStalePaymentByKey: after a successful keyed replay,
// CANCELED/FAILED are definitive failures (row marked failed), APPROVED/PENDING
// are non-terminal (row left pending), and any unknown status is failed.
func TestSweepStalePendingPayments_ReconcileByKey_TriState(t *testing.T) {
cases := []struct {
name string
status string
wantFinal string // "failed" or "pending"
}{
{name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"},
{name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"},
{name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"},
{name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"},
{name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
require.NoError(t, err)
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
require.NoError(t, err)
key := "key-replay-tri-" + tc.name
// 23h old: past the 22h keyed cutoff (so the keyed pass picks it up)
// but still inside Square's 24h retention window (so the replay runs).
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := SweepStalePendingPayments(context.Background()); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var rowStatus string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
require.Equal(t, tc.wantFinal, rowStatus, "replay-by-key status %q must leave the row %q", tc.status, tc.wantFinal)
})
}
}
// TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses locks the
// same status switch in reconcileStalePaymentAtSquare (rows reconciled by their
// stored square_payment_id).
func TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses(t *testing.T) {
cases := []struct {
name string
status string
wantFinal string
}{
{name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"},
{name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"},
{name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"},
{name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"},
{name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
require.NoError(t, err)
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
require.NoError(t, err)
sqPayID := "sqp_reconcile_tri_" + tc.name
// 25h old: past the 24h pass-2 cutoff, with a square_payment_id so
// the reconcile runs by payment id (not by key).
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = $1 WHERE id = $2", sqPayID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status, SquarePayID: sqPayID}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := SweepStalePendingPayments(context.Background()); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var rowStatus string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
require.Equal(t, tc.wantFinal, rowStatus, "by-payment-id status %q must leave the row %q", tc.status, tc.wantFinal)
})
}
}
// =============================================================================
// R7 — resolveChargeSource Square-failure branches (all 500 + ok=false)
// =============================================================================
// round7SquareFailureClient forces CreateCustomer / CreateCardOnFile to return
// errors so the resolveChargeSource error branches can be exercised
// deterministically (the embedding pattern mirrors staleGetPaymentClient /
// staleReplayClient).
type round7SquareFailureClient struct {
square.SquareClient
failCreateCustomer bool
failCreateCard bool
}
func (c *round7SquareFailureClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
if c.failCreateCustomer {
return nil, fmt.Errorf("square: network error creating customer")
}
return c.SquareClient.CreateCustomer(ctx, name, email)
}
func (c *round7SquareFailureClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
if c.failCreateCard {
return nil, fmt.Errorf("square: network error creating card-on-file")
}
return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID)
}
func TestResolveChargeSource_SquareFailureBranches_500NoOrphan(t *testing.T) {
ctx := context.Background()
t.Run("ensure_square_customer_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
origClient := SquareClient
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true}
defer func() { SquareClient = origClient }()
token := "cnon:r7-customer-fail"
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "")
require.False(t, ok, "an EnsureSquareCustomer failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
require.Zero(t, rows, "no orphan saved-card row may be created when customer provisioning fails")
})
t.Run("create_card_on_file_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
// Provisioning succeeds from the cache; the card-on-file creation fails.
squareCustomerCache.Store(userID, "cus_r7_createcard")
defer InvalidateSquareCustomerCache(userID)
origClient := SquareClient
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCard: true}
defer func() { SquareClient = origClient }()
token := "cnon:r7-createcard-fail"
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "")
require.False(t, ok, "a CreateCardOnFile failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
require.Zero(t, rows, "no orphan saved-card row may be created when the Square card creation fails")
})
t.Run("saved_card_lazy_provisioning_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
// A pre-P14 saved-card row with an EMPTY square_customer_id: charging it
// requires lazy provisioning, which fails here.
cardID, err := fixtures.CreateTestPaymentMethod(db.Conn, userID, "ccof:r7-legacy-card", "VISA", "4242")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true}
defer func() { SquareClient = origClient }()
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found")
require.False(t, ok, "a lazy-provisioning failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
// The pre-existing saved card must remain, still un-provisioned.
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND user_id = $2`, cardID, userID).Scan(&rows))
require.Equal(t, 1, rows, "the existing saved card must not be deleted by the failed provisioning")
var custID *string
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, cardID).Scan(&custID))
require.Nil(t, custID, "the failed provisioning must not persist a Square customer id")
})
t.Run("get_card_non_404_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
// A cancelled context makes the GetCardByID query fail with a non-404
// error (context canceled), exercising the 500 branch (NOT the 404
// no-rows branch).
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
cardID := "nonexistent-card-id"
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(cancelCtx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found")
require.False(t, ok, "a non-404 GetCardByID failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
})
}
// =============================================================================
// R7 — CreateBookingPayment end-to-end charge-failure classification
// =============================================================================
// round7ChargeClient forces CreatePayment to return a fixed error so the
// charge-failure status mapping can be exercised end to end through the
// handler (the pending record must stay pending on every failure).
type round7ChargeClient struct {
square.SquareClient
createErr error
}
func (c *round7ChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
if c.createErr != nil {
return nil, c.createErr
}
return c.SquareClient.CreatePayment(ctx, req)
}
// round7CancelAwareChargeClient blocks inside CreatePayment until the request
// context is cancelled and then returns ctx.Err() — simulating Square hanging
// until the client gives up mid-charge.
type round7CancelAwareChargeClient struct {
square.SquareClient
entered chan struct{}
}
func (c *round7CancelAwareChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
close(c.entered)
<-ctx.Done()
return nil, ctx.Err()
}
// structuredSquareErrorWithCode builds a structured *square.squareAPIError of
// the same concrete type the real client produces, re-stamped with the given
// HTTP status and Square error code. The type is not nameable outside
// internal/square, so the clone-through-reflection technique mirrors
// errors_test.go's structuredSquareAPIError (which rewrites only the status);
// here the code is also rewritten so a CARD_DECLINED decline can be produced.
func structuredSquareErrorWithCode(t *testing.T, status int, code string) error {
t.Helper()
mc := square.NewDevClient().(*square.MockClient)
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 1000,
Currency: "GBP",
SourceID: "ccof:card_1",
})
require.Error(t, err, "expected the mock to reject a ccof charge without a customer")
v := reflect.ValueOf(err)
require.Equal(t, reflect.Ptr, v.Kind(), "expected the structured error to be a pointer")
clone := reflect.New(v.Elem().Type())
clone.Elem().Set(v.Elem())
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
clone.Elem().FieldByName("Code").SetString(code)
return clone.Interface().(error)
}
func TestCreateBookingPayment_ChargeFailureStatuses_KeepPending(t *testing.T) {
t.Run("structured_500_returns_503_keeps_pending", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
defer func() { SquareClient = origClient }()
cardToken := "cnon:r7-structured-500"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "r7-500-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusServiceUnavailable, w.Code, "a structured Square 500 must classify as 503, body: %s", w.Body.String())
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
require.Equal(t, "pending", status, "an ambiguous charge failure must leave the row pending for a same-key retry")
})
t.Run("card_declined_returns_402_keeps_pending", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
cardToken := "cnon:r7-card-declined"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "r7-declined-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, "a definitive decline (CARD_DECLINED 4xx) must classify as 402, body: %s", w.Body.String())
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
require.Equal(t, "pending", status, "a declined charge must leave the row pending")
})
t.Run("cancelled_context_returns_503_keeps_pending", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
entered := make(chan struct{})
origClient := SquareClient
SquareClient = &round7CancelAwareChargeClient{SquareClient: square.NewDevClient(), entered: entered}
defer func() { SquareClient = origClient }()
reqCtx, cancel := context.WithCancel(ctx)
defer cancel()
cardToken := "cnon:r7-cancel-ctx"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "r7-cancel-" + bookingID,
}
done := make(chan *httptest.ResponseRecorder, 1)
go func() {
done <- makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, reqCtx)
}()
select {
case <-entered:
// The charge reached Square with the DB pending record already
// committed — cancel now.
case <-time.After(10 * time.Second):
t.Fatal("the charge never reached Square")
}
cancel()
w := <-done
require.Equal(t, http.StatusServiceUnavailable, w.Code, "a cancelled charge context must classify as 503, body: %s", w.Body.String())
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
require.Equal(t, "pending", status, "a cancelled-context charge must leave the row pending")
})
}
// =============================================================================
// R7 — deriveBookingPaymentIdempotencyKey >45-char sha256 truncation
// =============================================================================
// TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic
// locks the >45-char truncation: a candidate key built from a very long
// bookingID + card must be hashed down to a deterministic ≤45-char key, and
// the truncation must be input-sensitive (the seq-0 truncated key is distinct
// from the seq-1 candidate's key — distinct inputs never collapse).
func TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// A bookingID far longer than Square's 45-char idempotency-key limit.
longBookingID := "pay-" + strings.Repeat("b", 60)
cardPart := "ccof:some-long-card-id"
base := fmt.Sprintf("pay-%s-%s-%d-%s", longBookingID, "deposit", 2500, cardPart)
key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
if len(key1) > 45 {
t.Errorf("the derived key must stay within Square's 45-char limit, got %d chars: %q", len(key1), key1)
}
require.True(t, strings.HasPrefix(key1, "pay-"), "the truncated key must keep the pay- prefix, got %q", key1)
// Deterministic: an identical re-derivation returns the same key.
key2, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
require.Equal(t, key1, key2, "the truncated key must be deterministic")
// The truncation hashes the FULL candidate: verify the seq-0 key against
// the sha256[:16] derivation and confirm it differs from the seq-1 key.
h0 := sha256.Sum256([]byte(base))
wantSeq0 := fmt.Sprintf("pay-%x", h0[:16])
require.Equal(t, wantSeq0, key1, "the seq-0 truncated key must be sha256[:16] of the full candidate")
h1 := sha256.Sum256([]byte(base + "-1"))
wantSeq1 := fmt.Sprintf("pay-%x", h1[:16])
require.NotEqual(t, wantSeq1, key1, "the seq-0 truncated key must be distinct from the seq-1 truncated key")
}
// TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct locks the
// sequence-advance path under truncation: when the seq-0 truncated key is
// occupied by a REFUNDED completed payment (so it can no longer be replayed),
// the derive advances to a seq-1 truncated key that is distinct from seq-0 —
// two different inputs must never collide in the truncated namespace.
func TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
require.NoError(t, err)
// A real CHAR(12) bookingID plus a long card ID pushes the candidate over
// 45 chars while the booking_id stays insertable in the payments table.
cardPart := "ccof:super-long-card-on-file-id-00000001"
key0, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
if len(key0) > 45 {
t.Errorf("the seq-0 truncated key must stay within 45 chars, got %d: %q", len(key0), key0)
}
// Occupy the seq-0 slot with a REFUNDED completed payment so the derive
// must advance to seq 1 (a refunded completed row never blocks a new
// equal-amount charge).
var paymentID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 'completed', 25.00, $2, NOW(), NOW())
RETURNING id
`, bookingID, key0).Scan(&paymentID))
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 25.00, 'completed', 'test refund', NOW())
`, paymentID, bookingID)
require.NoError(t, err)
key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
require.NotEqual(t, key0, key1, "the seq-1 truncated key must be distinct from the seq-0 key")
if len(key1) > 45 {
t.Errorf("the seq-1 truncated key must stay within 45 chars, got %d: %q", len(key1), key1)
}
}