Files
Crussell/backend/handlers/payments/loop_a_money_fixes_test.go
T
popertots 36887167c6 fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round
Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:

MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance

SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue

DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)

Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
2026-08-22 00:34:50 +01:00

340 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package payments
// =============================================================================
// LOOP A — fresh-review money findings (HIGH-1, HIGH-2, MEDIUM-3, MEDIUM-5,
// LOW-6). Each test pins the fixed behaviour and would fail on the old code.
// =============================================================================
import (
"context"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// HIGH-1 — the overflow→tip guard compares chargeAmount (what Square will
// actually charge and buildSplitRecords will split), not req.Amount against an
// inflated remaining+discount threshold. A pending campaign credit previously
// let a full payment exceed the REAL remaining and silently mint a pre-start
// tip.
// =============================================================================
// TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation locks the HIGH-1
// bypass: a full £60 payment on the £50 fixture booking with a 100% campaign
// eligible (£50 credit) would have passed the old guard (60 < 50+50) and
// silently charged £60, carving a £10 pre-start tip with no confirmation.
// chargeAmount == req.Amount for a 'full' payment, so it exceeds the real £50
// remaining and MUST require confirmation.
func TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedActiveCampaign(t, ctx, tx, 100)
cardToken := "cnon:loop-a-overflow-full"
req := CreateBookingPaymentRequest{
Amount: 6000, // £60 on a £50 booking
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "loop-a-overflow-full-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "a full payment beyond the real remaining must require confirmation even with a discount pending, body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
// No payment record may be written for the rejected overflow.
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record")
}
// TestLoopA_PreStartDepositWithDiscount_NeverMintsUnintendedTip locks the HIGH-1
// deposit side: a deposit charge is net of the campaign credit, so a pre-start
// deposit can never exceed the real remaining and never mints an unconfirmed
// tip. A £60 deposit on the £50 booking with a 20% campaign (£10 credit)
// charges exactly the £50 remaining — no tip record. A separate booking with a
// deposit that WOULD overflow (discounted charge > remaining) still requires
// confirmation.
func TestLoopA_PreStartDepositWithDiscount_NeverMintsUnintendedTip(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedActiveCampaign(t, ctx, tx, 20)
cardToken := "cnon:loop-a-deposit-no-tip"
req := CreateBookingPaymentRequest{
Amount: 6000, // £60 deposit; chargeAmount = £50 (remaining)
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "loop-a-deposit-no-tip-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a deposit whose discounted charge equals the remaining obligation must be accepted, body: %s", w.Body.String())
// No tip may be carved: the discounted charge (£50) is fully booking money.
var tipCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount))
assert.Zero(t, tipCount, "a pre-start deposit-with-discount must never mint a tip")
// A deposit that WOULD overflow into a tip requires confirmation: £61
// deposit → chargeAmount £51 > remaining £50 → confirmation needed.
userID2, bookingID2, _ := setupTestData(t, ctx, tx)
userToken2 := jwt.GenerateUserToken(userID2)
seedActiveCampaign(t, ctx, tx, 20)
req2 := CreateBookingPaymentRequest{
Amount: 6100,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "loop-a-deposit-overflow-" + bookingID2,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID2+"/payment", req2, userToken2, ctx)
require.Equal(t, http.StatusBadRequest, w2.Code, "a deposit whose discounted charge exceeds the remaining must require confirmation, body: %s", w2.Body.String())
assert.Contains(t, w2.Body.String(), "overflow_tip_confirmation_required")
}
// =============================================================================
// HIGH-2 — a deposit-with-discount pending-reuse retry must compare against the
// CHARGE amount stored on the pending row (the discounted amount), not the raw
// req.Amount the frontend resends. Previously every such retry 400'd
// "amount_mismatch" forever.
// =============================================================================
// TestLoopA_DepositWithDiscount_PendingReuseRetry_Succeeds seeds the pending
// row at the DISCOUNTED charge (£15 = £25 deposit £10 campaign credit) and
// retries with the RAW £25 deposit — exactly what the frontend resends. The
// retry must be accepted (chargeAmount recomputes to £15 and matches) and the
// charge completed, not rejected with amount_mismatch.
func TestLoopA_DepositWithDiscount_PendingReuseRetry_Succeeds(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// 20% campaign on the £50 fixture booking = £10 credit → a £25 raw deposit
// charges £15.
seedActiveCampaign(t, ctx, tx, 20)
key := "loop-a-deposit-retry-" + bookingID
// Seed the pending row exactly as the handler's first attempt stored it:
// the CHARGE amount (£15), not the requested £25.
_, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_at, updated_at, created_by)
VALUES ($1, 'deposit', 'online_square', 'pending', 15.00, $2, 'cnon:first-attempt', NOW(), NOW(), $3)
`, bookingID, key, userID)
require.NoError(t, err)
cardToken := "cnon:loop-a-deposit-retry"
req := CreateBookingPaymentRequest{
Amount: 2500, // raw £25 deposit — the frontend resends this
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: key,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a deposit-with-discount pending-reuse retry must succeed, body: %s", w.Body.String())
var status, sqPayID string
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE idempotency_key = $1`, key).Scan(&status, &sqPayID))
assert.Equal(t, "completed", status, "the reused pending row must complete")
assert.NotEmpty(t, sqPayID, "the completed row must carry the Square payment id")
// Exactly one row for the key — the pending row was reused, not duplicated.
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount))
assert.Equal(t, 1, payCount, "the retry must reuse the pending row, not mint a second one")
}
// =============================================================================
// MEDIUM-3 — the stale-pending sweep rescue must mirror the live-path split: an
// overflow beyond the booking's remaining obligation is carved out as a tip
// record (never mis-booked as service revenue) and the fully-paid completion
// check runs.
// =============================================================================
// TestLoopA_SweepRescue_CarvesTipAndCompletes rescues a keyed lost-response
// payment of £60 on the £50 fixture booking via the sweep. The rescue must:
// complete the row, split it into deposit £25 + balance £25 + a carved tip £10,
// and complete the booking (fully paid).
func TestLoopA_SweepRescue_CarvesTipAndCompletes(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, 60.00, "online_square", "full", "pending")
require.NoError(t, err)
const key = "loop-a-sweep-rescue"
_, err = tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:loop-a' WHERE id = $2", key, staleID)
require.NoError(t, err)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 6000,
Currency: "GBP",
SourceID: "cnon:loop-a",
IdempotencyKey: key,
})
require.NoError(t, err, "failed to seed the completed Square payment")
SquareClient = mock
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx, "no transaction in context")
require.NoError(t, pgxTx.Commit(ctx), "failed to commit test tx")
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)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status, sqPayID string
require.NoError(t, db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID))
assert.Equal(t, "completed", status, "the rescued row must complete")
assert.Equal(t, pay.SquarePayID, sqPayID, "the rescued row must carry the replayed square_payment_id")
// The primary row is the deposit portion (£25); the balance and tip are
// separate split rows.
var bookingPortion float64
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip'`, bookingID).Scan(&bookingPortion))
assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must total the £50 obligation (no overflow mis-booked as service revenue)")
var tipCount int
var tipAmount float64
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount))
assert.Equal(t, 1, tipCount, "the £10 overflow must be carved out as a tip record")
assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 overflow")
// The booking was fully paid by the rescue → completed.
var bookingStatus string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
assert.Equal(t, "completed", bookingStatus, "the fully-paid rescue must run the completion side-effects")
}
// =============================================================================
// MEDIUM-5 — the till gift-card create/top-up must go through the same £5,000/
// day admin cap as the admin API surface. The till's own same-day value counts.
// =============================================================================
// TestLoopA_TillGiftCard_DailyCap_Enforced seeds a till_sales row of £4,800
// created by the admin today and verifies a £250 till create is rejected 400
// (would land the day on £5,050 — over the cap; £250 is at the per-transaction
// limit so the daily check is what fires) while a £200 create lands exactly on
// the £5,000 cap and succeeds — pinning the cap as inclusive and the till's own
// value as counted.
func TestLoopA_TillGiftCard_DailyCap_Enforced(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
// A gift card created BEFORE today that the admin topped up at the till
// today for £4,800 — the seeded till_sales row is the day's issued value.
var cardID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at)
VALUES (4800.00, 4800.00, $1, NOW() - INTERVAL '1 day') RETURNING id
`, adminID).Scan(&cardID))
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card topup', 1, 4800.00, 4800.00, 'cash', 'completed',
'loop-a-till-seed', $2, NOW(), NOW())
`, cardID, adminID)
require.NoError(t, err)
// £250 (the per-transaction maximum) would land the day on £5,050 — over
// the £5,000 daily cap.
over := makeTillSaleRequest(t, TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 250.00,
PaymentMethod: "cash",
IdempotencyKey: "loop-a-till-over",
}, adminToken, ctx, tx.(pgx.Tx))
require.Equal(t, http.StatusBadRequest, over.Code, "body: %s", over.Body.String())
assert.Contains(t, over.Body.String(), "£5,000", "the rejection must cite the daily cap")
assert.Contains(t, over.Body.String(), "daily", "the rejection must be the daily-limit message")
// £200 lands the day on EXACTLY £5,000 — inside the cap (inclusive).
ok := makeTillSaleRequest(t, TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 200.00,
PaymentMethod: "cash",
IdempotencyKey: "loop-a-till-ok",
}, adminToken, ctx, tx.(pgx.Tx))
require.Equal(t, http.StatusCreated, ok.Code, "boundary body: %s", ok.Body.String())
}
// =============================================================================
// LOW-6 — expiry is enforced at redemption, not just by the nightly cleanup
// job: a card whose expiry_date has passed cannot be redeemed even before the
// next CleanupExpiredGiftCards run.
// =============================================================================
// TestLoopA_RedeemExpiredCard_Rejected redeems a card whose expiry_date is in
// the past but whose amount_remaining is still live (the nightly job has not
// run yet). The redemption must be rejected 400 and the card left untouched.
func TestLoopA_RedeemExpiredCard_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
var cardID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, expiry_date)
VALUES (20.00, 20.00, NOW() - INTERVAL '1 day') RETURNING id
`).Scan(&cardID))
w := redeemCodeRequest(t, token, tx.(pgx.Tx), cardID)
require.Equal(t, http.StatusBadRequest, w.Code, "an expired card must not be redeemable, body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "expired")
// The card is untouched: balance live, not redeemed, no balance credited.
var remaining float64
var redeemedBy interface{}
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &redeemedBy))
assert.Equal(t, 20.0, remaining, "the expired card's balance must be left untouched")
assert.Nil(t, redeemedBy, "the expired card must not be marked redeemed")
var balanceCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balanceCount))
assert.Zero(t, balanceCount, "no balance may be credited from an expired card")
}