Files
Crussell/backend/handlers/payments/errors_test.go
T
popertots dfe856b181 fix: loop-B adversarial (503c326 baseline) — IDEMPOTENCY_KEY_REUSED reclassified ambiguous, 2FA reissue fail-closed alerts, family-cache crash window, consolidation regression checks
Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

- CRITICAL-ish: IDEMPOTENCY_KEY_REUSED (409) no longer classified as a definitive
  402 in chargeFailureStatus — it means the ORIGINAL charge may have landed with
  a different body, so it is now AMBIGUOUS (503): the frontend keeps the same
  idempotency key, the pending row stays rescuable by the sweep (which already
  treated it as ambiguous), and the frontend no longer regenerates the key into
  a possible double charge. SCA verification-required codes remain definitive 402.
- HIGH: reissueTwoFACodeAfterFailedCharge now writes a CRITICAL admin notification
  (insertCriticalPaymentNotification) when issuance is refused (missing pepper /
  unavailable delivery) instead of silently stranding the customer; documented that
  a pepper CHANGE invalidates all pending codes.
- MEDIUM: family-alive cache invalidation crash window documented (invalidate-after-
  commit leaves up to 30s warm on a crash; the near-TTL DB re-check bounds it).
- Consolidation regression checks (8b2fe3b helpers): writeChargeSnapshot guard
  preserved at all sites, postChargeRecheck identical, squareRefundStatusToLocal
  mappings verified, reissue fresh-only semantics confirmed at all 5 call sites.

Verified: 26/26 dev packages, both vet tags, frontend tests + build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

914 lines
42 KiB
Go

