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.
346 lines
17 KiB
Go
346 lines
17 KiB
Go
//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")
|
||
}
|