Files
Crussell/backend/handlers/payments/p14_payment_fixes_test.go
T
popertots 54a5b1024e Implement full Square payment review fixes + frontend polish
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.
2026-08-22 00:34:49 +01:00

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