Files
Crussell/backend/handlers/payments/sweep_round10_adversarial_test.go
T
popertotsandSisyphus 1d6d3e2f8d test: round-9/10 adversarial suites — sync-path vs webhook completion, gift-card double-refund, sweep VAT, till lock, account lockout + erasure
- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows
- giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund)
- sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge
- webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync
- account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

428 lines
18 KiB
Go

//go:build test && dev
package payments
import (
"context"
"database/sql"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// =============================================================================
// Round 10 — adversarial sweep tests
// =============================================================================
//
// 1. BUG 1 (MAJOR): the sweep's payments-table rescue aligned the primary row
// to the split amount but did NOT clear the pending row's VAT fields, and
// apply_vat_to_payment is guarded on vat_amount IS NULL — so the re-apply
// was a silent no-op and the rescued row kept VAT computed on the FULL
// pre-split charge (a larger base than the split primary). The all-tip
// rescue was worse: the completed primary row carried VAT on the whole
// tip, violating the tip-never-VAT invariant.
// 2. BUG 2 (MAJOR): a keyless till-sale retry locks the derived BASE key while
// its slot scan resolves a SUFFIXED final key that becomes the STORED key
// the sweep locks — the sweep and the retry do not serialize. If the retry
// resolves the stale sale (completes it, or re-keys it) between the sweep's
// fetch and its lock acquisition, the sweep must SKIP the row instead of
// failing/clawing back.
// flipTillSaleClient simulates a same-key till-sale retry racing the sweep: it
// mutates the stale sale's row inside ReplayPaymentByKey (the window between
// the sweep's fetch and its lock acquisition) and then answers the probe with
// the definitive no-charge rejection that would normally drive the sweep's
// fail/clawback. Used with a cnon source so the rejection classifies as
// staleReconcileDefinitivelyFailed (a ccof source would take the A1
// leave-pending path instead).
type flipTillSaleClient struct {
square.SquareClient
t *testing.T
saleID string
// flipTo is the status to set on the row before answering the probe
// ("" = leave the status unchanged).
flipTo string
// newKey is the idempotency_key to set on the row before answering the
// probe ("" = leave the stored key unchanged).
newKey string
}
func (c *flipTillSaleClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*square.PaymentResult, error) {
c.t.Helper()
if c.newKey != "" {
if _, err := db.Conn.Exec(ctx, `UPDATE till_sales SET idempotency_key = $1, updated_at = NOW() WHERE id = $2`, c.newKey, c.saleID); err != nil {
c.t.Fatalf("failed to re-key till sale %s during the sweep reconcile: %v", c.saleID, err)
}
}
if c.flipTo != "" {
if _, err := db.Conn.Exec(ctx, `UPDATE till_sales SET status = $1, updated_at = NOW() WHERE id = $2`, c.flipTo, c.saleID); err != nil {
c.t.Fatalf("failed to flip till sale %s during the sweep reconcile: %v", c.saleID, err)
}
}
return nil, square.ErrReplayKeyNotRetained
}
// seedKeyedStaleTillSale ages a seedStaleTillSaleWithCard sale to 23h (inside
// Square's key-retention window so the keyed pass replays it) with a stored
// idempotency key and a chargeable cnon source.
func seedKeyedStaleTillSale(t *testing.T, ctx context.Context, q db.Querier, adminID, key string) (saleID, giftCardID string) {
t.Helper()
saleID, giftCardID = seedStaleTillSaleWithCard(t, ctx, q, adminID, 50.00, "", true)
if _, err := q.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, saleID); err != nil {
t.Fatalf("failed to age the keyed till sale: %v", err)
}
if _, err := q.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
t.Fatalf("failed to age the gift card: %v", err)
}
return saleID, giftCardID
}
// TestSweepStalePendingPayments_RescuedSplitPrimary_VATOnSplitAmount locks
// BUG 1's payments-table rescue VAT fix: a VAT-registered booking's deposit
// charge that COMPLETED at Square but whose response was lost is rescued by the
// sweep, the primary row is aligned to the split booking portion, and its VAT
// is recomputed on the SPLIT amount — not carried over from the full pre-split
// charge the pending insert taxed. Pre-fix the align left vat_amount from the
// full £80 charge (13.33) on a row aligned to the £50 split (correct 8.33).
func TestSweepStalePendingPayments_RescuedSplitPrimary_VATOnSplitAmount(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Post-start £50 booking: the £80 rescued charge partitions into a £50
// booking portion (the aligned primary) + a £30 overflow tip
// (buildSplitRecords' post-start carve).
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(-2*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
t.Fatalf("failed to enable VAT in business_settings: %v", err)
}
const key = "key-round10-vat-split"
var payID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 'pending', 80.00, $2, 'cnon:test-card', $3, NOW() - INTERVAL '23 hours', NOW())
RETURNING id
`, bookingID, key, userID).Scan(&payID)
if err != nil {
t.Fatalf("failed to seed stale keyed pending payment: %v", err)
}
// Step-1 path parity: the pending insert applied VAT on the FULL pre-split
// £80 charge → vat_amount 13.33. The rescue must recompute on the split £50.
ApplyVATToBookingPayment(ctx, tx, payID)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 8000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: key,
})
if err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = 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)
_, _ = db.Conn.Exec(context.Background(), `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
})
pool := context.Background()
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status, ptype string
var amount float64
var isVAT bool
var vatAmount, vatRate, netAmount sql.NullFloat64
if err := db.Conn.QueryRow(pool, `SELECT status, payment_type, amount, is_vat_applicable, vat_amount, vat_rate, net_amount FROM payments WHERE id = $1`, payID).Scan(&status, &ptype, &amount, &isVAT, &vatAmount, &vatRate, &netAmount); err != nil {
t.Fatalf("failed to query rescued payment: %v", err)
}
if status != "completed" {
t.Errorf("expected the genuinely-charged stale payment rescued to 'completed', got %q", status)
}
if ptype != "deposit" {
t.Errorf("expected the primary row aligned to the split deposit type, got %q", ptype)
}
if amount != 50.00 {
t.Errorf("expected the primary row aligned to the £50 split booking portion, got %.2f", amount)
}
if !isVAT {
t.Errorf("expected is_vat_applicable=TRUE on the rescue-recomputed primary, got false")
}
if !vatAmount.Valid || vatAmount.Float64 != 8.33 {
t.Errorf("expected vat_amount 8.33 (VAT on the £50 split, not 13.33 on the full £80), got %v", vatAmount)
}
if !netAmount.Valid || netAmount.Float64 != 41.67 {
t.Errorf("expected net_amount 41.67 on the £50 split primary, got %v", netAmount)
}
if !vatRate.Valid || vatRate.Float64 != 20.00 {
t.Errorf("expected vat_rate 20.00 on the rescue-recomputed primary, got %v", vatRate)
}
if pay.SquarePayID == "" {
t.Errorf("expected the mock charge to carry a square payment id")
}
// The carved £30 tip record is inserted and must carry NO VAT.
var tipCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed' AND vat_amount IS NULL`, bookingID).Scan(&tipCount); err != nil {
t.Fatalf("failed to count the carved tip record: %v", err)
}
if tipCount != 1 {
t.Errorf("expected exactly one VAT-free carved tip record, got %d", tipCount)
}
}
// TestSweepStalePendingPayments_RescuedAllTip_VATNull locks BUG 1's all-tip
// edge: a fully-paid booking's £55 overflow rescue carves the ENTIRE charge as
// the tip record, the primary row is aligned to it, and a tip must NEVER carry
// VAT. Pre-fix the completed primary kept vat_amount 9.17 (VAT on the £55 tip)
// because the align did not clear it and the tip guard had nothing to skip.
func TestSweepStalePendingPayments_RescuedAllTip_VATNull(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Pre-start £50 booking already fully paid by a completed £50 payment: the
// rescued £55 charge has zero booking obligation left, so buildSplitRecords
// carves the ENTIRE charge as the tip record (the tip-only branch).
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
if _, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed"); err != nil {
t.Fatalf("failed to fully pay the booking: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
t.Fatalf("failed to enable VAT in business_settings: %v", err)
}
const key = "key-round10-all-tip"
var payID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 'pending', 55.00, $2, 'cnon:test-card', $3, NOW() - INTERVAL '23 hours', NOW())
RETURNING id
`, bookingID, key, userID).Scan(&payID)
if err != nil {
t.Fatalf("failed to seed stale keyed pending payment: %v", err)
}
// Step-1 path parity: the pending insert computed VAT on the full £55 →
// vat_amount 9.17. The pre-fix rescue left that on the tip-aligned primary.
ApplyVATToBookingPayment(ctx, tx, payID)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
if _, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5500,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: key,
}); err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = 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)
_, _ = db.Conn.Exec(context.Background(), `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
})
pool := context.Background()
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status, ptype string
var amount float64
var isVAT bool
var vatAmount, vatRate, netAmount sql.NullFloat64
if err := db.Conn.QueryRow(pool, `SELECT status, payment_type, amount, is_vat_applicable, vat_amount, vat_rate, net_amount FROM payments WHERE id = $1`, payID).Scan(&status, &ptype, &amount, &isVAT, &vatAmount, &vatRate, &netAmount); err != nil {
t.Fatalf("failed to query rescued payment: %v", err)
}
if status != "completed" {
t.Errorf("expected the all-tip charge rescued to 'completed', got %q", status)
}
if ptype != "tip" {
t.Errorf("expected the all-tip primary row aligned to the carved tip record, got %q", ptype)
}
if amount != 55.00 {
t.Errorf("expected the primary row aligned to the full £55 tip amount, got %.2f", amount)
}
if isVAT {
t.Errorf("expected is_vat_applicable=FALSE on the all-tip rescue (a tip never carries VAT), got true")
}
if vatAmount.Valid {
t.Errorf("expected vat_amount NULL on the all-tip rescue (a tip never carries VAT), got %v", vatAmount)
}
if vatRate.Valid {
t.Errorf("expected vat_rate NULL on the all-tip rescue, got %v", vatRate)
}
if netAmount.Valid {
t.Errorf("expected net_amount NULL on the all-tip rescue, got %v", netAmount)
}
}
// TestSweepStalePendingPayments_TillStatusFlippedAfterFetch_SkipsClawback locks
// BUG 2's re-read: a stale till sale whose row status flips to 'completed'
// between the sweep's fetch/reconcile and its lock acquisition (a same-key
// retry landed its charge while the sweep waited) must be SKIPPED — the sweep
// must not fail the sale or claw back the funded gift card.
func TestSweepStalePendingPayments_TillStatusFlippedAfterFetch_SkipsClawback(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
pool := context.Background()
saleID, giftCardID := seedKeyedStaleTillSale(t, ctx, tx, adminID, "key-round10-status-flip")
// The reconcile probe answers "no payment under the stored key" BUT flips
// the row to 'completed' first — the retry completed the sale while the
// sweep's probe was in flight, before the sweep's lock acquisition.
origClient := SquareClient
SquareClient = &flipTillSaleClient{SquareClient: square.NewDevClient(), t: t, saleID: saleID, flipTo: "completed"}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
// The sweep must leave the retry-completed row alone.
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "completed" {
t.Errorf("expected the retry-completed till sale left 'completed' (sweep skipped), got %q", status)
}
// The funded gift card must NOT have been clawed back (deleted).
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected the funded gift card untouched when the row flipped to completed mid-sweep, got %d cards", cardCount)
}
}
// TestSweepStalePendingPayments_TillKeyChangedWhilePending_SkipsClawback locks
// the re-read's key-change check — the genuinely money-saving half of BUG 2: a
// stale till sale whose STORED idempotency key is rewritten while the row stays
// 'pending' between the sweep's fetch and its lock acquisition. The sweep's
// lock (on the stale key) no longer matches the row the retry is charging
// under, so the fail/clawback must be skipped: pre-fix the clawback's
// status='pending' claim would still match and DELETE the funded gift card +
// fail the sale while the retry's charge is mid-flight under the new key.
func TestSweepStalePendingPayments_TillKeyChangedWhilePending_SkipsClawback(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
pool := context.Background()
saleID, giftCardID := seedKeyedStaleTillSale(t, ctx, tx, adminID, "key-round10-key-change")
// The reconcile probe answers "no payment under the stored key" BUT rewrites
// the row's stored key first (still 'pending') — a same-key retry re-scanned
// its slot and is charging under the NEW key while the sweep holds a lock on
// the stale one.
const newKey = "key-round10-key-change-new"
origClient := SquareClient
SquareClient = &flipTillSaleClient{SquareClient: square.NewDevClient(), t: t, saleID: saleID, newKey: newKey}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
// The sweep must leave the still-pending row alone (it is reconciled again
// next sweep under its current key) — never claimed to 'failed'.
var status, storedKey string
if err := db.Conn.QueryRow(pool, `SELECT status, idempotency_key FROM till_sales WHERE id = $1`, saleID).Scan(&status, &storedKey); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "pending" {
t.Errorf("expected the re-keyed till sale left 'pending' (sweep skipped the fail/clawback), got %q", status)
}
if storedKey != newKey {
t.Errorf("expected the till sale's stored key untouched at %q, got %q", newKey, storedKey)
}
// The funded gift card must NOT have been clawed back (deleted).
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected the funded gift card untouched when the stored key changed mid-sweep, got %d cards", cardCount)
}
}