//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) } }