Files
Crussell/backend/handlers/payments/p14_payment_fixes_test.go
T
popertots a8d54f1e2a Fix review findings: aggregated-refund/saved-card/legacy-refund idempotency keys, structured Square error classification, CSP for Square SDK
Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
  set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
  a new row completed against an old smaller refund; >45-char chargeIDs use a
  hashed prefix instead of verbatim truncation (which would collide charges on
  Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
  per-attempt UUID preferred (distinct identical charges no longer collapse),
  deterministic booking+type+amount+card fallback for no-key retry safety.
  PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
  the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
  so a lost-response retry reuses the key and never double-refunds. Wired into
  resumeManualPendingRefund and the sweep's manual-retry loop.

Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
  when present; message-substring matching only for non-structured errors
  (dev mock, client-side status errors). Fixes fragile string-matching driving
  sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
  (was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
  inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
  time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
  payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
  "till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
  Payments SDK + card iframe can tokenize behind the proxy.

Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
2026-08-22 00:34:49 +01:00

550 lines
20 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.
type definitiveChargeClient struct {
square.SquareClient
}
func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/CARD_DECLINED] card declined")
}
// 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
}
func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
// ---------------------------------------------------------------------------
// 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()}
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()}
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()}
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()}
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.StatusPaymentRequired, w.Code)
// 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.GetBookingRemainingBalanceCents(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.GetBookingRemainingBalanceCents(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.GetBookingRemainingBalanceCents(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")
}