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.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
// TestCompleteActiveBookingFromPayment_RefusesCancelledBooking locks the M2
|
||||
// money-safety boundary of the sweep rescue's completion side-effect: a
|
||||
// cancelled / lapsed / no-show booking must NEVER be auto-completed by the
|
||||
// payment path — a charge landing on such a booking is failed + auto-refunded
|
||||
// by the sweep's F3 gate, and completing the booking would record money against
|
||||
// a booking the cancellation flow already closed.
|
||||
func TestCompleteActiveBookingFromPayment_RefusesCancelledBooking(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set booking we_cancelled: %v", err)
|
||||
}
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
completeActiveBookingFromPayment(ctx, pgxTx, bookingID)
|
||||
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "we_cancelled" {
|
||||
t.Errorf("expected the cancelled booking NOT auto-completed, got %q", status)
|
||||
}
|
||||
// The setup tx is rolled back at test end, so no pool-level cleanup is needed.
|
||||
}
|
||||
|
||||
// TestCompleteFullyPaidBooking_CompletesPayableBooking locks the sweep rescue's
|
||||
// completion side-effect (applyStaleRescueRecords → bookingIsFullyPaid →
|
||||
// completeActiveBookingFromPayment): a PAYABLE booking fully covered by
|
||||
// completed real money is completed by the rescue-completion path, exactly as
|
||||
// the live payment path would.
|
||||
func TestCompleteFullyPaidBooking_CompletesPayableBooking(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||
|
||||
// A full £50 completed payment covers the £50 booking total.
|
||||
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create completed payment: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pool := context.Background()
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, payID)
|
||||
_, _ = 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)
|
||||
})
|
||||
|
||||
completeFullyPaidBooking(pool, bookingID)
|
||||
|
||||
var status string
|
||||
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected the fully-paid payable booking completed, got %q", status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user