//go:build test && dev package payments // ============================================================================= // LOOP A — fresh-review money findings (HIGH-1, HIGH-2, MEDIUM-3, MEDIUM-5, // LOW-6). Each test pins the fixed behaviour and would fail on the old code. // ============================================================================= import ( "context" "net/http" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // HIGH-1 — the overflow→tip guard compares chargeAmount (what Square will // actually charge and buildSplitRecords will split), not req.Amount against an // inflated remaining+discount threshold. A pending campaign credit previously // let a full payment exceed the REAL remaining and silently mint a pre-start // tip. // ============================================================================= // TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation locks the HIGH-1 // bypass: a full £60 payment on the £50 fixture booking with a 100% campaign // eligible (£50 credit) would have passed the old guard (60 < 50+50) and // silently charged £60, carving a £10 pre-start tip with no confirmation. // chargeAmount == req.Amount for a 'full' payment, so it exceeds the real £50 // remaining and MUST require confirmation. func TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedActiveCampaign(t, ctx, tx, 100) cardToken := "cnon:loop-a-overflow-full" req := CreateBookingPaymentRequest{ Amount: 6000, // £60 on a £50 booking PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "loop-a-overflow-full-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "a full payment beyond the real remaining must require confirmation even with a discount pending, body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required") // No payment record may be written for the rejected overflow. var payCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)) assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record") } // TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation locks the // Loop-B A6 finding on the HIGH-1 deposit side: a deposit-with-discount charge // is clamped DOWN to the discounted obligation (remaining − discount), and the // overflow guard compares the RAW request against that obligation. A £60 // deposit on the £50 booking with a 20% campaign (£10 credit) requests £60 // against a £40 discounted obligation — it MUST require confirmation (the old // guard compared the discounted charge against the real £50 remaining, accepted // it and silently truncated the discount). On confirmation the full £60 is // charged and the £10 excess (beyond the real remaining) is carved out as a tip // record — never absorbed as service revenue. func TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedActiveCampaign(t, ctx, tx, 20) cardToken := "cnon:loop-a-deposit-overflow" req := CreateBookingPaymentRequest{ Amount: 6000, // £60 deposit; discounted obligation is £40 PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "loop-a-deposit-overflow-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "a deposit beyond the discounted obligation must require confirmation, body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required") // No payment record may be written for the unconfirmed overflow. var payCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)) assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record") // Confirmed: the full £60 is charged and the £10 excess (60 − 50 real // remaining) is carved out as a tip record, never absorbed as service // revenue. req.ConfirmOverflowTip = true w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w2.Code, "a confirmed deposit overflow must proceed, body: %s", w2.Body.String()) var bookingPortion float64 require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip'`, bookingID).Scan(&bookingPortion)) assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must total the £50 obligation") var tipCount int var tipAmount float64 require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount)) assert.Equal(t, 1, tipCount, "the £10 excess must be carved out as a tip record") assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 excess") } // ============================================================================= // HIGH-2 — a deposit-with-discount pending-reuse retry must compare against the // CHARGE amount stored on the pending row (the discounted amount), not the raw // req.Amount the frontend resends. Previously every such retry 400'd // "amount_mismatch" forever. // ============================================================================= // TestLoopA_DepositWithDiscount_PendingReuseRetry_Succeeds seeds the pending // row at the DISCOUNTED charge (£15 = £25 deposit − £10 campaign credit) and // retries with the RAW £25 deposit — exactly what the frontend resends. The // retry must be accepted (chargeAmount recomputes to £15 and matches) and the // charge completed, not rejected with amount_mismatch. func TestLoopA_DepositWithDiscount_PendingReuseRetry_Succeeds(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) // 20% campaign on the £50 fixture booking = £10 credit → a £25 raw deposit // charges £15. seedActiveCampaign(t, ctx, tx, 20) key := "loop-a-deposit-retry-" + bookingID // Seed the pending row exactly as the handler's first attempt stored it: // the CHARGE amount (£15), not the requested £25. _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_at, updated_at, created_by) VALUES ($1, 'deposit', 'online_square', 'pending', 15.00, $2, 'cnon:first-attempt', NOW(), NOW(), $3) `, bookingID, key, userID) require.NoError(t, err) cardToken := "cnon:loop-a-deposit-retry" req := CreateBookingPaymentRequest{ Amount: 2500, // raw £25 deposit — the frontend resends this PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: key, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "a deposit-with-discount pending-reuse retry must succeed, body: %s", w.Body.String()) var status, sqPayID string require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE idempotency_key = $1`, key).Scan(&status, &sqPayID)) assert.Equal(t, "completed", status, "the reused pending row must complete") assert.NotEmpty(t, sqPayID, "the completed row must carry the Square payment id") // Exactly one row for the key — the pending row was reused, not duplicated. var payCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount)) assert.Equal(t, 1, payCount, "the retry must reuse the pending row, not mint a second one") } // ============================================================================= // MEDIUM-3 — the stale-pending sweep rescue must mirror the live-path split: an // overflow beyond the booking's remaining obligation is carved out as a tip // record (never mis-booked as service revenue) and the fully-paid completion // check runs. // ============================================================================= // TestLoopA_SweepRescue_CarvesTipAndCompletes rescues a keyed lost-response // payment of £60 on the £50 fixture booking via the sweep. The rescue must: // complete the row, split it into deposit £25 + balance £25 + a carved tip £10, // and complete the booking (fully paid). func TestLoopA_SweepRescue_CarvesTipAndCompletes(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) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) staleID, err := fixtures.CreateTestPayment(tx, bookingID, 60.00, "online_square", "full", "pending") require.NoError(t, err) const key = "loop-a-sweep-rescue" _, err = tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:loop-a' WHERE id = $2", key, staleID) require.NoError(t, err) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 6000, Currency: "GBP", SourceID: "cnon:loop-a", IdempotencyKey: key, }) require.NoError(t, err, "failed to seed the completed Square payment") SquareClient = mock defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx, "no transaction in context") require.NoError(t, pgxTx.Commit(ctx), "failed to commit test tx") t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = 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() if _, err := SweepStalePendingPayments(freshCtx); err != nil { t.Fatalf("sweep failed: %v", err) } var status, sqPayID string require.NoError(t, db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID)) assert.Equal(t, "completed", status, "the rescued row must complete") assert.Equal(t, pay.SquarePayID, sqPayID, "the rescued row must carry the replayed square_payment_id") // The primary row is the deposit portion (£25); the balance and tip are // separate split rows. var bookingPortion float64 require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip'`, bookingID).Scan(&bookingPortion)) assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must total the £50 obligation (no overflow mis-booked as service revenue)") var tipCount int var tipAmount float64 require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount)) assert.Equal(t, 1, tipCount, "the £10 overflow must be carved out as a tip record") assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 overflow") // The booking was fully paid by the rescue → completed. var bookingStatus string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus)) assert.Equal(t, "completed", bookingStatus, "the fully-paid rescue must run the completion side-effects") } // ============================================================================= // MEDIUM-5 — the till gift-card create/top-up must go through the same £5,000/ // day admin cap as the admin API surface. The till's own same-day value counts. // ============================================================================= // TestLoopA_TillGiftCard_DailyCap_Enforced seeds a till_sales row of £4,800 // created by the admin today and verifies a £250 till create is rejected 400 // (would land the day on £5,050 — over the cap; £250 is at the per-transaction // limit so the daily check is what fires) while a £200 create lands exactly on // the £5,000 cap and succeeds — pinning the cap as inclusive and the till's own // value as counted. func TestLoopA_TillGiftCard_DailyCap_Enforced(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") // A gift card created BEFORE today that the admin topped up at the till // today for £4,800 — the seeded till_sales row is the day's issued value. var cardID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at) VALUES (4800.00, 4800.00, $1, NOW() - INTERVAL '1 day') RETURNING id `, adminID).Scan(&cardID)) _, err = tx.Exec(ctx, ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, idempotency_key, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'Gift Card topup', 1, 4800.00, 4800.00, 'cash', 'completed', 'loop-a-till-seed', $2, NOW(), NOW()) `, cardID, adminID) require.NoError(t, err) // £250 (the per-transaction maximum) would land the day on £5,050 — over // the £5,000 daily cap. over := makeTillSaleRequest(t, TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 250.00, PaymentMethod: "cash", IdempotencyKey: "loop-a-till-over", }, adminToken, ctx, tx.(pgx.Tx)) require.Equal(t, http.StatusBadRequest, over.Code, "body: %s", over.Body.String()) assert.Contains(t, over.Body.String(), "£5,000", "the rejection must cite the daily cap") assert.Contains(t, over.Body.String(), "daily", "the rejection must be the daily-limit message") // £200 lands the day on EXACTLY £5,000 — inside the cap (inclusive). ok := makeTillSaleRequest(t, TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 200.00, PaymentMethod: "cash", IdempotencyKey: "loop-a-till-ok", }, adminToken, ctx, tx.(pgx.Tx)) require.Equal(t, http.StatusCreated, ok.Code, "boundary body: %s", ok.Body.String()) } // ============================================================================= // LOW-6 — expiry is enforced at redemption, not just by the nightly cleanup // job: a card whose expiry_date has passed cannot be redeemed even before the // next CleanupExpiredGiftCards run. // ============================================================================= // TestLoopA_RedeemExpiredCard_Rejected redeems a card whose expiry_date is in // the past but whose amount_remaining is still live (the nightly job has not // run yet). The redemption must be rejected 400 and the card left untouched. func TestLoopA_RedeemExpiredCard_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") var cardID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, expiry_date) VALUES (20.00, 20.00, NOW() - INTERVAL '1 day') RETURNING id `).Scan(&cardID)) w := redeemCodeRequest(t, token, tx.(pgx.Tx), cardID) require.Equal(t, http.StatusBadRequest, w.Code, "an expired card must not be redeemable, body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "expired") // The card is untouched: balance live, not redeemed, no balance credited. var remaining float64 var redeemedBy interface{} require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &redeemedBy)) assert.Equal(t, 20.0, remaining, "the expired card's balance must be left untouched") assert.Nil(t, redeemedBy, "the expired card must not be marked redeemed") var balanceCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balanceCount)) assert.Zero(t, balanceCount, "no balance may be credited from an expired card") }