//go:build test && dev package payments import ( "encoding/json" "net/http" "testing" "time" "crussell/clock" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // maxOnlineTipPence: online tip business bound (CreateTipPayment) // ============================================================================= // TestTipPayment_AtBound_Accepted pins the online tip bound's inclusive edge: a // tip exactly at maxOnlineTipPence (£250) is a legitimate business amount and // must flow through the normal happy path (charge, completed tip record). func TestTipPayment_AtBound_Accepted(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:tip-at-bound" req := CreateTipPaymentRequest{ Amount: maxOnlineTipPence, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp PaymentResponse require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) assert.Equal(t, "tip", resp.PaymentType) assert.Equal(t, "completed", resp.Status) assert.Equal(t, maxOnlineTipPence, resp.Amount, "the at-bound tip amount must be charged verbatim") } // TestTipPayment_OverBound_Rejected pins the online tip bound's exclusive edge: // any tip above maxOnlineTipPence (£250) is rejected with 400 and a clear // message, and the rejection fires before any pending record insert or Square // charge — no tip payment row may be created. func TestTipPayment_OverBound_Rejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:tip-over-bound" req := CreateTipPaymentRequest{ Amount: maxOnlineTipPence + 1, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Tip exceeds the maximum allowed amount") var tipCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount) require.NoError(t, err) assert.Equal(t, 0, tipCount, "an over-bound tip must not create any payment record") } // ============================================================================= // maxOnlineTipPence applies to the CreateBookingPayment OVERFLOW carve too: // the B12 gate (confirm_overflow_tip) must cap the tip portion it mints at // £250, mirroring the dedicated tip endpoint. A confirmed £10,000 payment on a // booking with £50 remaining would otherwise carve a £9,950 tip row. // ============================================================================= // TestBookingPayment_OverflowTip_OverCap_Rejected_PostStart is the confirmed // bypass regression: a POST-START booking (the carve at handlers.go's // buildSplitRecords post-start branch) with £50 remaining and a confirmed // £10,000 payment must be rejected 400 with the tip-cap message — even though // confirm_overflow_tip=true — and must not create any payment row. func TestBookingPayment_OverflowTip_OverCap_Rejected_PostStart(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:overflow-over-cap-post" req := CreateBookingPaymentRequest{ Amount: 1000000, // £10,000 on a £50-remaining booking → £9,950 tip portion PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "overflow-over-cap-post-" + bookingID, ConfirmOverflowTip: true, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "an over-cap overflow must be rejected even when confirmed, body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Tip exceeds the maximum allowed amount") var payCount int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount) require.NoError(t, err) assert.Zero(t, payCount, "the rejected over-cap overflow must not create any payment record") } // TestBookingPayment_OverflowTip_OverCap_Rejected_PreStart is the same bypass // regression for the PRE-START carve (deposit/balance/tip split): the // confirmed £10,000 payment must be rejected before any pending row or Square // charge, so no tip can be minted over the cap. func TestBookingPayment_OverflowTip_OverCap_Rejected_PreStart(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:overflow-over-cap-pre" req := CreateBookingPaymentRequest{ Amount: 1000000, // £10,000 on a £50-remaining booking → £9,950 tip portion PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "overflow-over-cap-pre-" + bookingID, ConfirmOverflowTip: true, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "an over-cap overflow must be rejected even when confirmed, body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Tip exceeds the maximum allowed amount") var payCount int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount) require.NoError(t, err) assert.Zero(t, payCount, "the rejected over-cap overflow must not create any payment record") } // TestBookingPayment_OverflowTip_WithinCap_CarvesTip pins the inclusive edge: // a confirmed £250 payment on a £50-remaining booking (tip portion £200 — under // the £250 cap) proceeds and buildSplitRecords carves a tip row of EXACTLY // £200 alongside a £50 booking portion. func TestBookingPayment_OverflowTip_WithinCap_CarvesTip(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:overflow-within-cap" req := CreateBookingPaymentRequest{ Amount: 25000, // £250 on a £50-remaining booking → tip portion £200 PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "overflow-within-cap-" + bookingID, ConfirmOverflowTip: true, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "a within-cap overflow must proceed, body: %s", w.Body.String()) var tipCount int var tipAmount float64 err := 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) require.NoError(t, err) assert.Equal(t, 1, tipCount, "a within-cap overflow must carve exactly one tip record") assert.InDelta(t, 200.0, tipAmount, 0.001, "the carved tip must equal the £200 overflow, not more") var bookingPortion float64 err = tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'full'`, bookingID).Scan(&bookingPortion) require.NoError(t, err) assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must remain the £50 obligation") } // TestBuildSplitRecords_OverCapTip_Rejected proves the belt-and-braces cap in // buildSplitRecords itself: even a caller that skips the B12 gate cannot mint a // tip row over maxOnlineTipPence — both the post-start and pre-start carve // return an error instead of minting the over-cap tip. A £10,000 charge on a // £50 booking would otherwise carve a £9,950 tip row. func TestBuildSplitRecords_OverCapTip_Rejected(t *testing.T) { t.Parallel() record := makeTestRecord("b-cap-reject", "full", 10000) info := &BookingPaymentInfo{ StartTime: clock.Now().Add(-2 * time.Hour), TotalAmount: 50, TotalPaid: 0, } // Post-start carve: booking portion £50, tip portion £9,950 — over the cap. records, err := buildSplitRecords(record, "full", info, 10000) require.Error(t, err, "the post-start carve must reject an over-cap tip portion") assert.Contains(t, err.Error(), "exceeding the £250 online tip cap") assert.Nil(t, records) // Pre-start carve: deposit £25 + balance £25, tip portion £9,950 — over the cap. preInfo := &BookingPaymentInfo{ StartTime: clock.Now().Add(48 * time.Hour), TotalAmount: 50, TotalPaid: 0, } records, err = buildSplitRecords(record, "full", preInfo, 10000) require.Error(t, err, "the pre-start carve must reject an over-cap tip portion") assert.Contains(t, err.Error(), "exceeding the £250 online tip cap") assert.Nil(t, records) // Inclusive boundary: a £250 charge on the £50 booking (tip portion £200) // stays below the cap and splits normally (single post-start record pair). withinRecord := makeTestRecord("b-cap-within", "full", 250) withinInfo := &BookingPaymentInfo{ StartTime: clock.Now().Add(-2 * time.Hour), TotalAmount: 50, TotalPaid: 0, } within, err := buildSplitRecords(withinRecord, "full", withinInfo, 250) require.NoError(t, err, "a within-cap tip portion must split normally") require.Len(t, within, 2) assert.Equal(t, "full", within[0].PaymentType) assert.InDelta(t, 50.0, within[0].Amount, 0.001) assert.Equal(t, "tip", within[1].PaymentType) assert.InDelta(t, 200.0, within[1].Amount, 0.001) }