- 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
244 lines
10 KiB
Go
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)
|
|
}
|