Files
Crussell/backend/handlers/payments/max_online_tip_test.go
T
popertots 1429eddd34 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
2026-08-22 00:34:50 +01:00

244 lines
10 KiB
Go

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