//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 TestGetBookingRemainingBalancePence_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") }