Files
Crussell/backend/handlers/payments/p14_payment_fixes_test.go
T
popertots faceb9809c fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

555 lines
21 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"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/require"
)
// ---------------------------------------------------------------------------
// Test clients
// ---------------------------------------------------------------------------
// fixedCardClient returns the same Square card id for every CreateCardOnFile
// call, simulating a card token that tokenizes to the same Square card for two
// different users (the cross-user saved-card collision scenario).
type fixedCardClient struct {
square.SquareClient
fixedCardID string
}
func (c *fixedCardClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
return &square.CardOnFile{
ID: c.fixedCardID,
CardID: c.fixedCardID,
Brand: "VISA",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
Fingerprint: "sqfp_shared",
}, nil
}
// recordingCustomerClient counts CreateCustomer calls per email and returns a
// deterministic customer id, so tests can assert provisioning happens exactly
// once and the stored id is reused.
type recordingCustomerClient struct {
square.SquareClient
mu sync.Mutex
createCalls []string
customerSeq int
customerByID map[string]*square.CustomerResult
}
func (c *recordingCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.customerByID == nil {
c.customerByID = map[string]*square.CustomerResult{}
}
if existing, ok := c.customerByID[email]; ok {
return existing, nil
}
c.customerSeq++
res := &square.CustomerResult{ID: fmt.Sprintf("cus_mock_%d", c.customerSeq), Email: email}
c.customerByID[email] = res
c.createCalls = append(c.createCalls, email)
return res, nil
}
func (c *recordingCustomerClient) customerCalls() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.createCalls...)
}
// definitiveChargeClient simulates a Square charge rejection that can never
// succeed (declined) — a definitive failure. createErr carries the structured
// CARD_DECLINED error the real client produces (see the construction sites), so
// chargeFailureStatus classifies it as 402 and isDefinitiveChargeFailure claws
// the funded card back.
type definitiveChargeClient struct {
square.SquareClient
createErr error
}
func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, c.createErr
}
// ambiguousChargeClient simulates a transport-level charge failure where Square
// may or may not have processed the payment — an ambiguous failure.
type ambiguousChargeClient struct {
square.SquareClient
createErr error
}
func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, c.createErr
}
// ---------------------------------------------------------------------------
// Cross-user saved-card collision (schema + upsert fix)
// ---------------------------------------------------------------------------
func TestCreatePaymentMethodFromToken_CrossUserSameCard_DoesNotMutateOtherUserRow(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userA, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
userB, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &fixedCardClient{SquareClient: square.NewDevClient(), fixedCardID: "ccof:shared_card"}
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
// User A saves the card, then deletes it (soft delete + retention).
cardA, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
require.NoError(t, err)
require.NoError(t, svc.DeletePaymentMethod(ctx, cardA.ID, userA))
var aDeletedAt string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&aDeletedAt))
require.NotEqual(t, "", aDeletedAt, "user A's card must be soft-deleted")
// User B tokenizes the SAME card. With the old global UNIQUE(square_card_id)
// this upsert targeted A's row — reviving A's deleted card, clearing its
// retention, and returning A's card id to B.
cardB, err := svc.CreatePaymentMethodFromToken(ctx, userB, "cnon:shared")
require.NoError(t, err)
require.NotEqual(t, cardA.ID, cardB.ID, "user B must get their own saved-card row, not user A's")
// Exactly two rows for the shared Square card (one per user).
var rows int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE square_card_id = 'ccof:shared_card'`).Scan(&rows))
require.Equal(t, 2, rows)
// A's row is still owned by A and still deleted — never mutated by B.
var ownerA, deletedA string
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&ownerA, &deletedA))
require.Equal(t, userA, ownerA)
require.NotEqual(t, "", deletedA, "user A's deleted card must not be revived by user B")
// B's row is active and owned by B.
var ownerB, deletedB string
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardB.ID).Scan(&ownerB, &deletedB))
require.Equal(t, userB, ownerB)
require.Equal(t, "", deletedB)
// Same-user retry still revives the deleted row (N-8 preserved): user A
// re-tokenizes the same card → the existing row comes back, not a new one.
cardARetry, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
require.NoError(t, err)
require.Equal(t, cardA.ID, cardARetry.ID, "same-user re-tokenize must revive the existing row")
var revivedDeleted string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&revivedDeleted))
require.Equal(t, "", revivedDeleted)
}
// ---------------------------------------------------------------------------
// Till sale gift-card clawback on definitive charge failure
// ---------------------------------------------------------------------------
func TestCreateTillSale_DefinitiveFailure_ClawsBackCreatedGiftCard(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 = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
IdempotencyKey: "till-clawback-create",
}
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)
// Definitive rejection — the sale is marked failed immediately (not left
// pending for the sweep), so a same-key retry cannot re-complete against a
// gift card that no longer exists.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-create").Scan(&status))
require.Equal(t, "failed", status)
// The created gift card was clawed back (deleted).
var gcCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1
`, "till-clawback-create").Scan(&gcCount))
require.Equal(t, 0, gcCount)
// And its purchase transaction is gone too.
var txCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_card_transactions gct
JOIN till_sales ts ON gct.reference_id = ts.id
WHERE ts.idempotency_key = $1
`, "till-clawback-create").Scan(&txCount))
require.Equal(t, 0, txCount)
}
func TestCreateTillSale_DefinitiveFailure_ClawsBackTopUp(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
var gcID 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(&gcID))
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
GiftCardID: &gcID,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
IdempotencyKey: "till-clawback-topup",
}
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)
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-topup").Scan(&status))
require.Equal(t, "failed", status)
// The top-up was reversed: the card is back to its pre-sale £50.
var remaining float64
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, gcID).Scan(&remaining))
require.Equal(t, 50.00, remaining)
// This request's top-up transaction is gone (prior accounting untouched).
var txCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale'
`, gcID).Scan(&txCount))
require.Equal(t, 0, txCount)
}
func TestCreateTillSale_DefinitiveFailure_ClawsBackRedeemedCard(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
redeemUserID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
RedeemToUserID: &redeemUserID,
IdempotencyKey: "till-clawback-redeem",
}
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 redeemed-to-account credit was reversed.
var balance float64
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1`, redeemUserID).Scan(&balance))
require.Equal(t, 0.00, balance)
}
func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(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 = &ambiguousChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
IdempotencyKey: "till-ambiguous",
}
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.StatusServiceUnavailable, w.Code, "an ambiguous charge failure must classify as 503 (not a definitive 402)")
// Ambiguous failure — the sale stays pending for the sweep, NOT failed.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-ambiguous").Scan(&status))
require.Equal(t, "pending", status)
// The gift card stays funded so a late retry can complete the sale.
var remaining float64
require.NoError(t, 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
`, "till-ambiguous").Scan(&remaining))
require.Equal(t, 50.00, remaining)
}
// ---------------------------------------------------------------------------
// Remaining balance excludes tips
// ---------------------------------------------------------------------------
func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
require.NoError(t, err)
svc := NewPaymentService()
initial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Positive(t, initial)
// £20 partial payment reduces the remaining balance.
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "partial",
PaymentMethod: "cash",
Status: "completed",
Amount: 20.00,
}, nil)
require.NoError(t, err)
afterPartial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, initial-2000, afterPartial)
// A £5 tip must NOT reduce the remaining balance — it is not payment toward
// the booking total.
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "online_square",
Status: "completed",
Amount: 5.00,
}, nil)
require.NoError(t, err)
afterTip, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, afterPartial, afterTip, "a tip must not count toward the paid balance")
}
// ---------------------------------------------------------------------------
// Customer provisioning on save (P14)
// ---------------------------------------------------------------------------
func TestCreatePaymentMethodFromToken_ProvisionsCustomerOnceAndReuses(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
origClient := SquareClient
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
card1, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:visa")
require.NoError(t, err)
card2, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:mastercard")
require.NoError(t, err)
require.Len(t, rec.customerCalls(), 1, "customer must be created exactly once and then reused from the stored id")
var cid1, cid2 string
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card1.ID).Scan(&cid1))
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card2.ID).Scan(&cid2))
require.NotEmpty(t, cid1)
require.Equal(t, cid1, cid2, "both saved cards must share the user's Square customer id")
}
// alwaysNewCustomerClient counts every CreateCustomer call and always returns a
// fresh customer id (unlike recordingCustomerClient, which dedups by email and
// would mask a second mint for the same user).
type alwaysNewCustomerClient struct {
square.SquareClient
mu sync.Mutex
createCalls int
}
func (c *alwaysNewCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
c.mu.Lock()
c.createCalls++
id := fmt.Sprintf("cus_cache_%d", c.createCalls)
c.mu.Unlock()
return &square.CustomerResult{ID: id, Email: email}, nil
}
func (c *alwaysNewCustomerClient) customerCallCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.createCalls
}
// TestInvalidateSquareCustomerCache_DropsCachedID proves the exported cache
// invalidation: after GDPR erasure NULLs the DB square_customer_id and the
// customer is deleted at Square, the process-local cache must not keep serving
// the erased user's stale customer id. Without invalidation a later
// ensureSquareCustomer would return the cached id without re-minting; after
// invalidation it re-queries the (NULLed) DB and mints a fresh customer.
func TestInvalidateSquareCustomerCache_DropsCachedID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// A saved-card row is the persistence point for the customer id; start
// with a NULL square_customer_id so ensureSquareCustomer must mint one.
_, err = tx.Exec(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
VALUES ($1, 'sq_card_cache_test', 'Visa', '4242', 12, 2030, 'fp_cache', true)
`, userID)
require.NoError(t, err)
origClient := SquareClient
rec := &alwaysNewCustomerClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
svc := NewPaymentService()
// 1. First ensure mints a customer and caches it.
c1, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.NotEmpty(t, c1)
require.Equal(t, 1, rec.customerCallCount())
// Simulate GDPR erasure NULLing the saved-card square_customer_id.
_, err = tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = NULL WHERE user_id = $1`, userID)
require.NoError(t, err)
// 2. WITHOUT invalidation the stale cached id is still served (no re-mint).
c2, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.Equal(t, c1, c2, "stale cached customer id must be served when the cache is NOT invalidated")
require.Equal(t, 1, rec.customerCallCount())
// 3. Invalidate, then re-ensure: the entry is gone, so the NULLed DB is
// re-queried and a fresh customer is minted — the stale identity must not
// resurface.
InvalidateSquareCustomerCache(userID)
c3, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.NotEqual(t, c1, c3, "after invalidation a fresh customer must be minted, not the stale cached id")
require.Equal(t, 2, rec.customerCallCount())
}
func TestBuyGiftCard_NoSaveCard_NoCustomerProvisioned(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
newToken := "cnon:test-card"
reqBody := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: false,
IdempotencyKey: "buy-gc-nosave-key",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", reqBody, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Empty(t, rec.customerCalls(), "one-off buy must not provision a Square 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, "one-off buy must not persist a saved card")
}