Files
Crussell/backend/handlers/payments/payments_review_fixes_test.go
T
popertots 738f6b6a51 Expand payment test coverage: lock contention, nonce-direct, provisional rows, GDPR scrub, validators
Close the coverage-gap round: terminal CreateCheckout-failure marks the provisional row failed, GetCheckoutStatus reference_id mismatch 400, deadline wire shape, concurrent loyalty redemption 409, delete_guest_user + stale-guest saved-card scrubbing, ValidateAmount and isTokenLike direct units, bounded try-lock timeout, buildSplitRecords tip-overflow, and concurrent same-key dedup for gift card / booking / tip / checkout.
2026-08-22 00:34:49 +01:00

633 lines
24 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// BuyGiftCard / CreateTillSale verification_token wiring
// =============================================================================
// recordingCardOnFileClient records the customerID passed to
// CreateCardOnFile (the new P14 4th parameter) so tests can assert save-card
// flows provision the Square customer before tokenizing, while one-off flows
// pass "".
type recordingCardOnFileClient struct {
square.SquareClient
mu sync.Mutex
customerID string
cofCalls int
}
func (c *recordingCardOnFileClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
c.mu.Lock()
c.customerID = customerID
c.cofCalls++
c.mu.Unlock()
return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID)
}
func (c *recordingCardOnFileClient) lastCustomerID() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.customerID
}
func (c *recordingCardOnFileClient) callCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.cofCalls
}
func TestBuyGiftCard_VerificationTokenPassthrough(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
vrf := "vrf_gc_token_789"
newToken := "cnon:test-card"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: false,
IdempotencyKey: "buy-gc-vrf-key",
VerificationToken: &vrf,
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.VerificationToken
rec.mu.Unlock()
require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square")
}
func TestBuyGiftCard_VerificationTokenTooLongRejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
big := strings.Repeat("a", 600)
newToken := "cnon:test-card"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
IdempotencyKey: "buy-gc-vrf-long-key",
VerificationToken: &big,
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestBuyGiftCard_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
newToken := "cnon:test-card"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: true,
IdempotencyKey: "buy-gc-save-cust-key",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 1, rec.callCount())
require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile")
var cid string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid))
require.Equal(t, rec.lastCustomerID(), cid, "the stored square_customer_id must match the id passed to CreateCardOnFile")
}
func TestBuyGiftCard_NoSaveCard_ChargesNonceDirectly(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec
defer func() { SquareClient = origClient }()
newToken := "cnon:test-card"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: false,
IdempotencyKey: "buy-gc-nosave-cust-key",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
// R6: a one-off non-save charge uses the cnon: nonce DIRECTLY — no
// card-on-file is created (nothing to orphan) and no customer is involved.
require.Equal(t, 0, cof.callCount(), "a one-off non-save charge must NOT tokenize via CreateCardOnFile")
rec.mu.Lock()
got := rec.lastReq.SourceID
rec.mu.Unlock()
require.Equal(t, newToken, got, "the nonce itself must be charged directly")
require.Equal(t, "", rec.lastReq.CustomerID, "a cnon: nonce charge carries no customer")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
require.Zero(t, cardCount, "a one-off buy must not persist a saved card")
}
func TestCreateTillSale_VerificationTokenPassthrough(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
vrf := "vrf_till_token_012"
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:visa",
IdempotencyKey: "till-vrf-key",
VerificationToken: &vrf,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.VerificationToken
rec.mu.Unlock()
require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square")
}
func TestCreateTillSale_VerificationTokenTooLongRejected(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
big := strings.Repeat("a", 600)
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:visa",
IdempotencyKey: "till-vrf-long-key",
VerificationToken: &big,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCreateTillSale_OnlineSquare_NoCustomerProvisioned(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:visa",
IdempotencyKey: "till-nosave-cust-key",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 0, rec.callCount())
require.Equal(t, "", rec.lastCustomerID(), "the ephemeral till card is a one-off cnon: charge — no CreateCardOnFile, no Square customer")
}
// =============================================================================
// INSUFFICIENT_FUNDS and other definitive decline codes
// =============================================================================
func TestIsDefinitiveChargeFailure_CoversInsufficientFunds(t *testing.T) {
errs := []error{
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/INSUFFICIENT_FUNDS] insufficient funds"),
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/ADDRESS_VERIFICATION_FAILURE] avs mismatch"),
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/TRANSACTION_LIMIT] limit reached"),
}
for _, err := range errs {
if !isDefinitiveChargeFailure(err) {
t.Errorf("expected %v to be classified as a definitive charge failure", err)
}
}
if isDefinitiveChargeFailure(fmt.Errorf("network error: connection reset by peer")) {
t.Error("expected an ambiguous transport error to NOT be definitive")
}
if isDefinitiveChargeFailure(nil) {
t.Error("expected nil to not be a definitive charge failure")
}
}
// =============================================================================
// GetCheckoutStatus — cancellation recheck before recording a completed payment
// =============================================================================
func TestGetCheckoutStatus_CancelledBooking_RejectsRecord(t *testing.T) {
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: square.NewDevClient(),
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
// Cancel the booking after the checkout was created but before it is polled —
// a terminal payment landing on a cancelled booking must NOT be recorded.
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID); err != nil {
t.Fatalf("failed to cancel booking: %v", err)
}
var w *httptest.ResponseRecorder
assert.Eventually(t, func() bool {
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", checkoutID)
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
if info := extractUserFromTestJWT(adminToken); info != nil {
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
}
statusReq = statusReq.WithContext(statusCtx)
w = httptest.NewRecorder()
GetCheckoutStatus(w, statusReq)
return w.Code == http.StatusConflict
}, 10*time.Second, 100*time.Millisecond, "expected the cancelled-booking checkout to be rejected with 409")
var completedCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount))
require.Zero(t, completedCount, "no completed payment may be recorded on a cancelled booking")
var rowStatus string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&rowStatus))
require.Equal(t, "failed", rowStatus, "the terminal_checkouts row must be marked failed so a fresh charge is possible")
}
// =============================================================================
// activeTerminalCheckoutID — definitively cancelled checkout must not wedge
// =============================================================================
// canceledCheckoutClient makes one checkout report CANCELED at Square (the
// error the real HTTP client produces for a non-COMPLETED, non-PENDING status)
// while delegating everything else to the real mock.
type canceledCheckoutClient struct {
square.SquareClient
checkoutID string
}
func (c *canceledCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
return nil, fmt.Errorf("square: checkout %s is CANCELED (not COMPLETED)", checkoutID)
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
func TestActiveTerminalCheckoutID_ResolvesCanceledCheckout(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
checkoutID := "chk_canceled_12345"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
VALUES ($1, $2, 'full', 'PENDING', 50.00)
`, checkoutID, bookingID); err != nil {
t.Fatalf("failed to seed terminal checkout: %v", err)
}
origClient := SquareClient
SquareClient = &canceledCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID}
defer func() { SquareClient = origClient }()
got := activeTerminalCheckoutID(ctx, bookingID)
require.Equal(t, "", got, "a definitively CANCELED checkout must resolve to \"\" so a new checkout can be created")
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&status))
require.Equal(t, "failed", status, "the canceled checkout row must be marked failed")
}
// =============================================================================
// SweepStaleTerminalCheckouts — terminal_checkouts (booking) coverage
// =============================================================================
func TestSweepStaleTerminalCheckouts_CoversTerminalCheckoutsTable(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.HoldCheckouts = true
checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "chk-stale-terminal-booking",
})
require.NoError(t, err)
SquareClient = mock
defer func() { SquareClient = origClient }()
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
`, checkout.ID, bookingID); err != nil {
t.Fatalf("failed to seed stale terminal checkout row: %v", err)
}
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
// Drop stale rows left by other sweep tests so the count is deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkout.ID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(freshCtx)
require.NoError(t, err)
require.Equal(t, 1, n, "the stale booking terminal checkout must be resolved by the sweep")
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID).Scan(&status))
require.Equal(t, "failed", status)
// The checkout must no longer be PENDING at Square (it was cancelled).
if _, gErr := mock.GetCheckout(freshCtx, checkout.ID); gErr == nil || errors.Is(gErr, square.ErrCheckoutPending) {
t.Errorf("expected checkout %s to be cancelled at Square (no longer pending), GetCheckout err=%v", checkout.ID, gErr)
}
}
// =============================================================================
// Till-sale clawback on pending-retry definitive failure
// =============================================================================
func TestCreateTillSale_PendingRetry_DefinitiveFailure_ClawsBack(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
// Seed a PENDING till_sale whose gift card was already funded by a prior
// attempt of this same sale (the prior charge failed ambiguously). The
// retry's definitive failure must claw the funding back.
key := "till-pending-definitive-clawback-key"
var giftCardID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id
`, adminID).Scan(&giftCardID))
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusPaymentRequired, w.Code)
// The reused sale row must be 'failed' and the previously funded gift card
// clawed back — a definitive failure on retry means the charge can never
// complete, so the funded card must not be left behind (free gift card).
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&status))
require.Equal(t, "failed", status)
var gcCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&gcCount))
require.Zero(t, gcCount, "the funded gift card must be clawed back after a definitive failure on retry")
}
// =============================================================================
// customer_id on saved-card (ccof:) charges
// =============================================================================
func TestCreateBookingPayment_SavedCard_ForwardsCustomerID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_saved", "VISA", "4242")
require.NoError(t, err)
if _, err := tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = 'cus_test_123' WHERE id = $1`, cardID); err != nil {
t.Fatalf("failed to set square_customer_id: %v", err)
}
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
IdempotencyKey: "saved-cust-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.Equal(t, "cus_test_123", got, "a saved-card (ccof:) charge must carry the saved-card row's Square customer id")
}
func TestCreateBookingPayment_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "save-cust-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, 1, rec.callCount())
require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile")
var cid string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid))
require.Equal(t, rec.lastCustomerID(), cid)
}
func TestCreateBookingPayment_NoSaveCard_ChargesNonceDirectly(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec
defer func() { SquareClient = origClient }()
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "nosave-cust-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// R6: a one-off non-save charge uses the cnon: nonce DIRECTLY — no
// card-on-file is created (nothing to orphan) and no customer is involved.
require.Equal(t, 0, cof.callCount(), "a one-off non-save charge must NOT tokenize via CreateCardOnFile")
rec.mu.Lock()
got := rec.lastReq.SourceID
rec.mu.Unlock()
require.Equal(t, cardToken, got, "the nonce itself must be charged directly")
require.Equal(t, "", rec.lastReq.CustomerID, "a cnon: nonce charge carries no customer")
}