- till.go: the saved-card till charge carries the C6 consent fields and enforces them on the (unreachable) 2FA fallback path; the card ownership SELECT became owner-agnostic with the owner read at the gate (F1 — no charge surface can act on a card it does not own); a till sale's gift-card creation/top-up now runs under the SAME per-admin daily-cap advisory lock as the admin API surfaces (F5) so two concurrent distinct sales cannot overshoot the £5,000 day ceiling; money-F4: an expired gift card can never be topped up (expiry gate mirrors RedeemGiftCard's DB-clock comparison) — the top-up would otherwise resurrect a card the nightly cleanup already forfeited. - charge_helpers_test.go: TestResolveChargeSource_SCATokenizeResult_UsesTokenAsSource pins the SCA tokenize-result wire contract (token as source, card row for the customer). - errors_test.go: token-less saved-card charges are refused 402 verification_required under 2FA enforcement (create-payment, gift-card buy + save-card), even for a user with 2FA enabled — the homegrown gate can never substitute for SCA.
916 lines
42 KiB
Go
916 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, persisting a card
|
|
// is refused 402 verification_required (SCA-only — the 2FA fallback was
|
|
// removed) and no card row is created — the save-card endpoint is not an
|
|
// un-gated side door.
|
|
func TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_402(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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 verification_required when 2FA is enforced (SCA-only), got %d: %s", 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"])
|
|
|
|
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 refused token-less save must not persist a card, got %d rows", cardCount)
|
|
}
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Blocked verifies that even
|
|
// a VALID 2FA code cannot save a card through the add-card endpoint when
|
|
// enforced (SCA-only — the homegrown 2FA fallback was removed entirely).
|
|
func TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Blocked(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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 verification_required even with a valid 2FA code (SCA-only), 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 != 0 {
|
|
t.Errorf("a valid 2FA code must not persist a card (SCA-only), got %d", cardCount)
|
|
}
|
|
}
|
|
|
|
// TestTwoFactorEnforced_BuyGiftCard_SaveCard_Tokenless_402 verifies the H4 gate
|
|
// fires on the gift-card purchase path too: BuyGiftCard with req.SaveCard=true
|
|
// is refused 402 verification_required (SCA-only) when enforced, mirroring
|
|
// CreatePaymentMethod/CreateBookingPayment. The purchase is rejected BEFORE any
|
|
// payment row is inserted.
|
|
func TestTwoFactorEnforced_BuyGiftCard_SaveCard_Tokenless_402(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.StatusPaymentRequired {
|
|
t.Fatalf("expected 402 for BuyGiftCard with SaveCard=true without SCA, got %d: %s", 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"])
|
|
|
|
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 refused 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)
|
|
}
|
|
}
|