Files
Crussell/backend/handlers/payments/completion_test.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

366 lines
16 KiB
Go

//go:build test && dev
package payments
import (
"context"
"sync"
"testing"
"time"
"crussell/db"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCompleteActiveBookingFromPayment_RefusesCancelledBooking locks the M2
// money-safety boundary of the sweep rescue's completion side-effect: a
// cancelled / lapsed / no-show booking must NEVER be auto-completed by the
// payment path — a charge landing on such a booking is failed + auto-refunded
// by the sweep's F3 gate, and completing the booking would record money against
// a booking the cancellation flow already closed.
func TestCompleteActiveBookingFromPayment_RefusesCancelledBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking we_cancelled: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
completeActiveBookingFromPayment(ctx, pgxTx, bookingID)
var status string
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "we_cancelled" {
t.Errorf("expected the cancelled booking NOT auto-completed, got %q", status)
}
// The setup tx is rolled back at test end, so no pool-level cleanup is needed.
}
// TestCompleteFullyPaidBooking_CompletesPayableBooking locks the sweep rescue's
// completion side-effect (applyStaleRescueRecords → bookingIsFullyPaid →
// completeActiveBookingFromPayment): a PAYABLE booking fully covered by
// completed real money is completed by the rescue-completion path, exactly as
// the live payment path would.
func TestCompleteFullyPaidBooking_CompletesPayableBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
// A full £50 completed payment covers the £50 booking total.
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create completed payment: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, payID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
completeFullyPaidBooking(pool, bookingID)
var status string
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "completed" {
t.Errorf("expected the fully-paid payable booking completed, got %q", status)
}
}
// TestCompletion_ConcurrentCompletions_CampaignMaxRedemptions proves the B13
// atomic reservation in the completion path (completion.go): two concurrent
// completions of two DIFFERENT bookings of the same user both pass the
// eligibility SELECT while a max_redemptions=1 campaign still has headroom, but
// only ONE may win the conditional reservation UPDATE. The loser matches zero
// rows (pgx.ErrNoRows) and SKIPS the discount — the booking still completes.
// times_redeemed must end at exactly 1, never 2.
func TestCompletion_ConcurrentCompletions_CampaignMaxRedemptions(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)
bookingIDs := make([]string, 2)
for i := range bookingIDs {
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, i+1, 0, 0, time.UTC))
require.NoError(t, err)
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking in_progress: %v", err)
}
bookingIDs[i] = bookingID
}
pool := context.Background()
t.Cleanup(func() {
for _, bid := range bookingIDs {
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bid)
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bid)
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bid)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bid)
}
_, _ = db.Conn.Exec(pool, `DELETE FROM discount_campaigns WHERE name = 'Completion Race Campaign'`)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
// Commit the setup so both goroutines complete at pool level on independent
// connections — a shared per-test tx would serialize them.
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx, "no transaction in context")
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
var campaignID string
require.NoError(t, db.Conn.QueryRow(pool, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ('Completion Race Campaign', 'time_based', 10, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 1, 0)
RETURNING id
`).Scan(&campaignID))
var wg sync.WaitGroup
startBoth := make(chan struct{})
commitErrs := make([]error, 2)
for i, bid := range bookingIDs {
wg.Add(1)
go func(idx int, booking string) {
defer wg.Done()
<-startBoth
cctx := context.Background()
gtx, err := db.Conn.Begin(cctx)
if err != nil {
commitErrs[idx] = err
return
}
defer func() { _ = gtx.Rollback(cctx) }()
completeActiveBookingFromPayment(cctx, gtx, booking)
commitErrs[idx] = gtx.Commit(cctx)
}(i, bid)
}
close(startBoth)
wg.Wait()
for i, err := range commitErrs {
require.NoError(t, err, "completion tx %d must commit", i)
}
// Both bookings must complete regardless of who won the discount race.
for _, bid := range bookingIDs {
var status string
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bid).Scan(&status))
assert.Equal(t, "completed", status, "a discount race must never fail a booking completion")
}
// Exactly ONE booking got the campaign discount.
var discountCount int
require.NoError(t, db.Conn.QueryRow(pool, `
SELECT COUNT(*) FROM booking_discounts
WHERE source_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based'
`, campaignID).Scan(&discountCount))
assert.Equal(t, 1, discountCount, "exactly one completion must win the max_redemptions=1 campaign")
// The redemption counter never over-increments past max_redemptions.
var redeemed int
require.NoError(t, db.Conn.QueryRow(pool, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&redeemed))
assert.Equal(t, 1, redeemed, "times_redeemed must end at 1, never 2")
}
// TestCompletion_CampaignExhaustedAtReservation_SkipsDiscount locks the
// pgx.ErrNoRows skip branch of the completion's conditional reservation
// deterministically. A concurrent redemption exhausts the campaign
// (times_redeemed = max_redemptions) in an uncommitted transaction AFTER the
// completion's eligibility SELECT reads headroom but BEFORE its conditional
// UPDATE. Under READ COMMITTED the blocked UPDATE re-evaluates its WHERE
// against the post-increment row, matches zero rows, and the completion must
// STILL complete the booking while SKIPPING the discount — no booking_discounts
// row, no discount payment, counter unchanged.
func TestCompletion_CampaignExhaustedAtReservation_SkipsDiscount(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
var campaignID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ('Exhausted At Completion', 'time_based', 10, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 1, 0)
RETURNING id
`).Scan(&campaignID))
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx, "no transaction in context")
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM discount_campaigns WHERE id = $1`, campaignID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
// A separate transaction exhausts the campaign (times_redeemed = 1) WITHOUT
// committing — its row lock makes the completion's conditional UPDATE block
// until it is released, while READ COMMITTED keeps the completion's earlier
// eligibility SELECT reading the old committed value (0). That is exactly
// the read-then-write window the race exploits.
htx, err := db.Conn.Begin(pool)
require.NoError(t, err, "failed to begin holder tx")
defer func() { _ = htx.Rollback(pool) }()
_, err = htx.Exec(pool, `UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1`, campaignID)
require.NoError(t, err, "failed to exhaust campaign on holder tx")
// Launch the completion on another connection — it passes the eligibility
// SELECT and blocks on the conditional UPDATE. Releasing the holder lets the
// blocked UPDATE re-evaluate and miss.
done := make(chan struct{})
var completeErr error
go func() {
defer close(done)
cctx := context.Background()
gtx, err := db.Conn.Begin(cctx)
if err != nil {
completeErr = err
return
}
defer func() { _ = gtx.Rollback(cctx) }()
completeActiveBookingFromPayment(cctx, gtx, bookingID)
completeErr = gtx.Commit(cctx)
}()
// Give the completion time to reach the blocked conditional UPDATE.
time.Sleep(300 * time.Millisecond)
require.NoError(t, htx.Commit(pool), "failed to release holder tx")
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("completion did not finish after holder release (deadlock?)")
}
require.NoError(t, completeErr, "completion must succeed despite the exhausted campaign")
var status string
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status))
assert.Equal(t, "completed", status, "a discount race must never fail the booking completion")
var discountCount int
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
assert.Zero(t, discountCount, "the exhausted campaign must NOT be applied at completion")
var redeemed int
require.NoError(t, db.Conn.QueryRow(pool, "SELECT times_redeemed FROM discount_campaigns WHERE id = $1", campaignID).Scan(&redeemed))
assert.Equal(t, 1, redeemed, "times_redeemed stays at the concurrent redemption's 1")
}
// TestCompletion_PerUserMilestone_DuplicateSuppressed locks the once-per-user
// backstop for the per-user milestone (completion.go): the partial unique index
// uq_booking_discounts_user_milestone_campaign + ON CONFLICT DO NOTHING. A
// concurrent completion of ANOTHER of this user's bookings inserts the milestone
// discount row (uncommitted — invisible to this completion's NOT EXISTS
// eligibility read under READ COMMITTED). This completion's own INSERT then
// blocks on the conflicting unique index entry and is suppressed after the
// concurrent winner commits: exactly ONE milestone row exists for the user, no
// discount payment is minted, and the booking still completes.
func TestCompletion_PerUserMilestone_DuplicateSuppressed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
var campaignID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions)
VALUES ('Milestone At Completion', 'milestone', 15, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 'per_user_booking_count', 1, 10)
RETURNING id
`).Scan(&campaignID))
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx, "no transaction in context")
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM discount_campaigns WHERE id = $1`, campaignID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
// The concurrent winner already inserted the milestone row for this user
// (uncommitted — the completion's NOT EXISTS eligibility read passes, its
// INSERT then blocks on the unique index entry).
htx, err := db.Conn.Begin(pool)
require.NoError(t, err, "failed to begin holder tx")
defer func() { _ = htx.Rollback(pool) }()
_, err = htx.Exec(pool, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', 15, 50.00, 7.50)
`, bookingID, userID, campaignID)
require.NoError(t, err, "failed to insert concurrent milestone discount on holder tx")
done := make(chan struct{})
var completeErr error
go func() {
defer close(done)
cctx := context.Background()
gtx, err := db.Conn.Begin(cctx)
if err != nil {
completeErr = err
return
}
defer func() { _ = gtx.Rollback(cctx) }()
completeActiveBookingFromPayment(cctx, gtx, bookingID)
completeErr = gtx.Commit(cctx)
}()
// Give the completion time to reach the blocked booking_discounts INSERT.
time.Sleep(300 * time.Millisecond)
require.NoError(t, htx.Commit(pool), "failed to release holder tx")
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("completion did not finish after holder release (deadlock?)")
}
require.NoError(t, completeErr, "completion must succeed despite the suppressed duplicate")
var status string
require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status))
assert.Equal(t, "completed", status, "a duplicate milestone must never fail the booking completion")
var milestoneCount int
require.NoError(t, db.Conn.QueryRow(pool, `
SELECT COUNT(*) FROM booking_discounts
WHERE user_id = $1 AND source_id = $2 AND discount_source = 'campaign'
`, userID, campaignID).Scan(&milestoneCount))
assert.Equal(t, 1, milestoneCount, "the once-per-user milestone must apply at most once per user")
var payCount int
require.NoError(t, db.Conn.QueryRow(pool, `
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'
`, bookingID).Scan(&payCount))
assert.Zero(t, payCount, "the suppressed duplicate must not mint a discount payment")
}