Expand payment test coverage: lock contention, nonce-direct, provisional rows, GDPR scrub, validators

Close the coverage-gap round: terminal CreateCheckout-failure marks the provisional row failed, GetCheckoutStatus reference_id mismatch 400, deadline wire shape, concurrent loyalty redemption 409, delete_guest_user + stale-guest saved-card scrubbing, ValidateAmount and isTokenLike direct units, bounded try-lock timeout, buildSplitRecords tip-overflow, and concurrent same-key dedup for gift card / booking / tip / checkout.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 0d22f8d597
commit 738f6b6a51
7 changed files with 710 additions and 18 deletions
@@ -423,3 +423,99 @@ func TestGetCheckoutStatus_ConcurrentPolls_SingleRecord(t *testing.T) {
t.Errorf("expected exactly 1 terminal payment record, got %d (double-record race!)", payCount) t.Errorf("expected exactly 1 terminal payment record, got %d (double-record race!)", payCount)
} }
} }
// TestLoyaltyRedemption_ConcurrentSameBooking_SingleApply proves the loyalty
// redemption advisory lock (loyalty.go): two goroutines redeeming the same
// booking must apply the 10% discount exactly once. One request succeeds (200)
// and the other is rejected by the in-lock re-check ("A loyalty discount has
// already been applied", 400) — never two discount payments for one booking.
func TestLoyaltyRedemption_ConcurrentSameBooking_SingleApply(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupLoyaltyUser(t, ctx, tx, 10)
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
// Commit the setup so both goroutines operate at pool level — the advisory
// lock only serializes across independent connections.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makeApplyRedemptionRequest(bookingID, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
// Exactly one request wins the redemption; the other finds the already
// applied discount inside the lock and is rejected with 400 (the first
// handler completes in milliseconds, well under the ~3s lock bound, so the
// 409 lock-timeout path is not exercised).
okCount, rejectedCount := 0, 0
for i, rec := range recs {
switch {
case rec.Code == http.StatusOK:
okCount++
case rec.Code == http.StatusBadRequest || rec.Code == http.StatusConflict:
rejectedCount++
default:
t.Errorf("request %d unexpected status %d: %s", i, rec.Code, rec.Body.String())
}
}
if okCount != 1 {
t.Errorf("expected exactly 1 successful redemption, got %d", okCount)
}
if rejectedCount != 1 {
t.Errorf("expected exactly 1 rejected redemption, got %d", rejectedCount)
}
// Exactly one loyalty discount and one discount payment row.
var discountCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'`, bookingID).Scan(&discountCount); err != nil {
t.Fatalf("failed to count loyalty discounts: %v", err)
}
if discountCount != 1 {
t.Errorf("expected exactly 1 loyalty discount, got %d (double-apply!)", discountCount)
}
var payCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count discount payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 discount payment, got %d", payCount)
}
// The redemption is applied exactly once.
var appliedCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM loyalty_redemptions WHERE user_id = $1 AND status = 'applied'`, userID).Scan(&appliedCount); err != nil {
t.Fatalf("failed to count applied redemptions: %v", err)
}
if appliedCount != 1 {
t.Errorf("expected exactly 1 applied redemption, got %d", appliedCount)
}
// Clean up the committed loyalty_redemptions row (cleanupConcurrentTestRows
// does not cover it; user_id is SET NULL by the FK so it would otherwise leak).
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM loyalty_redemptions WHERE user_id = $1 OR applied_to_booking_id = $2`, userID, bookingID)
})
}
@@ -809,3 +809,62 @@ func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) {
t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount) t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount)
} }
} }
// mismatchedRefCheckoutClient forces GetCheckout to return a fixed COMPLETED
// payment whose reference_id points at a DIFFERENT booking, deterministically
// exercising GetCheckoutStatus's ownership check.
type mismatchedRefCheckoutClient struct {
square.SquareClient
result *square.PaymentResult
}
func (c *mismatchedRefCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
return c.result, nil
}
func TestGetCheckoutStatus_ReferenceIDMismatch_Returns400(t *testing.T) {
// The terminal checkout's reference_id must match the booking being
// polled; a checkout that references a different booking is refused with
// 400 so its payment can never be recorded against the wrong booking.
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
origClient := SquareClient
SquareClient = &mismatchedRefCheckoutClient{
SquareClient: square.NewDevClient(),
result: &square.PaymentResult{
ID: "pay_mismatch",
Status: "COMPLETED",
Amount: 5000,
SquarePayID: "pay_mismatch",
ReferenceID: "00000000dead", // a DIFFERENT booking
CreatedAt: "2026-07-31T00:00:00Z",
UpdatedAt: "2026-07-31T00:00:00Z",
},
}
defer func() { SquareClient = origClient }()
checkoutID := "abcd1234ef56" // 12 hex chars, passes the checkout-id validation
req := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", checkoutID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetCheckoutStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for reference_id mismatch, got %d: %s", w.Code, w.Body.String())
}
// No payment may be recorded for the mismatched checkout.
var rowCount int
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount); err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 0 {
t.Errorf("expected no payment rows after reference mismatch, got %d", rowCount)
}
}
@@ -898,9 +898,12 @@ func TestCreateTerminalPayment_RecordInsertFailure_CancelsOrphanedCheckout(t *te
_, bookingID, _ := setupTestData(t, ctx, tx) _, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken() adminToken := jwt.GenerateAdminToken()
// A COMPLETED terminal_checkouts row with the same checkout_id the client // R3: the handler now inserts the tracked row FIRST with a provisional
// will return forces the handler's INSERT to collide on the PK while the // (tmp-) checkout_id, then CreateCheckout, then UPDATEs the row to the real
// active-checkout guard (PENDING/IN_PROGRESS only) does not fire. // Square id. A COMPLETED terminal_checkouts row with the same checkout_id
// the client will return makes that tracking UPDATE collide on the PK while
// the active-checkout guard (PENDING/IN_PROGRESS only) does not fire — the
// checkout is live at Square but untracked, so the handler must cancel it.
const dupCheckoutID = "chk_dup_insert_01" const dupCheckoutID = "chk_dup_insert_01"
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount) INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
@@ -926,3 +929,56 @@ func TestCreateTerminalPayment_RecordInsertFailure_CancelsOrphanedCheckout(t *te
t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", dupCheckoutID, calls) t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", dupCheckoutID, calls)
} }
} }
func TestCreateTerminalPayment_CreateCheckoutFailure_MarksProvisionalRowFailed(t *testing.T) {
// R3: the handler inserts the tracked row FIRST with a provisional
// ("tmp-") checkout_id, THEN calls Square CreateCheckout. When Square fails
// AFTER that insert, the handler must mark the provisional row failed so a
// retry can proceed — otherwise the "tmp-" row (provably pre-Square) wedges
// the booking's in-flight guard forever.
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.FailCreateCheckout = true
SquareClient = mc
defer func() { SquareClient = origClient }()
handler := CreateTerminalPayment
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 when Square CreateCheckout fails, got %d: %s", w.Code, w.Body.String())
}
// The provisional row must have been marked failed, and its checkout_id
// must still be the provisional "tmp-" value (Square never returned a real
// id, so nothing may overwrite it).
var status, checkoutID string
err := tx.QueryRow(ctx, `
SELECT status, checkout_id FROM terminal_checkouts WHERE booking_id = $1
`, bookingID).Scan(&status, &checkoutID)
if err != nil {
t.Fatalf("failed to query terminal checkout row: %v", err)
}
if status != "failed" {
t.Errorf("expected provisional terminal_checkouts row status 'failed', got %q", status)
}
if !strings.HasPrefix(checkoutID, "tmp-") {
t.Errorf("expected checkout_id to remain provisional (tmp- prefix), got %q", checkoutID)
}
// A retry after the failure must be able to create a fresh checkout: the
// in-flight guard must not see the failed row as active.
var activeCount int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
`, bookingID).Scan(&activeCount); err != nil {
t.Fatalf("failed to count active terminal checkouts: %v", err)
}
if activeCount != 0 {
t.Errorf("expected 0 active terminal checkouts after the failure, got %d", activeCount)
}
}
@@ -0,0 +1,347 @@
//go:build test && dev
package payments
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
)
// =============================================================================
// R6: ccof charges must ALWAYS carry a CustomerID; one-off non-save charges
// use the cnon: nonce directly (no card-on-file, no customer).
// =============================================================================
// TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges
// covers the lazy Square-customer provisioning for saved-card rows that
// predate P14 (square_customer_id empty): the row must be provisioned AND the
// provisioned id persisted on the row BEFORE the ccof: charge goes out.
func TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_legacy", "VISA", "4242")
require.NoError(t, err)
// Row is seeded with square_customer_id empty (legacy).
origClient := SquareClient
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
CardID: &cardID,
IdempotencyKey: "legacy-cust-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.NotEmpty(t, got, "a legacy ccof: charge must be provisioned a Square customer before charging")
var persisted string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&persisted))
require.Equal(t, got, persisted, "the provisioned customer id must be persisted on the saved-card row")
}
// TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID covers the save
// path of the new-card flow: the ccof: charge MUST carry the same provisioned
// customer id that CreateCardOnFile used.
func TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec
defer func() { SquareClient = origClient }()
cardToken := "cnon:save-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "save-charge-cust-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
require.NotEmpty(t, cof.lastCustomerID(), "a save-card flow must provision a Square customer")
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) charge must carry the provisioned customer id")
require.NotEmpty(t, got)
}
// TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID is the BuyGiftCard
// counterpart of the above: the gift-card purchase's ccof: charge must carry
// the provisioned customer id.
func TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec
defer func() { SquareClient = origClient }()
newToken := "cnon:save-gift-card-nonce"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: true,
IdempotencyKey: "buy-gc-save-charge-cust",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
rec.mu.Lock()
got := rec.lastReq.CustomerID
rec.mu.Unlock()
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) gift-card purchase must carry the provisioned customer id")
require.NotEmpty(t, got)
}
// =============================================================================
// R2: BuyGiftCard requires an idempotency key
// =============================================================================
func TestBuyGiftCard_MissingIdempotencyKey_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
newToken := "cnon:test-card"
req := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: false,
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "an empty idempotency key must be rejected (R2)")
}
// =============================================================================
// R3: provisional (pre-Square) terminal checkout rows
// =============================================================================
// TestActiveTerminalCheckoutID_ResolvesProvisionalRow covers the crash-window
// guard: a PENDING row with a synthetic "tmp-" checkout_id never had a checkout
// created at Square, so it is provably not live — the guard must mark it failed
// and allow a fresh checkout instead of wedging the booking.
func TestActiveTerminalCheckoutID_ResolvesProvisionalRow(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
provisionalID := "tmp-crash-window-1"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
VALUES ($1, $2, 'full', 'PENDING', 50.00)
`, provisionalID, bookingID); err != nil {
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
}
got := activeTerminalCheckoutID(ctx, bookingID)
require.Equal(t, "", got, "a provisional (pre-Square) row must not block a new checkout")
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
require.Equal(t, "failed", status, "the provisional row must be marked failed")
}
// failingGetCheckoutClient errors on every GetCheckout — if the sweep wrongly
// resolves a provisional row against Square, the row is left pending (the error
// is not a terminal checkout state) and the test fails.
type failingGetCheckoutClient struct {
square.SquareClient
}
func (c *failingGetCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
return nil, fmt.Errorf("GetCheckout was called for %s — provisional rows must resolve without a Square round-trip", checkoutID)
}
// TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare proves
// the sweep resolves a stale provisional row to failed without calling Square.
func TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
provisionalID := "tmp-sweep-provisional-1"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
`, provisionalID, bookingID); err != nil {
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
}
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
// Drop stale rows left by other sweep tests so the count is deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, provisionalID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
origClient := SquareClient
SquareClient = &failingGetCheckoutClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
n, err := SweepStaleTerminalCheckouts(freshCtx)
require.NoError(t, err)
require.Equal(t, 1, n, "the stale provisional terminal checkout must be resolved by the sweep")
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
require.Equal(t, "failed", status)
}
// =============================================================================
// R5: GetDiscountPreviewHandler ownership (IDOR)
// =============================================================================
// TestGetDiscountPreviewHandler_WrongOwner_Forbidden covers the IDOR fix: a
// non-admin user must not read another user's booking discount preview.
func TestGetDiscountPreviewHandler_WrongOwner_Forbidden(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
otherUserID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
otherToken := jwt.GenerateUserToken(otherUserID)
w := serveDiscountPreviewHandler(bookingID, otherUserID, otherToken, ctx)
require.Equal(t, http.StatusForbidden, w.Code, "a non-owner must not read another user's discount preview")
}
// TestGetDiscountPreviewHandler_NoUserContext_Unauthorized covers the fail-closed
// 401 for requests that carry no user id.
func TestGetDiscountPreviewHandler_NoUserContext_Unauthorized(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
GetDiscountPreviewHandler(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
// =============================================================================
// R8: DeletePaymentMethod disables the Square card before the local soft-delete
// =============================================================================
type deletingCardClient struct {
square.SquareClient
mu sync.Mutex
deleted []string
}
func (c *deletingCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
c.mu.Lock()
c.deleted = append(c.deleted, cardID)
c.mu.Unlock()
return c.SquareClient.DeleteCardOnFile(ctx, cardID)
}
func (c *deletingCardClient) deletedIDs() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deleted...)
}
func TestDeletePaymentMethod_DisablesSquareCard(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_disable", "VISA", "9999")
require.NoError(t, err)
origClient := SquareClient
rec := &deletingCardClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
require.Equal(t, []string{"ccof:sq_del_disable"}, rec.deletedIDs(), "the Square card must be disabled on local delete (R8)")
var deletedAt string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
require.NotEqual(t, "", deletedAt, "the card must still be soft-deleted locally")
}
// TestDeletePaymentMethod_SquareFailure_StillDeletesLocally covers the
// best-effort contract: a Square disable failure must never block the local
// soft-delete.
func TestDeletePaymentMethod_SquareFailure_StillDeletesLocally(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_fail", "VISA", "8888")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &failingDeleteClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
var deletedAt string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
require.NotEqual(t, "", deletedAt, "the local soft-delete must proceed even when the Square call fails")
}
type failingDeleteClient struct {
square.SquareClient
}
func (c *failingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return fmt.Errorf("square: network error disabling card %s", cardID)
}
@@ -142,14 +142,15 @@ func TestBuyGiftCard_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T
require.Equal(t, rec.lastCustomerID(), cid, "the stored square_customer_id must match the id passed to CreateCardOnFile") require.Equal(t, rec.lastCustomerID(), cid, "the stored square_customer_id must match the id passed to CreateCardOnFile")
} }
func TestBuyGiftCard_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) { func TestBuyGiftCard_NoSaveCard_ChargesNonceDirectly(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
token := jwt.GenerateUserToken(userID) token := jwt.GenerateUserToken(userID)
origClient := SquareClient origClient := SquareClient
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec SquareClient = rec
defer func() { SquareClient = origClient }() defer func() { SquareClient = origClient }()
@@ -164,8 +165,18 @@ func TestBuyGiftCard_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) {
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 1, rec.callCount()) // R6: a one-off non-save charge uses the cnon: nonce DIRECTLY — no
require.Equal(t, "", rec.lastCustomerID(), "a one-off non-save charge needs no Square customer") // card-on-file is created (nothing to orphan) and no customer is involved.
require.Equal(t, 0, cof.callCount(), "a one-off non-save charge must NOT tokenize via CreateCardOnFile")
rec.mu.Lock()
got := rec.lastReq.SourceID
rec.mu.Unlock()
require.Equal(t, newToken, got, "the nonce itself must be charged directly")
require.Equal(t, "", rec.lastReq.CustomerID, "a cnon: nonce charge carries no 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, "a one-off buy must not persist a saved card")
} }
func TestCreateTillSale_VerificationTokenPassthrough(t *testing.T) { func TestCreateTillSale_VerificationTokenPassthrough(t *testing.T) {
@@ -272,8 +283,8 @@ func TestCreateTillSale_OnlineSquare_NoCustomerProvisioned(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Equal(t, 1, rec.callCount()) require.Equal(t, 0, rec.callCount())
require.Equal(t, "", rec.lastCustomerID(), "the ephemeral till card is a one-off cnon: charge — no Square customer") require.Equal(t, "", rec.lastCustomerID(), "the ephemeral till card is a one-off cnon: charge — no CreateCardOnFile, no Square customer")
} }
// ============================================================================= // =============================================================================
@@ -588,12 +599,13 @@ func TestCreateBookingPayment_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *
require.Equal(t, rec.lastCustomerID(), cid) require.Equal(t, rec.lastCustomerID(), cid)
} }
func TestCreateBookingPayment_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) { func TestCreateBookingPayment_NoSaveCard_ChargesNonceDirectly(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient origClient := SquareClient
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
rec := &recordingPaymentClient{SquareClient: cof}
SquareClient = rec SquareClient = rec
defer func() { SquareClient = origClient }() defer func() { SquareClient = origClient }()
@@ -609,6 +621,12 @@ func TestCreateBookingPayment_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testin
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, 1, rec.callCount()) // R6: a one-off non-save charge uses the cnon: nonce DIRECTLY — no
require.Equal(t, "", rec.lastCustomerID(), "a one-off non-save charge needs no Square customer") // card-on-file is created (nothing to orphan) and no customer is involved.
require.Equal(t, 0, cof.callCount(), "a one-off non-save charge must NOT tokenize via CreateCardOnFile")
rec.mu.Lock()
got := rec.lastReq.SourceID
rec.mu.Unlock()
require.Equal(t, cardToken, got, "the nonce itself must be charged directly")
require.Equal(t, "", rec.lastReq.CustomerID, "a cnon: nonce charge carries no customer")
} }
+86 -5
View File
@@ -2023,6 +2023,52 @@ func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) {
} }
} }
func TestBuildSplitRecords_OverflowBeyondTotal_BecomesTip(t *testing.T) {
// £60 on a £50 future booking: deposit caps at £25 (50%), balance covers
// the remaining £25 owed, and the £10 overflow beyond the booking total
// becomes a tip record.
record := makeTestRecord("b5", "full", 60)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "full", info, 60)
if len(records) != 3 {
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
}
if records[0].Amount != 25 {
t.Errorf("expected deposit amount 25, got %.2f", records[0].Amount)
}
if records[1].PaymentType != "balance" {
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
}
if records[1].Amount != 25 {
t.Errorf("expected balance amount 25, got %.2f", records[1].Amount)
}
if records[2].PaymentType != "tip" {
t.Errorf("expected third record 'tip', got %q", records[2].PaymentType)
}
if records[2].Amount != 10 {
t.Errorf("expected tip amount 10 (overflow beyond the booking total), got %.2f", records[2].Amount)
}
// The tip is a split: zero fees and a derived idempotency key.
if records[2].Fees != 0 {
t.Errorf("expected tip fees=0, got %.2f", records[2].Fees)
}
if *records[2].IdempotencyKey != *records[0].IdempotencyKey+"-split-3" {
t.Errorf("expected tip key derived from primary, got %q", *records[2].IdempotencyKey)
}
// The splits share the Square payment id of the primary record.
if *records[2].SquarePaymentID != *records[0].SquarePaymentID {
t.Error("tip split must share square_payment_id with the primary record")
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Handler-level atomicity — verify the full handler succeeds with split. // Handler-level atomicity — verify the full handler succeeds with split.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3405,7 +3451,7 @@ func TestSaveCardForUser_Success(t *testing.T) {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
svc := NewPaymentService() svc := NewPaymentService()
cardID, err := svc.SaveCardForUser(ctx, userID, "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success") cardID, err := svc.SaveCardForUser(ctx, userID, "cus_test_success", "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success")
if err != nil { if err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
} }
@@ -3418,7 +3464,7 @@ func TestSaveCardForUser_InvalidUserID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_ = tx _ = tx
svc := NewPaymentService() svc := NewPaymentService()
_, err := svc.SaveCardForUser(ctx, "000000000001", "cfa_test", "VISA", "4242", 12, 2030, "fp_test") _, err := svc.SaveCardForUser(ctx, "000000000001", "cus_test_invalid", "cfa_test", "VISA", "4242", 12, 2030, "fp_test")
if err == nil { if err == nil {
t.Error("expected error for non-existent user ID (FK violation)") t.Error("expected error for non-existent user ID (FK violation)")
} }
@@ -3584,8 +3630,6 @@ func TestTerminalPayment_ValidatePaymentTypeFails(t *testing.T) {
} }
} }
func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) { func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -3728,7 +3772,6 @@ func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) {
} }
} }
// TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep // TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep
// also marks stale pending till_sales rows (card payments) as failed, so a // also marks stale pending till_sales rows (card payments) as failed, so a
// lost-response till sale can't stay pending past Square's key retention. // lost-response till sale can't stay pending past Square's key retention.
@@ -3782,3 +3825,41 @@ func TestSweepStalePendingPayments_CoversTillSales(t *testing.T) {
t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus) t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus)
} }
} }
func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) {
// A £60 payment on a £50 future booking with nothing paid yet:
// deposit = min(60, 25) = £25, balance = min(35, 25) = £25,
// tip = 60 - 25 - 25 = £10 — the overflow becomes a separate tip record.
record := makeTestRecord("b-overflow", "full", 60)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildSplitRecords(record, "full", info, 60)
if len(records) != 3 {
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
}
if records[0].PaymentType != "deposit" || records[0].Amount != 25 {
t.Errorf("expected deposit 25, got %q %.2f", records[0].PaymentType, records[0].Amount)
}
if records[1].PaymentType != "balance" || records[1].Amount != 25 {
t.Errorf("expected balance 25, got %q %.2f", records[1].PaymentType, records[1].Amount)
}
if records[2].PaymentType != "tip" || records[2].Amount != 10 {
t.Errorf("expected tip record 10, got %q %.2f", records[2].PaymentType, records[2].Amount)
}
// Tip is an overflow split — zero fees, derived idempotency key -split-2.
if records[2].Fees != 0 {
t.Errorf("expected tip record fees=0, got %.2f", records[2].Fees)
}
wantKey := *record.IdempotencyKey + "-split-3"
if *records[2].IdempotencyKey != wantKey {
t.Errorf("expected tip key %q, got %q", wantKey, *records[2].IdempotencyKey)
}
// All three share the same SquarePaymentID (one charge, three ledger rows).
if *records[2].SquarePaymentID != *record.SquarePaymentID {
t.Error("tip split must share square_payment_id")
}
}
@@ -0,0 +1,35 @@
//go:build test && dev
package payments
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestValidateAmount covers the amount validator used by every payment
// handler: positive and within the £10,000 (1,000,000 pence) cap. Amounts are
// integer pence, so sub-penny "precision" is impossible by construction.
func TestValidateAmount(t *testing.T) {
t.Parallel()
valid := []int64{1, 500, 10000, 999999, 1000000}
for _, amount := range valid {
require.NoErrorf(t, ValidateAmount(amount), "expected %d to be a valid amount", amount)
}
invalid := []int64{0, -1, -500, 1000001}
for _, amount := range invalid {
require.Errorf(t, ValidateAmount(amount), "expected %d to be rejected", amount)
}
// The cap is exclusive: exactly £10,000 (1,000,000 pence) is allowed, one
// penny more is rejected.
if err := ValidateAmount(1000001); err == nil {
t.Error("expected amount above £10,000 cap to be rejected")
}
if err := ValidateAmount(1000000); err != nil {
t.Errorf("expected exactly £10,000 to be allowed, got %v", err)
}
}