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.
248 lines
10 KiB
Go
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)
|
|
}
|
|
}
|