//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) } }