//go:build test && dev
package payments
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// structuredSquareAPIError returns an error of the SAME concrete type the real
// Square client produces for structured API errors (the unexported
// *square.squareAPIError, re-stamped with the given HTTP status). The type is
// not nameable outside internal/square and there is no exported constructor,
// so the helper clones the dev mock's real structured 400 error (the only
// package-visible producer) via reflection and rewrites its status code. This
// mirrors the existing test's "build through the mock" style while covering
// status codes the mock cannot produce (429/408/425/422/500).
func structuredSquareAPIError(t *testing.T, status int) error {
t.Helper()
mc := square.NewDevClient().(*square.MockClient)
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 1000,
Currency: "GBP",
SourceID: "ccof:card_1",
})
if err == nil {
t.Fatal("expected the mock to reject a ccof charge without a customer")
}
if square.ErrorStatusCode(err) == 0 {
t.Fatal("expected the mock's ccof rejection to carry a structured status code")
}
v := reflect.ValueOf(err)
if v.Kind() != reflect.Ptr {
t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind())
}
clone := reflect.New(v.Elem().Type())
clone.Elem().Set(v.Elem())
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
return clone.Interface().(error)
}
// TestChargeFailureStatus classifies Square CreatePayment errors into
// 402 (definitive decline) vs 503 (ambiguous) so the charge handlers surface
// retryable failures as 503 (the pending record is resumed on a same-key
// retry) and only definitively-rejected charges as 402.
func TestChargeFailureStatus(t *testing.T) {
ctx := context.Background()
mc := square.NewDevClient().(*square.MockClient)
// The dev mock produces a structured 4xx squareAPIError for a
// card-on-file charge missing its required customer (mirrors a real
// Square 400 INVALID_REQUEST_ERROR) — exercises the definitive-decline
// classification through the real error type.
_, structuredErr := mc.CreatePayment(ctx, square.CreatePaymentReq{
Amount: 1000,
Currency: "GBP",
SourceID: "ccof:card_1",
})
if structuredErr == nil {
t.Fatal("expected the mock to reject a ccof charge without a customer")
}
if square.ErrorStatusCode(structuredErr) == 0 {
t.Fatal("expected the mock's ccof rejection to carry a structured status code")
}
tests := []struct {
name string
err error
want int
}{
{"structured 4xx decline → 402", structuredErr, http.StatusPaymentRequired},
{"plain mock failure (ambiguous) → 503", errors.New("mock: payment declined (simulated failure)"), http.StatusServiceUnavailable},
{"context deadline → 503", context.DeadlineExceeded, http.StatusServiceUnavailable},
{"context cancelled → 503", context.Canceled, http.StatusServiceUnavailable},
{"nil (defensive) → 402", nil, http.StatusPaymentRequired},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := chargeFailureStatus(tt.err); got != tt.want {
t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want)
}
})
}
}
// TestChargeFailureStatus_RetryableCarveOuts locks the 429/408/425 carve-outs:
// those retryable/ambiguous 4xx statuses must classify as 503 (ambiguous —
// retry later), never as the 402 (definitive decline) that the generic 4xx
// branch would produce. True declines (400/422) and 5xx keep their existing
// classifications.
func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) {
tests := []struct {
name string
err error
want int
}{
{"structured 429 rate limited (retryable) → 503", structuredSquareAPIError(t, http.StatusTooManyRequests), http.StatusServiceUnavailable},
{"structured 408 request timeout (ambiguous) → 503", structuredSquareAPIError(t, http.StatusRequestTimeout), http.StatusServiceUnavailable},
{"structured 425 too early (ambiguous) → 503", structuredSquareAPIError(t, http.StatusTooEarly), http.StatusServiceUnavailable},
{"structured 422 unprocessable (definitive) → 402", structuredSquareAPIError(t, http.StatusUnprocessableEntity), http.StatusPaymentRequired},
{"structured 400 bad request (definitive) → 402", structuredSquareAPIError(t, http.StatusBadRequest), http.StatusPaymentRequired},
{"structured 500 server error (ambiguous) → 503", structuredSquareAPIError(t, http.StatusInternalServerError), http.StatusServiceUnavailable},
{"plain error (ambiguous) → 503", errors.New("mock: payment declined (simulated failure)"), http.StatusServiceUnavailable},
{"context deadline (ambiguous) → 503", context.DeadlineExceeded, http.StatusServiceUnavailable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := chargeFailureStatus(tt.err); got != tt.want {
t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want)
}
})
}
}
// TestChargeFailureStatus_DefaultAndEdgeStatuses pins the full status-space
// classification, including the ambiguous DEFAULT branch (1xx/2xx/3xx): the
// default MUST be 503 (ambiguous → retryable) — never 402, which labels a
// definitive decline and suppresses the same-key retry that resumes the pending
// record. A generic 409 (a structured conflict that is NOT the
// IDEMPOTENCY_KEY_REUSED code) is a definitive client error and must stay 402;
// the IDEMPOTENCY_KEY_REUSED code is classified as 503 in the dedicated test
// below (Loop B finding 1 — the original charge may have landed under the
// retained key, so 402 would make the frontend regenerate the key and
// double-charge).
func TestChargeFailureStatus_DefaultAndEdgeStatuses(t *testing.T) {
tests := []struct {
name string
status int
want int
}{
{"0 (plain/transport error) → 503", 0, http.StatusServiceUnavailable},
{"1xx → 503 (ambiguous default)", http.StatusContinue, http.StatusServiceUnavailable},
{"3xx → 503 (ambiguous default)", http.StatusMultipleChoices, http.StatusServiceUnavailable},
{"400 → 402 (definitive)", http.StatusBadRequest, http.StatusPaymentRequired},
{"401 → 402 (definitive)", http.StatusUnauthorized, http.StatusPaymentRequired},
{"403 → 402 (definitive)", http.StatusForbidden, http.StatusPaymentRequired},
{"408 → 503 (retryable)", http.StatusRequestTimeout, http.StatusServiceUnavailable},
{"409 → 402 (generic conflict, definitive)", http.StatusConflict, http.StatusPaymentRequired},
{"425 → 503 (retryable)", http.StatusTooEarly, http.StatusServiceUnavailable},
{"429 → 503 (retryable)", http.StatusTooManyRequests, http.StatusServiceUnavailable},
{"500 → 503", http.StatusInternalServerError, http.StatusServiceUnavailable},
{"503 → 503", http.StatusServiceUnavailable, http.StatusServiceUnavailable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var err error
if tt.status == 0 {
err = errors.New("mock: payment declined (simulated failure)")
} else {
err = structuredSquareAPIError(t, tt.status)
}
if got := chargeFailureStatus(err); got != tt.want {
t.Errorf("chargeFailureStatus(status=%d) = %d, want %d", tt.status, got, tt.want)
}
})
}
}
// TestChargeFailureStatus_IdempotencyKeyReused_Ambiguous pins the Loop B
// CRITICAL-ish finding 1 classification: a structured IDEMPOTENCY_KEY_REUSED
// error (Square retained the key against a DIFFERENT request body — the
// original charge may have landed) is AMBIGUOUS and must classify as 503,
// never 402. A 402 would make the frontend regenerate the idempotency key and
// issue a NEW charge under a fresh key — double-charging the customer when the
// original landed. The check keys on the structured ErrorCode, so BOTH the 400
// (dev mock / real Square) and 409 (real Square) surfaces classify as 503,
// while a generic 409 without the code stays a definitive 402 (covered above).
func TestChargeFailureStatus_IdempotencyKeyReused_Ambiguous(t *testing.T) {
tests := []struct {
name string
err error
want int
}{
{"IDEMPOTENCY_KEY_REUSED 400 → 503", structuredSquareErrorFull(t, http.StatusBadRequest, "IDEMPOTENCY_KEY_REUSED", "INVALID_REQUEST_ERROR"), http.StatusServiceUnavailable},
{"IDEMPOTENCY_KEY_REUSED 409 → 503", structuredSquareErrorFull(t, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED", "INVALID_REQUEST_ERROR"), http.StatusServiceUnavailable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := chargeFailureStatus(tt.err); got != tt.want {
t.Errorf("chargeFailureStatus(%v) = %d, want %d", tt.err, got, tt.want)
}
})
}
}
// TestCreateBookingPayment_AmbiguousSquareFailure_Returns503 verifies the
// charge-failure classification end to end: the dev mock's simulated failure
// is a PLAIN error (no structured Square status), so the handler now returns
// 503 (ambiguous — the pending record stays pending for a same-key retry)
// instead of 402 (which implied a definitive decline).
func TestCreateBookingPayment_AmbiguousSquareFailure_Returns503(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.ShouldFail = true
SquareClient = mc
defer func() { SquareClient = origClient }()
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "ambiguous-503-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for ambiguous mock Square failure, got %d: %s", w.Code, w.Body.String())
}
// The pending record must be left pending (not failed) so a same-key retry
// reuses it instead of creating a second Square charge.
var status string
err := tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status)
if err != nil {
t.Fatalf("failed to query payment status: %v", err)
}
if status != "pending" {
t.Errorf("expected payment status 'pending' after ambiguous failure, got %q", status)
}
}
// =============================================================================
// M3 — till HTTP status classification (till.go CreateTillSale error path)
// =============================================================================
// tillChargeFailureClient injects a Square CreatePayment failure into the till
// sale handler. The embedded client carries every other method so the sale
// setup (gift-card create/commit, advisory locks) runs exactly as in
// production; only CreatePayment is overridden to return the fault.
type tillChargeFailureClient struct {
square.SquareClient
createErr error
}
func (c *tillChargeFailureClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, c.createErr
}
// structuredSquareErrorFull builds a structured *square.squareAPIError of the
// same concrete type the real client produces, re-stamped with an arbitrary
// HTTP status, Square error code, AND error category. The type is not nameable
// outside internal/square, so the clone-through-reflection technique mirrors
// structuredSquareAPIError above (which rewrites only the status code); here
// the Code and Category are also rewritten so a CARD_DECLINED decline or an
// INVALID_REQUEST_ERROR category can be produced for isDefinitiveCardSaveFailure
// classification.
func structuredSquareErrorFull(t *testing.T, status int, code, category string) error {
t.Helper()
mc := square.NewDevClient().(*square.MockClient)
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 1000,
Currency: "GBP",
SourceID: "ccof:card_1",
})
if err == nil {
t.Fatal("expected the mock to reject a ccof charge without a customer")
}
v := reflect.ValueOf(err)
if v.Kind() != reflect.Ptr {
t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind())
}
clone := reflect.New(v.Elem().Type())
clone.Elem().Set(v.Elem())
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
if code != "" {
clone.Elem().FieldByName("Code").SetString(code)
}
if category != "" {
clone.Elem().FieldByName("Category").SetString(category)
}
return clone.Interface().(error)
}
// TestCreateTillSale_DefinitiveDecline_Returns402 covers M3: a definitive
// Square decline (structured CARD_DECLINED) on a fresh online-square till sale
// must surface as 402 (Payment Required) — never 503 — and the funded gift card
// must be clawed back.
func TestCreateTillSale_DefinitiveDecline_Returns402(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)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:till-status-definitive",
IdempotencyKey: "till-status-definitive-key",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
if w.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402 for a definitive CARD_DECLINED till charge, got %d: %s", w.Code, w.Body.String())
}
// Definitive rejection → the sale is marked failed and the funded gift
// card is clawed back (a late retry must not re-complete against it).
var status string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if status != "failed" {
t.Errorf("expected till_sale status 'failed' after a definitive decline, got %q", status)
}
var gcCount int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&gcCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if gcCount != 0 {
t.Errorf("expected the created gift card to be clawed back after a definitive decline, got %d rows", gcCount)
}
}
// TestCreateTillSale_AmbiguousFailure_Returns503 covers M3: an ambiguous
// failure (simulated transport error — a plain error with no structured Square
// status) must surface as 503 (Service Unavailable), NEVER 402: the money state
// at Square is unknown, so the pending sale must stay resumable on a same-key
// retry.
func TestCreateTillSale_AmbiguousFailure_Returns503(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)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: errors.New("mock: payment declined (simulated failure)")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:till-status-ambiguous",
IdempotencyKey: "till-status-ambiguous-key",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for an ambiguous till charge failure, got %d: %s", w.Code, w.Body.String())
}
// Ambiguous failure → the sale stays pending for the stale-pending sweep
// and the gift card stays funded so a late same-key retry can complete it.
var status string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if status != "pending" {
t.Errorf("expected till_sale status 'pending' after an ambiguous failure, got %q", status)
}
var remaining float64
if err := tx.QueryRow(ctx, `
SELECT amount_remaining FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&remaining); err != nil {
t.Fatalf("failed to query gift card balance: %v", err)
}
if remaining != 50.00 {
t.Errorf("expected the gift card to stay funded (£50.00) after an ambiguous failure, got £%.2f", remaining)
}
}
// =============================================================================
// H3 — isDefinitiveCardSaveFailure (handlers.go CreatePaymentMethod path)
// =============================================================================
// TestIsDefinitiveCardSaveFailure pins the H3 classification: a card-save
// failure carrying Square's INVALID_REQUEST_ERROR category (e.g.
// MISSING_REQUIRED_PARAMETER) is DEFINITIVE — the card can never be saved, so
// the attempt must fail immediately (400) instead of being retried as 500. The
// card-on-file creation codes SOURCE_USED / CARD_TOKEN_USED /
// CARD_TOKEN_EXPIRED / INVALID_CARD are definitive too, as are the shared
// definitive charge-decline codes. Generic structured errors (5xx, unknown
// code/category) and plain transport errors are AMBIGUOUS.
func TestIsDefinitiveCardSaveFailure(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"INVALID_REQUEST_ERROR category (MISSING_REQUIRED_PARAMETER) → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_REQUIRED_PARAMETER", "INVALID_REQUEST_ERROR"), true},
{"SOURCE_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "SOURCE_USED", "INVALID_REQUEST_ERROR"), true},
{"CARD_TOKEN_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_USED", "PAYMENT_METHOD_ERROR"), true},
{"CARD_TOKEN_EXPIRED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
{"INVALID_CARD → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "INVALID_CARD", "PAYMENT_METHOD_ERROR"), true},
{"CARD_DECLINED charge code → definitive", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), true},
{"generic structured 500 → ambiguous", structuredSquareErrorFull(t, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "API_ERROR"), false},
{"generic structured 400 unknown code/category → ambiguous", structuredSquareErrorFull(t, http.StatusBadRequest, "SOMETHING_ELSE", "PAYMENT_METHOD_ERROR"), false},
{"plain transport error → ambiguous", errors.New("network error: connection reset by peer"), false},
{"nil → ambiguous", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDefinitiveCardSaveFailure(tt.err); got != tt.want {
t.Errorf("isDefinitiveCardSaveFailure(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// cardSaveFailClient overrides only CreateCardOnFile so a transport-level
// card-save failure can be injected into CreatePaymentMethod without breaking
// the customer-provisioning call that precedes it.
type cardSaveFailClient struct {
square.SquareClient
createCardErr error
}
func (c *cardSaveFailClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
return nil, c.createCardErr
}
// TestCreatePaymentMethod_SourceUsed_Definitive400 drives the H3 classification
// end to end through the add-card handler: Square consumes a cnon: nonce on
// card creation, so reusing it is rejected with a structured 400 SOURCE_USED.
// isDefinitiveCardSaveFailure classifies that as definitive → 400 "Invalid
// request" (the save fails immediately, no retry), NOT 500, and no card row is
// persisted by the failed attempt.
func TestCreatePaymentMethod_SourceUsed_Definitive400(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.SimulateSourceUsed = true
SquareClient = mc
defer func() { SquareClient = origClient }()
handler := CreatePaymentMethod
cardToken := "cnon:reused-source"
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("first save with a fresh nonce must succeed, got %d: %s", w.Code, w.Body.String())
}
// Reusing the consumed nonce → SOURCE_USED (INVALID_REQUEST_ERROR) →
// definitive card-save failure → 400, NOT 500.
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for a definitive SOURCE_USED card-save failure, got %d: %s", w.Code, w.Body.String())
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if count != 1 {
t.Errorf("expected exactly 1 saved card (the failed re-save must not persist a row), got %d", count)
}
}
// TestCreatePaymentMethod_AmbiguousCardSaveFailure_500 drives the H3
// classification the other way: a plain transport error during card tokenization
// carries no structured Square code, so isDefinitiveCardSaveFailure is false and
// the handler returns 500 (retrying with the same inputs might succeed).
func TestCreatePaymentMethod_AmbiguousCardSaveFailure_500(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
SquareClient = &cardSaveFailClient{SquareClient: square.NewDevClient(), createCardErr: errors.New("network error: connection reset by peer")}
defer func() { SquareClient = origClient }()
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:ambiguous-save"}, token, ctx)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for an ambiguous card-save failure, got %d: %s", w.Code, w.Body.String())
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if count != 0 {
t.Errorf("an ambiguous card-save failure must not persist a card, got %d rows", count)
}
}
// =============================================================================
// H4 — 2FA gate on CreatePaymentMethod and BuyGiftCard(SaveCard)
// =============================================================================
// TestIsVerificationRequiredError pins the SCA-challenge classification: the
// four buyer-verification codes must classify as verification-required (so the
// handlers surface the structured 402 body the frontend keys on to trigger the
// 3DS challenge), while a plain decline and CVV re-entry requests must not.
func TestIsVerificationRequiredError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"CARD_DECLINED_VERIFICATION_REQUIRED → true", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), true},
{"VERIFICATION_TOKEN_EXPIRED → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
{"VERIFICATION_TOKEN_INVALID → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_INVALID", "PAYMENT_METHOD_ERROR"), true},
{"MISSING_VERIFICATION_TOKEN → true", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_VERIFICATION_TOKEN", "PAYMENT_METHOD_ERROR"), true},
{"CARD_DECLINED plain decline → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), false},
{"CVV_VERIFICATION_REQUIRED (re-entry, not a 3DS challenge) → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CVV_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), false},
{"INSUFFICIENT_FUNDS → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "INSUFFICIENT_FUNDS", "PAYMENT_METHOD_ERROR"), false},
{"plain transport error → false", errors.New("network error: connection reset by peer"), false},
{"nil → false", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isVerificationRequiredError(tt.err); got != tt.want {
t.Errorf("isVerificationRequiredError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// TestCreateTillSale_SCARequired_ReturnsStructured402 verifies the SCA-required
// surfacing end to end on a till charge: a Square
// CARD_DECLINED_VERIFICATION_REQUIRED failure (the buyer must complete 3DS)
// returns 402 with the structured verification_required body — the frontend
// triggers the challenge — instead of the plain-text "Payment failed".
func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")}
defer func() { SquareClient = origClient }()
req := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:till-sca-required",
IdempotencyKey: "till-sca-required-key",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code")
require.Contains(t, body["error"], "card issuer requires verification")
}
// =============================================================================
// Verification-required surfacing at the OTHER 4 charge sites (booking, tip,
// terminal, gift-card). The till site is covered by
// TestCreateTillSale_SCARequired_ReturnsStructured402 above.
// =============================================================================
func assertStructuredVerificationRequired(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code")
require.Contains(t, body["error"], "card issuer requires verification")
}
func assertPlain402(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
require.NotContains(t, w.Body.String(), "verification_required", "a real decline must stay a plain 402, never the SCA challenge body")
}
// TestVerificationRequiredSurfacing_AllChargeSites drives a Square
// CARD_DECLINED_VERIFICATION_REQUIRED failure through the booking, tip,
// terminal-saved-card, and gift-card charge sites: each must surface 402 with
// the structured {code:verification_required} body (so the frontend triggers
// the 3DS challenge), while a real CARD_DECLINED decline at the same site
// stays a plain 402.
func TestVerificationRequiredSurfacing_AllChargeSites(t *testing.T) {
scaErr := func(t *testing.T) error {
return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")
}
declineErr := func(t *testing.T) error {
return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")
}
installErr := func(t *testing.T, err error) {
t.Helper()
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: err}
t.Cleanup(func() { SquareClient = origClient })
}
t.Run("booking_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
installErr(t, scaErr(t))
cardToken := "cnon:sca-booking"
req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "sca-site-booking"}
assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx))
})
t.Run("booking_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
installErr(t, declineErr(t))
cardToken := "cnon:decline-booking"
req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "decline-site-booking"}
assertPlain402(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx))
})
t.Run("tip_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
installErr(t, scaErr(t))
cardToken := "cnon:sca-tip"
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "sca-site-tip"}
assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx))
})
t.Run("tip_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
installErr(t, declineErr(t))
cardToken := "cnon:decline-tip"
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "decline-site-tip"}
assertPlain402(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx))
})
t.Run("terminal_saved_card_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_sca_terminal", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installErr(t, scaErr(t))
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sca-site-terminal-" + bookingID}
assertStructuredVerificationRequired(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx))
})
t.Run("terminal_saved_card_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_decline_terminal", "VISA", "4242")
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
installErr(t, declineErr(t))
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "decline-site-terminal-" + bookingID}
assertPlain402(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx))
})
t.Run("gift_card_site_sca_required", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
installErr(t, scaErr(t))
cardToken := "cnon:sca-giftcard"
req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "sca-site-giftcard"}
assertStructuredVerificationRequired(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx))
})
t.Run("gift_card_site_plain_decline", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
installErr(t, declineErr(t))
cardToken := "cnon:decline-giftcard"
req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "decline-site-giftcard"}
assertPlain402(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx))
})
}
// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
// having completed 2FA setup, persisting a card is blocked with 403 and no card
// row is created — the save-card endpoint is not an un-gated side door.
func TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:2fa-blocked"}, token, ctx)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 when 2FA is enforced and the user has not enabled it, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
var cardCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if cardCount != 0 {
t.Errorf("a blocked 2FA save must not persist a card, got %d rows", cardCount)
}
}
// TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Succeeds verifies the gate
// lets a user WHO HAS enabled 2FA (and provides a matching one-time code) save
// a card through the add-card endpoint.
func TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Succeeds(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
seedTwoFAPendingCode(t, tx, userID, "778899")
t.Cleanup(func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
})
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:2fa-ok", VerificationCode: "778899"}, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when 2FA is enabled and the code matches, got %d: %s", w.Code, w.Body.String())
}
var cardCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count saved cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected exactly 1 saved card, got %d", cardCount)
}
}
// TestTwoFactorEnforced_BuyGiftCard_SaveCard_Blocked_403 verifies the H4 gate
// fires on the gift-card purchase path too: BuyGiftCard with req.SaveCard=true
// requires 2FA when enforced, mirroring CreatePaymentMethod/CreateBookingPayment.
// The purchase is rejected with 403 BEFORE any payment row is inserted.
func TestTwoFactorEnforced_BuyGiftCard_SaveCard_Blocked_403(t *testing.T) {
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
cardToken := "cnon:2fa-buy-gc"
req := BuyGiftCardRequest{
Amount: 2000,
RecipientType: "self",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-buy-gc-blocked",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for BuyGiftCard with SaveCard=true without 2FA, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE created_by = $1`, userID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 0 {
t.Errorf("a blocked gift-card purchase must not create a payment row, got %d", payCount)
}
}
// =============================================================================
// M7 — ConfirmOverflowTip (handlers.go CreateBookingPayment overflow gate)
// =============================================================================
// TestBookingPayment_Overflow_PostStart_RequiresConfirmation locks B12: even
// on a booking that has STARTED, an overpayment that would become a tip is
// rejected with 400 overflow_tip_confirmation_required unless the client sets
// confirm_overflow_tip — an accidental overpayment (stale amount_due +
// discount preview) must never silently become gratuity. The pre-start
// rejection and the confirmed paths are covered in
// m4_tip_refund_redesign_test.go.
func TestBookingPayment_Overflow_PostStart_RequiresConfirmation(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// setupTestDataPast creates a booking whose start time is 1h ago — a
// post-start booking (the fixture booking total is £50).
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:overflow-post-start"
req := CreateBookingPaymentRequest{
Amount: 6000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "overflow-post-start-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for a post-start overflow without confirmation, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "overflow_tip_confirmation_required") {
t.Fatalf("expected the post-start overflow to require confirmation, body: %s", w.Body.String())
}
// No payment may be recorded for the rejected overflow.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 0 {
t.Errorf("expected 0 completed payments after the rejected post-start overflow, got %d", payCount)
}
// The same overflow WITH confirmation proceeds and carves the £10 excess
// as a tip record (gratuity).
req.ConfirmOverflowTip = true
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 for a confirmed post-start overflow, got %d: %s", w2.Code, w2.Body.String())
}
var tipCount int
var tipAmount float64
if err := tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount); err != nil {
t.Fatalf("failed to query tip records: %v", err)
}
if tipCount != 1 {
t.Errorf("expected exactly 1 tip record for the confirmed post-start overflow, got %d", tipCount)
}
if tipAmount < 9.995 || tipAmount > 10.005 {
t.Errorf("expected the tip to equal the £10 overflow, got %.2f", tipAmount)
}
// The booking portion is the remaining £50 (payment_type='full', the
// original request type — no deposit/balance split post-start).
var bookingPortion float64
if err := tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'full'`, bookingID).Scan(&bookingPortion); err != nil {
t.Fatalf("failed to query booking portion: %v", err)
}
if bookingPortion < 49.995 || bookingPortion > 50.005 {
t.Errorf("expected the booking portion to be £50.00, got %.2f", bookingPortion)
}
}