Files
Crussell/backend/handlers/payments/loop_a_money_fixes_test.go
T
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

346 lines
17 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_OverflowRequiresConfirmation locks the
// Loop-B A6 finding on the HIGH-1 deposit side: a deposit-with-discount charge
// is clamped DOWN to the discounted obligation (remaining discount), and the
// overflow guard compares the RAW request against that obligation. A £60
// deposit on the £50 booking with a 20% campaign (£10 credit) requests £60
// against a £40 discounted obligation — it MUST require confirmation (the old
// guard compared the discounted charge against the real £50 remaining, accepted
// it and silently truncated the discount). On confirmation the full £60 is
// charged and the £10 excess (beyond the real remaining) is carved out as a tip
// record — never absorbed as service revenue.
func TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation(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-overflow"
req := CreateBookingPaymentRequest{
Amount: 6000, // £60 deposit; discounted obligation is £40
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "loop-a-deposit-overflow-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "a deposit beyond the discounted obligation must require confirmation, body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
// No payment record may be written for the unconfirmed 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")
// Confirmed: the full £60 is charged and the £10 excess (60 50 real
// remaining) is carved out as a tip record, never absorbed as service
// revenue.
req.ConfirmOverflowTip = true
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "a confirmed deposit overflow must proceed, body: %s", w2.Body.String())
var bookingPortion float64
require.NoError(t, tx.QueryRow(ctx, `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")
var tipCount int
var tipAmount float64
require.NoError(t, tx.QueryRow(ctx, `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 excess must be carved out as a tip record")
assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 excess")
}
// =============================================================================
// 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")
}