SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.
BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
(SCA primary); no token => 2FA fallback requires delivery channel + consume +
insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
{sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
customer_initiated=false (merchant-initiated, no SCA, no liability shift);
customer-initiated online flows keep true
FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
(booking/tip/till/gift-card/admin); 'waiting for approval in your banking
app' state on admin surfaces; 2FA backup-only UX in the shared composable
MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
grandfathered; frontend helper tests
DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).
26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
750 lines
33 KiB
Go
750 lines
33 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"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. 409 (Square IDEMPOTENCY_KEY_REUSED — a key reused with a different
|
|
// request body) is a definitive client error and must stay 402, not fall into
|
|
// the ambiguous bucket.
|
|
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 (idempotency-key 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
// 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)
|
|
}
|
|
}
|