Files
Crussell/backend/handlers/payments/completion_idempotency_test.go
popertots 2cdbad0cea feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).

- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
  charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
  a non-empty Square verification_token (charge surfaces, token forwarded to
  Square) skips the gate; anything else is refused 402 verification_required.
  enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
  where the token IS forwarded to Square (charge — Square validates it) from
  card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
  must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
  one-time tokenize-result sends the token as the charge SOURCE (new_card_token
  -> source_id) alongside saved_card_id, never a separate verification_token.
  resolveChargeSource resolves the saved-card branch FIRST (customer from the
  card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
  tip/till/gift-card charge requests, enforced server-side before any fallback
  charge could reach Square and recorded on the 2fa_fallback_charge audit row;
  logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
  (twoFAPepperConfigured / twoFADeliveryChannelConfigured /
  twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
  exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
  .env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
  validation on save vs charge surfaces, completion idempotency, idempotency
  key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
2026-08-22 00:34:50 +01:00

248 lines
10 KiB
Go

//go:build test && dev
package payments
// M15 webhook/sweep completion-asymmetry tests. A Square payment.updated
// webhook (handlers/webhooks/square.go handlePaymentUpdated) and the stale
// pending-payment sweep's rescue path can both try to complete the same
// payment row: the webhook flips `payments.status` with a
// `WHERE ... AND status = 'pending'` guard, and the sweep's rescue
// (rescueStaleRowCompletedTx) flips the row with its own
// `WHERE id = ... AND status = 'pending'` guard before applying the split /
// VAT / fully-paid-booking completion side effects. Both guards make the
// completion idempotent — the first writer wins, the second matches zero rows
// and applies NO side effects. These tests lock that invariant in both race
// orderings. Sequential (no t.Parallel): they swap the package-global
// SquareClient and mutate the shared pool, like the other sweep tests.
import (
"context"
"database/sql"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestSweepRescueThenWebhook_CompletesExactlyOnce locks the sweep-first race
// ordering: the sweep rescues a stale pending payment to 'completed' and
// applies the booking-completion side effects exactly once; a webhook-style
// idempotent status flip (the exact `WHERE ... AND status = 'pending'` UPDATE
// handlePaymentUpdated runs) that arrives AFTER the rescue matches zero rows,
// and a re-run of the sweep also does nothing — the side effects are never
// doubled.
func TestSweepRescueThenWebhook_CompletesExactlyOnce(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
// Give the booking a payable total so the rescue's fully-paid check can
// complete it.
if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking total: %v", err)
}
// VAT-registered — the rescue applies VAT to the split records.
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)
}
payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", payID); err != nil {
t.Fatalf("failed to age the payment: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 200000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-asymmetry-completed",
})
if err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, payID); err != nil {
t.Fatalf("failed to set square_payment_id: %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)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
_, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
})
// 1. The sweep rescues the stale pending row (Square reports COMPLETED).
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var payStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", payID).Scan(&payStatus); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if payStatus != "completed" {
t.Fatalf("expected the sweep to rescue the payment to 'completed', got %q", payStatus)
}
// The rescue must have completed the booking (fully paid) exactly once.
var bookingStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if bookingStatus != "completed" {
t.Errorf("expected the sweep rescue to complete the fully-paid booking, got %q", bookingStatus)
}
// Exactly the deposit + balance split records exist — no duplicates.
var recordCount int
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
t.Fatalf("failed to count payment records: %v", err)
}
if recordCount != 2 {
t.Errorf("expected exactly 2 split payment records after the rescue, got %d", recordCount)
}
// VAT applied on both split records.
var vatRows int
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND vat_amount IS NOT NULL", bookingID).Scan(&vatRows); err != nil {
t.Fatalf("failed to count VAT'd records: %v", err)
}
if vatRows != 2 {
t.Errorf("expected VAT applied to both split records exactly once, got %d records with VAT", vatRows)
}
// 2. The webhook's completion path arrives AFTER the rescue: its pending-
// only UPDATE matches zero rows (idempotent status flip).
tag, err := db.Conn.Exec(pool, `
UPDATE payments SET status = 'completed', updated_at = NOW()
WHERE square_payment_id = $1 AND status = 'pending'
`, pay.SquarePayID)
if err != nil {
t.Fatalf("webhook-style update failed: %v", err)
}
if int(tag.RowsAffected()) != 0 {
t.Errorf("expected the post-rescue webhook completion to match 0 pending rows, got %d", tag.RowsAffected())
}
// 3. A re-run of the sweep finds nothing pending — no second rescue, no
// second completion, no duplicate records.
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("second sweep failed: %v", err)
}
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
t.Fatalf("failed to re-count payment records: %v", err)
}
if recordCount != 2 {
t.Errorf("expected the second sweep to add no payment records, got %d", recordCount)
}
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to re-query booking: %v", err)
}
if bookingStatus != "completed" {
t.Errorf("expected the booking to stay completed after the second sweep, got %q", bookingStatus)
}
var stampAwarded sql.NullTime
if err := db.Conn.QueryRow(pool, "SELECT loyalty_stamp_awarded_at FROM bookings WHERE id = $1", bookingID).Scan(&stampAwarded); err != nil {
t.Fatalf("failed to query loyalty stamp marker: %v", err)
}
if !stampAwarded.Valid {
t.Error("expected the loyalty stamp awarded exactly once by the single completion")
}
}
// TestWebhookThenSweepRescue_CompletesExactlyOnce locks the webhook-first race
// ordering: the webhook's pending-only status flip completes the payment
// first, so the sweep finds no pending row left to rescue and applies NO
// side effects — the booking is not double-completed and no split/VAT records
// are minted by the sweep.
func TestWebhookThenSweepRescue_CompletesExactlyOnce(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking total: %v", err)
}
payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_webhook_first' WHERE id = $1", payID); err != nil {
t.Fatalf("failed to age the payment: %v", err)
}
origClient := SquareClient
SquareClient = square.NewDevClient()
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)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
// 1. The webhook flips the pending row to completed first.
tag, err := db.Conn.Exec(pool, `
UPDATE payments SET status = 'completed', updated_at = NOW()
WHERE square_payment_id = 'sqp_webhook_first' AND status = 'pending'
`)
if err != nil {
t.Fatalf("webhook-style update failed: %v", err)
}
if int(tag.RowsAffected()) != 1 {
t.Fatalf("expected the webhook-style completion to match exactly 1 pending row, got %d", tag.RowsAffected())
}
// 2. The sweep rescue path arrives after: the row is no longer pending, so
// it is never fetched and NO side effects run.
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var recordCount int
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
t.Fatalf("failed to count payment records: %v", err)
}
// Exactly ONE row — the webhook only flipped status; the sweep's split
// logic must not have run (the rescue is gated on the row still pending).
if recordCount != 1 {
t.Errorf("expected the sweep to add no records after the webhook completed the row, got %d", recordCount)
}
var bookingStatus string
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if bookingStatus != "in_progress" {
t.Errorf("expected the booking untouched by the sweep (no double-completion), got %q", bookingStatus)
}
}