- 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
366 lines
16 KiB
Go
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")
|
|
}
|