Files
Crussell/backend/handlers/payments/money_safety_fixes_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

389 lines
17 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
// =============================================================================
// M7 — a manual refund of a gift-card purchase must never create money
// =============================================================================
// TestRefundPayment_GiftCardPurchase_Rejected locks the direct-handler guard:
// a completed gift-card purchase payment (payments row with NO booking_id)
// must be rejected with a clear message BEFORE any Square call or refund row —
// refunding it at Square would return the cash while the issued card + balance
// credit stay live (money created from nothing).
func TestRefundPayment_GiftCardPurchase_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
// Seed a completed gift-card purchase payment: NO booking_id, Square
// payment id set — exactly what BuyGiftCard inserts.
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'completed', 10.00, 'pay_gc_purchase_refund_1', $1, NOW(), NOW())
RETURNING id
`, userID).Scan(&paymentID)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
req := RefundRequest{Amount: 1000, Reason: "customer changed their mind"}
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
require.Equal(t, http.StatusBadRequest, rec.Code, "a gift-card purchase must be rejected before any Square call, body: %s", rec.Body.String())
if !strings.Contains(rec.Body.String(), "gift-card") {
t.Errorf("expected the rejection message to direct the admin to the gift-card section, got %q", rec.Body.String())
}
// No refund row may be created for the blocked refund.
var refundCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount))
require.Equal(t, 0, refundCount, "no refund row may exist for a rejected gift-card purchase refund")
}
// TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued locks the sweep
// resume path: a pending manual refund for a gift-card purchase payment must
// NEVER be re-issued at Square (the sweep is a bypass of the handler guard).
// The row is reconciled only — a COMPLETED refund at Square resolves it to
// completed; a genuine no-match fails it + admin-notifies.
func TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
const sqPayID = "pay_gc_purchase_sweep_1"
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'completed', 10.00, $1, 'gc-purchase-sweep-key', $2, NOW(), NOW())
RETURNING id
`, sqPayID, userID).Scan(&paymentID)
require.NoError(t, err)
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, NULL, 10.00, 'pending', 'manual refund of gift-card purchase', 'refund-key-1', $2, NOW() - INTERVAL '1 minute', 'manual')
RETURNING id
`, paymentID, adminID).Scan(&refundID)
require.NoError(t, err)
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL AND created_at > NOW() - INTERVAL '1 hour'`)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
})
t.Run("no_completed_refund_at_square_fails_without_reissue", func(t *testing.T) {
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
require.NoError(t, err)
require.Equal(t, 0, n, "a blocked gift-card-purchase refund must not count as money-moved")
if calls := counting.refundCalls(); len(calls) != 0 {
t.Fatalf("expected NO Square refund re-issue for a gift-card purchase, got %d refund call(s)", len(calls))
}
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
require.Equal(t, "failed", status, "a gift-card-purchase manual refund with no Square refund must be failed, never re-issued")
var notifCount int
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`).Scan(&notifCount))
require.Equal(t, 1, notifCount, "the blocked refund must surface an admin notification")
})
t.Run("completed_refund_at_square_resolves_completed", func(t *testing.T) {
// Put the row back to pending (the previous sub-run failed it).
_, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', refund_attempts = 0 WHERE id = $1`, refundID)
require.NoError(t, err)
_, err = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`)
require.NoError(t, err)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Seed a COMPLETED refund at Square directly on the mock (this is the
// historical attempt's money already having moved — NOT a re-issue).
_, err = mock.RefundPayment(freshCtx, square.RefundPaymentReq{
PaymentID: sqPayID,
Amount: 1000,
IdempotencyKey: "seed-refund-gc-purchase",
Reason: "historical",
})
require.NoError(t, err)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
require.NoError(t, err)
require.Equal(t, 1, n, "the reconciled COMPLETED refund resolves the row")
if calls := counting.refundCalls(); len(calls) != 0 {
t.Fatalf("expected NO Square refund re-issue even with a completed refund at Square, got %d refund call(s)", len(calls))
}
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
require.Equal(t, "completed", status, "an exact COMPLETED refund at Square resolves the row to completed")
})
}
// =============================================================================
// M7 — BuyGiftCard no-client-key fallback: retry dedup without collapse
// =============================================================================
// buyGiftCardNoKey issues a BuyGiftCard request without a client-supplied
// idempotency key and returns the response code + body.
func buyGiftCardNoKey(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int, recipientType string) (int, string) {
t.Helper()
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": amount,
"recipient_type": recipientType,
"new_card_token": "cnon:card-nonce-ok",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
// TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge locks the M7 fix:
// a no-client-key purchase whose Square response is lost (charge COMMITTED at
// Square, handler saw a 503, payment row left pending) must, when retried with
// the identical body, re-derive the SAME deterministic fallback key, reuse the
// pending row, and land ONE Square charge — the old fresh-random-suffix
// fallback generated a new key per retry and charged twice.
func TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailAfterCommit = true
counting := &countingPaymentClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
// Attempt 1: Square commits the charge under the derived key but the
// response is lost — the payment row stays pending.
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code == http.StatusCreated {
t.Fatalf("expected the lost-response attempt to fail (charge committed, response lost), got 201: %s", body)
}
mock.FailAfterCommit = false
// Retry: deterministic key reuse + Square dedup → the retry succeeds.
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
t.Fatalf("expected the retry to succeed, got %d: %s", code, body)
}
// Exactly ONE payment row, ONE idempotency key, ONE issued card.
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
require.Equal(t, 1, payCount, "exactly one payment row for a lost-response retry")
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 1, keyCount, "both attempts must share ONE deterministic fallback key")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "exactly one gift card issued")
// Both Square calls reused the SAME key; only ONE successful payment exists.
keys := counting.keys
if len(keys) != 2 {
t.Fatalf("expected 2 CreatePayment calls (attempt + retry), got %d", len(keys))
}
if keys[0] != keys[1] {
t.Errorf("expected the retry to reuse the fallback key %q, got %q — a fresh key per retry is the double-charge bug", keys[0], keys[1])
}
if len(counting.payments) != 1 {
t.Errorf("expected exactly ONE successful Square charge, got %d", len(counting.payments))
}
}
// TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges locks the other
// half of the tradeoff: two genuinely DISTINCT no-key purchases (different
// amounts) must diverge onto distinct deterministic keys and issue two charges.
func TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
for _, amount := range []int{2000, 5000} {
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, amount, "self"); code != http.StatusCreated {
t.Fatalf("expected 201 for £%d purchase, got %d: %s", amount/100, code, body)
}
}
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 2, keyCount, "two different-amount no-key purchases must get distinct keys")
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
require.Equal(t, 2, payCount)
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
require.Equal(t, 2, cardCount)
}
// TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges locks the
// distinct-recipient branch of the tradeoff.
func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
for _, recipient := range []string{"self", "friend"} {
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, recipient); code != http.StatusCreated {
t.Fatalf("expected 201 for %s purchase, got %d: %s", recipient, code, body)
}
}
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 2, keyCount, "different-recipient no-key purchases must get distinct keys")
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
require.Equal(t, 2, payCount)
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
require.Equal(t, 2, cardCount)
}
// =============================================================================
// H4 — a COMPLETED provisional terminal checkout must be recorded, not just
// released
// =============================================================================
// TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment locks the
// H4 fix: when activeTerminalCheckoutID discovers a provisional (tmp-)
// checkout COMPLETED at Square, it must RECORD the payment (mirroring the
// sweep's recordUntrackedTerminalPayment) instead of only marking the row
// COMPLETED and relying on a poll that may never come — a never-polled
// checkout would otherwise leave the charge permanently unrecorded and
// unrefundable via the app.
func TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment(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)
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking in_progress: %v", err)
}
const tmpID = "tmp-completed-no-poll"
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
const sqPayID = "pay_provisional_completed_1"
origClient := SquareClient
SquareClient = &provisionalCheckoutClient{
SquareClient: square.NewDevClient(),
checkoutID: tmpID,
result: &square.PaymentResult{
Status: "COMPLETED",
SquarePayID: sqPayID,
Amount: 5000,
Fees: 88,
CardBrand: "VISA",
CardLast4: "4242",
ReferenceID: bookingID,
},
}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
})
// A recorded COMPLETED provisional checkout must release the in-flight
// guard ("" returned), not wedge the booking.
got := activeTerminalCheckoutID(freshCtx, bookingID)
require.Equal(t, "", got, "a recorded COMPLETED provisional checkout must release the in-flight guard")
// The charge must be recorded as a payment row.
var payCount int
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = $2`, bookingID, sqPayID).Scan(&payCount))
require.Equal(t, 1, payCount, "the never-polled COMPLETED provisional charge must be recorded as a payment")
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, tmpID).Scan(&status))
require.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED")
}