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