Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
474 lines
17 KiB
Go
474 lines
17 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")
|
|
}
|
|
|
|
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")
|
|
}
|