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
This commit is contained in:
@@ -4,11 +4,16 @@ 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
|
||||
@@ -83,3 +88,278 @@ func TestCompleteFullyPaidBooking_CompletesPayableBooking(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user