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

290 lines
14 KiB
Go

//go:build test && dev
package payments
// =============================================================================
// LOOP B — Round-2 money findings. Each test pins a fixed behaviour and would
// fail on the pre-fix code.
// =============================================================================
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// Finding 1 — B1 re-poll race: a webhook-promoted 'completed' sweepdup refund
// whose PARENT payment row is still pending must still be resolved by the B1
// re-poll pass, and the sweep's in-flight guard must keep treating such a
// completed refund as in-flight (never re-replaying the expired key).
// =============================================================================
// seedCompletedB1RefundAndPendingParent seeds a payments-table B1 sweep
// auto-refund row with status 'completed' (as the webhook's COMPLETED
// promotion leaves it) on a still-pending parent payment — the stranded
// state the re-poll pass must resolve.
func seedCompletedB1RefundAndPendingParent(t *testing.T, ctx context.Context, tx db.Querier, userID, bookingID string, amount float64, squareRefundID, refundKey string) (paymentID, refundID string) {
t.Helper()
pid, err := fixtures.CreateTestPayment(tx, bookingID, amount, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create pending parent payment: %v", err)
}
var rid string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
VALUES ($1, $2, $3, $4, 'completed', 'manual', $5, $6, $7, NOW())
RETURNING id
`, pid, bookingID, amount, squareRefundID, sweepDuplicateRefundReason, refundKey, userID).Scan(&rid)
if err != nil {
t.Fatalf("failed to insert webhook-completed B1 refund row: %v", err)
}
return pid, rid
}
// TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight locks the
// in-flight guard widening (sweep.go hasInFlightSweepDuplicateRefund): a B1
// sweepdup refund a webhook promoted to 'completed' — while the parent payment
// row is still pending — must STILL count as in-flight. Before the fix the
// guard matched only 'pending', so the next sweep re-replayed the expired key
// and minted ANOTHER charge before the re-poll pass resolved the parent. The
// failed-refund guard must stay false for a completed (not failed) refund so
// the row is never blind-failed on the replay path.
func TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight(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.CreateTestBooking(tx, userID, serviceID)
require.NoError(t, err)
paymentID, _ := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, "ref_b1_wbhk_inflight", "sweepdup-pay_dup_inflight")
assert.True(t, hasInFlightSweepDuplicateRefund(ctx, "payments", paymentID),
"a webhook-completed sweepdup refund on a pending parent must still count as in-flight")
assert.False(t, hasFailedSweepDuplicateRefund(ctx, "payments", paymentID),
"a completed (not failed) sweepdup refund must NOT trip the failed-refund guard")
}
// TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment locks
// the re-poll query widening (refunds.go sweepPendingB1Refunds): a sweepdup
// refund the webhook promoted to 'completed' — whose PARENT payment row is
// still pending — is re-polled and, when Square confirms the refund COMPLETED,
// the parent is finally marked failed. Before the fix the query matched only
// 'pending' refunds, so the completed refund was never resolved and the parent
// stayed pending forever (feeding the sweep replay loop).
func TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment(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)
const squareRefundID = "ref_b1_wbhk_completed"
const refundKey = "sweepdup-pay_dup_wbhk"
paymentID, refundID := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey)
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx, "no transaction in context")
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "COMPLETED"}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
}
var refundStatus string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus))
assert.Equal(t, "completed", refundStatus, "the webhook-completed refund stays completed")
var parentStatus string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&parentStatus))
assert.Equal(t, "failed", parentStatus, "the pending parent must be resolved to failed once the refund settled")
}
// =============================================================================
// Finding 3 — a Square APPROVED refund is NON-terminal: the call sites must
// keep the refunds row pending (with the square_refund_id recorded) so a later
// FAILED/CANCELED can demote it, instead of resolving it to 'completed' and
// stranding it.
// =============================================================================
// approvedRefundClient answers RefundPayment with Square status APPROVED —
// the ambiguous authorization-only state that must stay pending locally.
type approvedRefundClient struct {
square.SquareClient
}
func (c *approvedRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
return &square.RefundResult{ID: "ref_approved_test", Status: "APPROVED", Amount: req.Amount, PaymentID: req.PaymentID}, nil
}
// TestRefundPayment_ApprovedStatus_LeavesRowPending pins the MED-HIGH finding
// at the RefundPayment handler call site: a Square APPROVED refund is NOT
// terminal — resolving it to 'completed' would strand the row (the FAILED
// demotion only demotes 'pending'). The row must stay 'pending' with the
// square_refund_id recorded, and the response must report 'pending'.
func TestRefundPayment_ApprovedStatus_LeavesRowPending(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)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
require.NoError(t, err)
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed")
require.NoError(t, err)
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_approved' WHERE id = $1", paymentID)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &approvedRefundClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
adminToken := jwt.GenerateTestToken(adminID, "admin")
req := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "approved-refund-" + bookingID}
w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "an APPROVED refund is non-terminal but the handler must still respond 200, body: %s", w.Body.String())
var body RefundResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, "pending", body.Status, "the response must report pending for an APPROVED refund")
var status, sqRefundID string
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_refund_id, '') FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID))
assert.Equal(t, "pending", status, "an APPROVED refund must leave the row pending, never completed")
assert.Equal(t, "ref_approved_test", sqRefundID, "the square_refund_id must be recorded on the pending row so the re-poll can settle it")
}
// =============================================================================
// Finding 6 — guest-booking cash/gift-card terminal charges must write an
// admin_audit_log row (target_user_id NULL) AFTER the money commits.
// =============================================================================
// TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget pins the MEDIUM
// finding: a CASH terminal charge on a GUEST booking (user_id NULL) previously
// wrote NO audit row (the `if customerID.Valid` guard skipped it). The audit
// must run with a NULL target_user_id — matching the till flow — after the
// money transaction commits.
func TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget(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)
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)
// Guest booking: no account behind it.
_, err = tx.Exec(ctx, `UPDATE bookings SET user_id = NULL, status = 'in_progress' WHERE id = $1`, bookingID)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
pm := "cash"
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: &pm,
IdempotencyKey: "guest-cash-audit-" + bookingID,
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a guest cash terminal charge must complete, body: %s", w.Body.String())
var auditCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_audit_log
WHERE action_type = 'admin_cash_charge' AND target_user_id IS NULL
AND details->>'booking_id' = $1
`, bookingID).Scan(&auditCount))
assert.Equal(t, 1, auditCount, "a guest cash charge must audit with a NULL target_user_id")
}
// =============================================================================
// Finding 8 — the A6 skip path (deposit fully covered by discount) must bind
// the request's idempotency key to a row so a same-key retry dedups instead of
// re-running and potentially charging the full deposit.
// =============================================================================
// TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups pins the
// idempotency fix: after a discount-covered deposit skips the Square charge,
// the discount row carries the request's idempotency key. A lost-response
// same-key retry then short-circuits on the completed row — no second skip, no
// Square call, no second discount redemption — where before it re-ran the
// handler and could charge the full deposit once the campaign had exhausted.
func TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
seedActiveCampaign(t, ctx, tx, 100)
origClient := SquareClient
SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t}
defer func() { SquareClient = origClient }()
cardToken := "cnon:deposit-covered-dedup"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "deposit-covered-dedup-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "the discount-covered deposit must complete, body: %s", w.Body.String())
// The skip path bound the request key to the applied discount row.
var keyedDiscountCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND payment_method = 'discount' AND idempotency_key = $2
`, bookingID, req.IdempotencyKey).Scan(&keyedDiscountCount))
assert.Equal(t, 1, keyedDiscountCount, "the request's idempotency key must be bound to the discount row")
// Same-key retry: short-circuits on the completed row — no Square charge,
// no additional discount redemption.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "a same-key retry must dedup to the completed result, body: %s", w2.Body.String())
var discountCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
assert.Equal(t, 1, discountCount, "the retry must not re-apply (double-redeem) the campaign discount")
}