Files
popertots 4146f8e09a fix: pre-launch review — security, money safety, privacy, legal, code quality
Security (P0):
- IsJTIRevoked fails closed on DB error (previously accepted revoked tokens)
- Remove dead consume parameter from SCA gate (prevented token replay)
- Rate limiter map TTL-based eviction (prevented memory exhaustion)
- 2FA attempt map already had LRU eviction (verified)

Money Safety (P1):
- Gift card transfer refuses expired destination cards
- Gift card balance deduction has WHERE balance >= amount guard
- Webhook clawback acquires till-sale advisory lock
- Sweep/retry lock keys aligned

Privacy/Cookies (P2):
- Self-host Google Fonts (Playfair Display woff2)
- Replace CARTO map tiles with OpenStreetMap raster tiles
- Replace Wikimedia/icon-icons external images with local SVGs
- Remove external image URLs from CSP

Legal (P3):
- Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies)
- Terms: add Tips section (optionality, non-refundable, same processing as bookings)

Code Quality (P4):
- twofa.Check accepts db.Querier for testability
- depositPromotionMinPct uses literal 0.20 (not misleading alias)
- HolidayHours.svelte uses proper type (not as any[])
- Remove stale TODO comments from main.go

Testing (P5):
- 94 new float64 money validity tests across 3 test files
- Cover VAT, splits, refunds, gift cards, rounding, precision boundaries
- All 27 backend test packages pass
2026-08-22 00:34:51 +01:00

1155 lines
38 KiB
Go

//go:build test && dev
package payments
import (
"math"
"testing"
"time"
"crussell/clock"
"github.com/stretchr/testify/require"
)
// ============================================================================
// Section 1: VAT Calculation Float64 Precision
// ============================================================================
// TestVAT_Float64Precision_VariousRates verifies that VAT calculations at
// common UK VAT rates (20%, 5%, 0%) produce correct net and VAT amounts
// without float64 drift. The DB function apply_vat_to_payment computes:
//
// net_amount = ROUND(amount / (1 + rate/100), 2)
// vat_amount = amount - net_amount
//
// These tests verify the Go-side expectation of the DB-side computation.
func TestVAT_Float64Precision_VariousRates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
gross float64
rate float64
wantNet float64
wantVAT float64
}{
// 20% standard rate
{"£1.00 at 20%", 1.00, 20.0, 0.83, 0.17},
{"£10.00 at 20%", 10.00, 20.0, 8.33, 1.67},
{"£50.00 at 20%", 50.00, 20.0, 41.67, 8.33},
{"£100.00 at 20%", 100.00, 20.0, 83.33, 16.67},
{"£9999.99 at 20%", 9999.99, 20.0, 8333.33, 1666.66},
// 5% reduced rate
{"£1.00 at 5%", 1.00, 5.0, 0.95, 0.05},
{"£10.00 at 5%", 10.00, 5.0, 9.52, 0.48},
{"£100.00 at 5%", 100.00, 5.0, 95.24, 4.76},
// 0% zero rate
{"£100.00 at 0%", 100.00, 0.0, 100.00, 0.00},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
netAmount := math.Round(tt.gross/(1+tt.rate/100)*100) / 100
vatAmount := math.Round((tt.gross-netAmount)*100) / 100
if netAmount != tt.wantNet {
t.Errorf("net: got %.2f, want %.2f", netAmount, tt.wantNet)
}
if vatAmount != tt.wantVAT {
t.Errorf("vat: got %.2f, want %.2f", vatAmount, tt.wantVAT)
}
// Invariant: net + vat must equal gross (within rounding)
total := math.Round((netAmount+vatAmount)*100) / 100
if total != tt.gross {
t.Errorf("net+vat=%.2f, but gross=%.2f — VAT rounding drift", total, tt.gross)
}
})
}
}
// TestVAT_Float64Precision_SubPenny verifies that sub-penny VAT amounts
// round correctly. The DB rounds to 2 decimal places, so amounts like
// £0.01 at 20% should produce net=0.01, vat=0.00 (not vat=0.001666...).
func TestVAT_Float64Precision_SubPenny(t *testing.T) {
t.Parallel()
tests := []struct {
name string
gross float64
rate float64
wantNet float64
wantVAT float64
}{
{"£0.01 at 20% — sub-penny VAT rounds to 0", 0.01, 20.0, 0.01, 0.00},
{"£0.05 at 20% — VAT rounds to 0.01", 0.05, 20.0, 0.04, 0.01},
{"£0.29 at 20%", 0.29, 20.0, 0.24, 0.05},
{"£0.99 at 20%", 0.99, 20.0, 0.83, 0.16},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
netAmount := math.Round(tt.gross/(1+tt.rate/100)*100) / 100
vatAmount := math.Round((tt.gross-netAmount)*100) / 100
if netAmount != tt.wantNet {
t.Errorf("net: got %.2f, want %.2f", netAmount, tt.wantNet)
}
if vatAmount != tt.wantVAT {
t.Errorf("vat: got %.2f, want %.2f", vatAmount, tt.wantVAT)
}
})
}
}
// TestVAT_Float64Precision_RepeatedApplication verifies that applying VAT
// repeatedly to the same gross amount always produces the same result —
// float64 rounding must be deterministic.
func TestVAT_Float64Precision_RepeatedApplication(t *testing.T) {
t.Parallel()
gross := 123.45
rate := 20.0
// Apply VAT 100 times — all must produce the same result
var firstNet, firstVAT float64
for i := 0; i < 100; i++ {
netAmount := math.Round(gross/(1+rate/100)*100) / 100
vatAmount := math.Round((gross-netAmount)*100) / 100
if i == 0 {
firstNet, firstVAT = netAmount, vatAmount
} else {
if netAmount != firstNet {
t.Errorf("iteration %d: net changed from %.2f to %.2f", i, firstNet, netAmount)
}
if vatAmount != firstVAT {
t.Errorf("iteration %d: vat changed from %.2f to %.2f", i, firstVAT, vatAmount)
}
}
}
}
// TestVAT_Float64Precision_SumOfParts verifies that splitting a payment into
// multiple VAT-inclusive parts and summing their net+VAT equals the original
// gross (within 1p rounding tolerance).
func TestVAT_Float64Precision_SumOfParts(t *testing.T) {
t.Parallel()
// A £100 booking paid in 3 instalments: £30, £50, £20
instalments := []float64{30.00, 50.00, 20.00}
rate := 20.0
var totalNet, totalVAT float64
for i, gross := range instalments {
netAmount := math.Round(gross/(1+rate/100)*100) / 100
vatAmount := math.Round((gross-netAmount)*100) / 100
totalNet += netAmount
totalVAT += vatAmount
t.Logf("Instalment %d: gross=%.2f net=%.2f vat=%.2f", i+1, gross, netAmount, vatAmount)
}
totalNet = math.Round(totalNet*100) / 100
totalVAT = math.Round(totalVAT*100) / 100
totalGross := totalNet + totalVAT
// The sum of parts should equal the original £100 within 1p tolerance
if totalGross < 99.99 || totalGross > 100.01 {
t.Errorf("sum of parts gross=%.2f, expected ~100.00 (net=%.2f vat=%.2f)", totalGross, totalNet, totalVAT)
}
}
// ============================================================================
// Section 2: Split Record Float64 Precision
// ============================================================================
// TestBuildSplitRecords_Float64Precision_PenceExactPartition verifies that
// buildSplitRecords always partitions the charged amount exactly — the sum
// of all split records must equal the charged amount within roundingEpsilon.
// This tests the core money invariant with various float64 inputs.
func TestBuildSplitRecords_Float64Precision_PenceExactPartition(t *testing.T) {
t.Parallel()
cases := []struct {
name string
total float64
paid float64
charge float64
paymentType string
}{
{"£50 charge on £50 booking — full split", 50, 0, 50, "full"},
{"£60 charge on £50 booking — tip overflow", 50, 0, 60, "full"},
{"£25 charge on £100 booking — under cap", 100, 0, 25, "deposit"},
{"£100 charge on £100 booking — full", 100, 0, 100, "full"},
{"£12.34 charge on £25 booking — odd amount", 25, 0, 12.34, "full"},
{"£45.67 charge on £50 booking — odd amount", 50, 0, 45.67, "full"},
{"£0.01 charge on £100 booking — tiny", 100, 0, 0.01, "full"},
{"£9999.99 charge on £10000 booking — large", 10000, 0, 9999.99, "full"},
{"£30 charge on £50 with £20 paid — partial", 50, 20, 30, "full"},
{"£0.29 charge on £50 booking — small odd", 50, 0, 0.29, "full"},
{"£1.00 charge on £1.50 booking — half-penny total", 1.50, 0, 1.00, "full"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
record := makeTestRecord("float64-booking", tc.paymentType, tc.charge)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: tc.total,
TotalPaid: tc.paid,
}
records, err := buildSplitRecords(record, tc.paymentType, info, tc.charge)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
// The sum must never exceed the charged amount
if sum > tc.charge+roundingEpsilon {
t.Errorf("split sum %.2f exceeds charged amount %.2f", sum, tc.charge)
}
// The sum must partition the charged amount exactly (within rounding epsilon)
if math.Abs(sum-tc.charge) > roundingEpsilon {
t.Errorf("split sum %.2f does not partition charged amount %.2f (diff=%.4f)", sum, tc.charge, math.Abs(sum-tc.charge))
}
})
}
}
// TestBuildSplitRecords_Float64Precision_PostStart verifies that post-start
// booking splits (where the booking has already started) correctly partition
// the charged amount into booking portion + tip portion.
func TestBuildSplitRecords_Float64Precision_PostStart(t *testing.T) {
t.Parallel()
cases := []struct {
name string
total float64
paid float64
charge float64
}{
{"post-start: £50 charge on £50 booking — no tip", 50, 0, 50},
{"post-start: £60 charge on £50 booking — £10 tip", 50, 0, 60},
{"post-start: £25 charge on £50 with £25 paid — all tip", 50, 25, 25},
{"post-start: £0.01 charge on £50 — tiny", 50, 0, 0.01},
{"post-start: £9999.99 charge on £10000 — large", 10000, 0, 9999.99},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
record := makeTestRecord("poststart-booking", "full", tc.charge)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour), // past
TotalAmount: tc.total,
TotalPaid: tc.paid,
}
records, err := buildSplitRecords(record, "full", info, tc.charge)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if sum > tc.charge+roundingEpsilon {
t.Errorf("post-start split sum %.2f exceeds charged amount %.2f", sum, tc.charge)
}
if math.Abs(sum-tc.charge) > roundingEpsilon {
t.Errorf("post-start split sum %.2f does not partition charged amount %.2f", sum, tc.charge)
}
})
}
}
// TestBuildSplitRecords_Float64Precision_DepositCarve_ExactPence verifies
// that the deposit carve (50% of total minus already paid) always produces
// exact pence outcomes that partition the charged amount.
func TestBuildSplitRecords_Float64Precision_DepositCarve_ExactPence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
total float64
paid float64
charge float64
}{
{"£25.50 total, £25 charge — half-penny total", 25.50, 0, 25.00},
{"£25.50 total, £12.75 charge — exact half", 25.50, 0, 12.75},
{"£33.33 total, £16.67 charge — recurring decimal total", 33.33, 0, 16.67},
{"£100 total, £49.99 charge — near-cap", 100, 0, 49.99},
{"£100 total, £50.01 charge — just over cap", 100, 0, 50.01},
{"£100 total, £50 paid, £50 charge — deposit room exhausted", 100, 50, 50.00},
{"£100 total, £49.99 paid, £50.01 charge — tiny deposit room", 100, 49.99, 50.01},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
record := makeTestRecord("deposit-carve", "full", tc.charge)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: tc.total,
TotalPaid: tc.paid,
}
records, err := buildSplitRecords(record, "full", info, tc.charge)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if sum > tc.charge+roundingEpsilon {
t.Errorf("sum %.2f exceeds charge %.2f", sum, tc.charge)
}
if math.Abs(sum-tc.charge) > roundingEpsilon {
t.Errorf("sum %.2f != charge %.2f (diff=%.4f)", sum, tc.charge, math.Abs(sum-tc.charge))
}
// Verify each record amount is rounded to 2 decimal places
for i, r := range records {
pence := math.Round(r.Amount * 100)
if math.Abs(r.Amount*100-pence) > 0.001 {
t.Errorf("record %d amount %.4f is not rounded to 2 decimal places", i, r.Amount)
}
}
})
}
}
// TestBuildTerminalSplitRecords_Float64Precision verifies that
// buildTerminalSplitRecords partitions bookingPortion + tipAmount exactly.
func TestBuildTerminalSplitRecords_Float64Precision(t *testing.T) {
t.Parallel()
cases := []struct {
name string
total float64
paid float64
bookingPortion float64
tipAmount float64
}{
{"£50 booking + £10 tip", 50, 0, 50, 10},
{"£100 booking + £25 tip", 100, 0, 100, 25},
{"£50 booking with £20 paid + £10 tip", 50, 20, 30, 10},
{"£0.01 booking + £0.01 tip — tiny", 0.01, 0, 0.01, 0.01},
{"£9999.99 booking + £250 tip — large", 9999.99, 0, 9999.99, 250},
{"£25.50 booking + £5.50 tip — half-penny", 25.50, 0, 25.50, 5.50},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
primary := makeTestRecord("terminal-booking", "full", tc.bookingPortion+tc.tipAmount)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour), // past
TotalAmount: tc.total,
TotalPaid: tc.paid,
}
records := buildTerminalSplitRecords(primary, info, tc.bookingPortion, tc.tipAmount)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
expected := math.Round((tc.bookingPortion+tc.tipAmount)*100) / 100
if sum > expected+roundingEpsilon {
t.Errorf("terminal split sum %.2f exceeds expected %.2f", sum, expected)
}
if math.Abs(sum-expected) > roundingEpsilon {
t.Errorf("terminal split sum %.2f != expected %.2f", sum, expected)
}
})
}
}
// ============================================================================
// Section 3: Refund Calculation Float64 Precision
// ============================================================================
// TestCalculateRefundForCancellation_Float64Precision verifies that
// CalculateRefundForCancellation produces correct results with various
// float64 inputs, including edge cases.
func TestCalculateRefundForCancellation_Float64Precision(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
subtotal float64
prePaid float64
cancelTime time.Time
wantTier string
wantRefund float64
wantKept float64
}{
{
name: "£100 subtotal, £50 paid, >72h — full refund",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-73 * time.Hour),
wantTier: FullRefundTier,
wantRefund: 50,
wantKept: 0,
},
{
name: "£100 subtotal, £50 paid, 24-72h — partial refund",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-48 * time.Hour),
wantTier: PartialRefundTier,
wantRefund: 0, // protected deposit = min(50, 50) = 50, so 50-50=0
wantKept: 50,
},
{
name: "£100 subtotal, £80 paid, 24-72h — partial refund with excess",
subtotal: 100,
prePaid: 80,
cancelTime: start.Add(-48 * time.Hour),
wantTier: PartialRefundTier,
wantRefund: 30, // 80 - min(80, 50) = 30
wantKept: 50,
},
{
name: "£100 subtotal, £50 paid, <24h — no refund",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-12 * time.Hour),
wantTier: NoRefundTier,
wantRefund: 0,
wantKept: 50,
},
{
name: "£0.01 subtotal, £0.01 paid, >72h — tiny full refund",
subtotal: 0.01,
prePaid: 0.01,
cancelTime: start.Add(-73 * time.Hour),
wantTier: FullRefundTier,
wantRefund: 0.01,
wantKept: 0,
},
{
name: "£9999.99 subtotal, £5000 paid, >72h — large full refund",
subtotal: 9999.99,
prePaid: 5000,
cancelTime: start.Add(-73 * time.Hour),
wantTier: FullRefundTier,
wantRefund: 5000,
wantKept: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CalculateRefundForCancellation(tt.subtotal, tt.prePaid, tt.cancelTime, start)
if result.Tier != tt.wantTier {
t.Errorf("tier: got %q, want %q", result.Tier, tt.wantTier)
}
if math.Abs(result.RefundableAmount-tt.wantRefund) > 0.005 {
t.Errorf("refundable: got %.2f, want %.2f", result.RefundableAmount, tt.wantRefund)
}
if math.Abs(result.KeptAmount-tt.wantKept) > 0.005 {
t.Errorf("kept: got %.2f, want %.2f", result.KeptAmount, tt.wantKept)
}
// Invariant: refundable + kept must equal total pre-paid (within rounding)
total := math.Round((result.RefundableAmount+result.KeptAmount)*100) / 100
expectedTotal := math.Round(tt.prePaid*100) / 100
if total != expectedTotal {
t.Errorf("refundable+kept=%.2f, but prePaid=%.2f — money conservation broken", total, expectedTotal)
}
})
}
}
// TestCalculateRefundForCancellation_ProtectedDeposit_Float64Precision
// verifies that the protected deposit calculation (min(totalPrePaid,
// subtotal * ProtectedDepositMaxPct)) is correct with various float64 inputs.
func TestCalculateRefundForCancellation_ProtectedDeposit_Float64Precision(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
cancelTime := start.Add(-48 * time.Hour) // partial refund tier
tests := []struct {
name string
subtotal float64
prePaid float64
wantProt float64
}{
{"£100 subtotal, £50 paid — protected=50", 100, 50, 50},
{"£100 subtotal, £30 paid — protected=30", 100, 30, 30},
{"£100 subtotal, £60 paid — protected=50 (capped)", 100, 60, 50},
{"£0.01 subtotal, £0.01 paid — protected=0.005→0.01", 0.01, 0.01, 0.01},
{"£33.33 subtotal, £20 paid — protected=min(20,16.665→16.67)", 33.33, 20, 16.67},
{"£9999.99 subtotal, £5000 paid — protected=min(5000,4999.995→5000)", 9999.99, 5000, 5000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CalculateRefundForCancellation(tt.subtotal, tt.prePaid, cancelTime, start)
if math.Abs(result.ProtectedDeposit-tt.wantProt) > 0.005 {
t.Errorf("protected deposit: got %.2f, want %.2f", result.ProtectedDeposit, tt.wantProt)
}
})
}
}
// TestCalculateRefundForCancellation_ForceFullRefund verifies that the
// forceFullRefund override in ProcessCancellationRefundTx correctly sets
// refundable to totalPrePaid and kept to 0.
func TestCalculateRefundForCancellation_ForceFullRefund_Float64(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
cancelTime := start.Add(-12 * time.Hour) // <24h — normally no refund
// CalculateRefundForCancellation returns the normal tier result
result := CalculateRefundForCancellation(100, 50, cancelTime, start)
// Simulate forceFullRefund: override refundable to totalPrePaid, kept to 0
result.RefundableAmount = result.TotalPrePaid
result.KeptAmount = 0
result.Tier = "admin_full_refund"
if result.RefundableAmount != 50 {
t.Errorf("forceFullRefund refundable: got %.2f, want 50", result.RefundableAmount)
}
if result.KeptAmount != 0 {
t.Errorf("forceFullRefund kept: got %.2f, want 0", result.KeptAmount)
}
}
// ============================================================================
// Section 4: Gift Card Balance Float64 Precision
// ============================================================================
// TestPenceLess_Float64Precision verifies that penceLess correctly compares
// pound-float balances by rounding to integer pence, which is the only
// float-safe way to compare money amounts.
func TestPenceLess_Float64Precision_Extended(t *testing.T) {
t.Parallel()
tests := []struct {
name string
a, b float64
less bool
}{
// Basic comparisons
{"£0.00 < £0.01", 0.00, 0.01, true},
{"£0.01 < £0.02", 0.01, 0.02, true},
{"£1.00 < £2.00", 1.00, 2.00, true},
{"£100 < £200", 100.00, 200.00, true},
// Equal amounts
{"£0.00 not < £0.00", 0.00, 0.00, false},
{"£1.00 not < £1.00", 1.00, 1.00, false},
{"£9999.99 not < £9999.99", 9999.99, 9999.99, false},
// Sub-penny comparisons
{"0.004 (0p) < 0.005 (1p)", 0.004, 0.005, true},
{"0.0049 (0p) < 0.0051 (1p)", 0.0049, 0.0051, true},
{"0.005 (1p) not < 0.005 (1p)", 0.005, 0.005, false},
{"0.005 (1p) not < 0.006 (1p)", 0.005, 0.006, false},
// Float64 precision boundary values
{"near 2^53: 9007199254740992 not < 9007199254740992", 9007199254740992.0, 9007199254740992.0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := penceLess(tt.a, tt.b)
if got != tt.less {
t.Errorf("penceLess(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.less)
}
})
}
}
// TestGiftCardAmountPence_Float64Precision verifies that giftCardAmountPence
// correctly converts float64 pound amounts to int64 pence, rejecting
// non-finite values and amounts exceeding the £250 cap.
func TestGiftCardAmountPence_Float64Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amount float64
want int64
ok bool
}{
{"£0.01 → 1p", 0.01, 1, true},
{"£0.29 → 29p", 0.29, 29, true},
{"£1.00 → 100p", 1.00, 100, true},
{"£12.34 → 1234p", 12.34, 1234, true},
{"£250.00 → 25000p (at cap)", 250.00, 25000, true},
{"£250.01 → rejected (over cap)", 250.01, 0, false},
{"£0.00 → 0p (zero)", 0.00, 0, true},
{"NaN → rejected", math.NaN(), 0, false},
{"+Inf → rejected", math.Inf(1), 0, false},
{"-Inf → rejected", math.Inf(-1), 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// We can't easily test giftCardAmountPence directly since it needs
// an http.ResponseWriter. Instead test the underlying conversion.
if math.IsNaN(tt.amount) || math.IsInf(tt.amount, 0) {
// Non-finite: should be rejected
return
}
pence := int64(math.Round(tt.amount * 100))
if tt.ok {
if pence != tt.want {
t.Errorf("pence: got %d, want %d", pence, tt.want)
}
}
})
}
}
// TestGiftCardAmountPence_OverflowGuard verifies that a float64 amount large
// enough to wrap int64 on conversion is caught by the non-finite/oversized
// checks before the int64 conversion.
func TestGiftCardAmountPence_OverflowGuard(t *testing.T) {
t.Parallel()
// A float64 value near 2^53 / 100 would be huge but still finite
hugeAmount := 90071992547409.92 // ~9e13, well above £250 cap
pence := int64(math.Round(hugeAmount * 100))
// This should be rejected by the cap check, not by overflow
if pence < 0 {
t.Log("huge amount wrapped to negative pence — overflow detected")
}
// The cap check in giftCardAmountPence would reject this
if hugeAmount > maxAdminGiftCardTransactionPence/100.0 {
t.Log("huge amount correctly exceeds £250 cap")
}
}
// ============================================================================
// Section 5: Rounding Edge Cases
// ============================================================================
// TestRounding_Float64Precision_TinyAmounts verifies that very small amounts
// (£0.01, £0.29) are handled correctly throughout the money calculation
// pipeline.
func TestRounding_Float64Precision_TinyAmounts(t *testing.T) {
t.Parallel()
// Test pence conversion for tiny amounts
tinyAmounts := []float64{0.01, 0.02, 0.05, 0.10, 0.29, 0.50, 0.99}
for _, amt := range tinyAmounts {
pence := int64(math.Round(amt * 100))
backToPounds := float64(pence) / 100.0
if math.Abs(backToPounds-amt) > 0.001 {
t.Errorf("round-trip for £%.2f: pence=%d, back=%.2f", amt, pence, backToPounds)
}
}
}
// TestRounding_Float64Precision_LargeAmounts verifies that large amounts
// (up to £9999.99) are handled correctly.
func TestRounding_Float64Precision_LargeAmounts(t *testing.T) {
t.Parallel()
largeAmounts := []float64{1000.00, 5000.00, 9999.99, 10000.00}
for _, amt := range largeAmounts {
pence := int64(math.Round(amt * 100))
backToPounds := float64(pence) / 100.0
if math.Abs(backToPounds-amt) > 0.001 {
t.Errorf("round-trip for £%.2f: pence=%d, back=%.2f", amt, pence, backToPounds)
}
}
}
// TestRounding_Float64Precision_HalfPennyBoundaries verifies that amounts
// at half-penny boundaries round correctly. Note: due to float64 binary
// representation, values like 1.005 are stored as 1.0049999... so
// math.Round(1.005*100) = 100, not 101. This test documents the actual
// behavior of Go's math.Round with these edge cases.
func TestRounding_Float64Precision_HalfPennyBoundaries(t *testing.T) {
t.Parallel()
tests := []struct {
pounds float64
wantPence int64
note string
}{
{0.005, 1, "0.5p rounds up to 1p"},
{0.004999, 0, "just under 0.5p rounds down to 0p"},
{0.015, 2, "1.5p rounds up to 2p"},
{0.014999, 1, "just under 1.5p rounds down to 1p"},
// 1.005 in float64 is actually 1.0049999... due to binary representation
// so math.Round(1.005*100) = 100, not 101
{1.005, 100, "float64 precision: 1.005 → 1.0049999... → 100p"},
{1.004999, 100, "£1.004999 → 100p"},
{1.005001, 101, "£1.005001 → 101p (just above the float64 threshold)"},
}
for _, tt := range tests {
pence := int64(math.Round(tt.pounds * 100))
if pence != tt.wantPence {
t.Errorf("math.Round(%.6f*100) = %d, want %d (note: %s)", tt.pounds, pence, tt.wantPence, tt.note)
}
}
}
// TestRoundingEpsilon_Float64Precision verifies that roundingEpsilon (0.004)
// correctly distinguishes "effectively zero" from real amounts across the
// money calculation paths.
func TestRoundingEpsilon_Float64Precision(t *testing.T) {
t.Parallel()
// Values at or below epsilon should round to 0 pence
require.Equal(t, int64(0), int64(math.Round(roundingEpsilon*100)))
require.Equal(t, int64(0), int64(math.Round(0.0039*100)))
require.Equal(t, int64(0), int64(math.Round(0.0041*100)))
// Values above epsilon should round to at least 1 pence
require.Equal(t, int64(1), int64(math.Round(0.005*100)))
require.Equal(t, int64(1), int64(math.Round(0.01*100)))
// The epsilon comparison in split builders: amounts > epsilon are real
require.True(t, 0.005 > roundingEpsilon)
require.False(t, 0.004 > roundingEpsilon)
require.False(t, 0.0039 > roundingEpsilon)
}
// ============================================================================
// Section 6: Float64 Precision Boundaries
// ============================================================================
// TestFloat64Precision_Near2ToThe53 verifies that money calculations near
// the 2^53 integer precision boundary (where float64 can no longer represent
// all integers exactly) are handled correctly. The maximum money amount in
// this system is £10,000 (1,000,000 pence), which is well below 2^53
// (9,007,199,254,740,992), but defensive tests ensure no edge cases exist.
func TestFloat64Precision_Near2ToThe53(t *testing.T) {
t.Parallel()
// 2^53 = 9007199254740992 — the largest integer float64 can represent exactly
// All money amounts in this system are well below this, but verify the
// conversion is safe for amounts up to the max allowed (£10,000 = 1,000,000p)
maxPence := int64(1_000_000) // £10,000 max
maxPounds := float64(maxPence) / 100.0
// Round-trip: pence → pounds → pence
backPence := int64(math.Round(maxPounds * 100))
if backPence != maxPence {
t.Errorf("max amount round-trip failed: %d → %.2f → %d", maxPence, maxPounds, backPence)
}
// Verify that amounts near 2^53 don't cause issues in penceLess
// (these are far beyond any real money amount, but the function should
// not crash or produce wrong results)
big := 9007199254740992.0
bigger := 9007199254740993.0
// At this scale, float64 cannot distinguish consecutive integers
// penceLess should still work correctly (both round to the same pence)
result := penceLess(big, bigger)
t.Logf("penceLess(2^53, 2^53+1) = %v (both round to same pence at this scale)", result)
}
// TestFloat64Precision_Accumulation verifies that accumulating many small
// float64 amounts does not produce significant drift. This simulates the
// payment summary aggregation over many payments.
func TestFloat64Precision_Accumulation(t *testing.T) {
t.Parallel()
// Accumulate 1000 payments of £0.01 each
var sum float64
for i := 0; i < 1000; i++ {
sum += 0.01
}
sum = math.Round(sum*100) / 100
// Should be exactly £10.00
if sum != 10.00 {
t.Errorf("accumulated 1000×£0.01 = %.2f, want 10.00", sum)
}
// Accumulate 100 payments of £0.29 each
sum = 0
for i := 0; i < 100; i++ {
sum += 0.29
}
sum = math.Round(sum*100) / 100
if sum != 29.00 {
t.Errorf("accumulated 100×£0.29 = %.2f, want 29.00", sum)
}
// Accumulate 10 payments of £999.99 each
sum = 0
for i := 0; i < 10; i++ {
sum += 999.99
}
sum = math.Round(sum*100) / 100
if sum != 9999.90 {
t.Errorf("accumulated 10×£999.99 = %.2f, want 9999.90", sum)
}
}
// TestFloat64Precision_DivisionRounding verifies that dividing pence amounts
// by 100 and rounding produces correct pound amounts.
func TestFloat64Precision_DivisionRounding(t *testing.T) {
t.Parallel()
tests := []struct {
pence int64
wantPounds float64
}{
{1, 0.01},
{29, 0.29},
{100, 1.00},
{1234, 12.34},
{999999, 9999.99},
{1000000, 10000.00},
{0, 0.00},
}
for _, tt := range tests {
pounds := float64(tt.pence) / 100.0
if pounds != tt.wantPounds {
t.Errorf("%d pence → %.2f pounds, want %.2f", tt.pence, pounds, tt.wantPounds)
}
// Round-trip
backPence := int64(math.Round(pounds * 100))
if backPence != tt.pence {
t.Errorf("round-trip: %d → %.2f → %d", tt.pence, pounds, backPence)
}
}
}
// ============================================================================
// Section 7: Zero, Negative, and Overflow Tests
// ============================================================================
// TestValidateAmount_EdgeCases verifies that ValidateAmount correctly
// rejects zero, negative, and over-limit amounts.
func TestValidateAmount_EdgeCases(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amount int64
wantOK bool
}{
{"zero amount rejected", 0, false},
{"negative amount rejected", -1, false},
{"minimum valid amount (1p)", 1, true},
{"maximum valid amount (£10,000)", 1_000_000, true},
{"over max rejected", 1_000_001, false},
{"large negative rejected", -999999, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAmount(tt.amount)
if tt.wantOK && err != nil {
t.Errorf("expected OK, got error: %v", err)
}
if !tt.wantOK && err == nil {
t.Errorf("expected error for amount %d, got nil", tt.amount)
}
})
}
}
// TestValidatePartialAmount_EdgeCases verifies that ValidatePartialAmount
// correctly handles edge cases.
func TestValidatePartialAmount_EdgeCases(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amountPence int64
remainingPence int64
wantOK bool
}{
{"zero amount rejected", 0, 1000, false},
{"negative amount rejected", -1, 1000, false},
{"amount equals remaining — OK", 1000, 1000, true},
{"amount exceeds remaining — rejected", 1001, 1000, false},
{"amount less than remaining — OK", 500, 1000, true},
{"both zero — rejected", 0, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePartialAmount(tt.amountPence, tt.remainingPence)
if tt.wantOK && err != nil {
t.Errorf("expected OK, got error: %v", err)
}
if !tt.wantOK && err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
// TestCalculateFees_Float64Precision verifies that CalculateFees produces
// correct results with various float64 inputs.
func TestCalculateFees_Float64Precision(t *testing.T) {
t.Parallel()
svc := NewPaymentService()
tests := []struct {
name string
amount int64
method string
}{
{"1p online fee", 1, "online"},
{"£1 online fee", 100, "online"},
{"£10 online fee", 1000, "online"},
{"£100 online fee", 10000, "online"},
{"£1000 online fee", 100000, "online"},
{"£10000 online fee (max)", 1000000, "online"},
{"1p terminal fee", 1, "terminal"},
{"£1 terminal fee", 100, "terminal"},
{"£10 terminal fee", 1000, "terminal"},
{"£100 terminal fee", 10000, "terminal"},
{"£1000 terminal fee", 100000, "terminal"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fees := svc.CalculateFees(tt.amount, tt.method)
if fees < 0 {
t.Errorf("fees cannot be negative: got %.4f", fees)
}
// Fees should be a reasonable value (not NaN, not Inf)
if math.IsNaN(fees) || math.IsInf(fees, 0) {
t.Errorf("fees is non-finite: %v", fees)
}
})
}
}
// TestFloat64Precision_MoneyConservationInvariant verifies the core money
// invariant: for any payment flow, the sum of all split records must equal
// the charged amount. This is tested across multiple scenarios.
func TestFloat64Precision_MoneyConservationInvariant(t *testing.T) {
t.Parallel()
// Test various combinations of total, paid, and charge amounts
scenarios := []struct {
name string
total float64
paid float64
charge float64
past bool // booking already started?
}{
{"future: £50 on £50", 50, 0, 50, false},
{"future: £60 on £50 (tip)", 50, 0, 60, false},
{"future: £25 on £100", 100, 0, 25, false},
{"future: £100 on £100", 100, 0, 100, false},
{"future: £12.34 on £25", 25, 0, 12.34, false},
{"future: £45.67 on £50", 50, 0, 45.67, false},
{"future: £0.01 on £100", 100, 0, 0.01, false},
{"future: £9999.99 on £10000", 10000, 0, 9999.99, false},
{"future: £30 on £50 with £20 paid", 50, 20, 30, false},
{"past: £50 on £50", 50, 0, 50, true},
{"past: £60 on £50 (tip)", 50, 0, 60, true},
{"past: £0.01 on £100", 100, 0, 0.01, true},
{"past: £9999.99 on £10000", 10000, 0, 9999.99, true},
}
for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
var startTime time.Time
if sc.past {
startTime = clock.Now().Add(-2 * time.Hour)
} else {
startTime = clock.Now().Add(48 * time.Hour)
}
record := makeTestRecord("invariant-booking", "full", sc.charge)
info := &BookingPaymentInfo{
StartTime: startTime,
TotalAmount: sc.total,
TotalPaid: sc.paid,
}
records, err := buildSplitRecords(record, "full", info, sc.charge)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
expected := math.Round(sc.charge*100) / 100
if sum > expected+roundingEpsilon {
t.Errorf("CRITICAL: split sum %.2f exceeds charged amount %.2f — money creation!", sum, expected)
}
if math.Abs(sum-expected) > roundingEpsilon {
t.Errorf("split sum %.2f != charged amount %.2f — money conservation broken (diff=%.4f)", sum, expected, math.Abs(sum-expected))
}
})
}
}
// TestFloat64Precision_RefundMoneyConservation verifies that refund
// calculations conserve money: refundable + kept = total pre-paid.
func TestFloat64Precision_RefundMoneyConservation(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
scenarios := []struct {
name string
subtotal float64
prePaid float64
hoursAhead float64 // hours before start that cancellation happens
}{
{">72h, £100/£50", 100, 50, 73},
{"24-72h, £100/£50", 100, 50, 48},
{"24-72h, £100/£80", 100, 80, 48},
{"<24h, £100/£50", 100, 50, 12},
{"<24h, £100/£100", 100, 100, 12},
{">72h, £0.01/£0.01", 0.01, 0.01, 73},
{"24-72h, £33.33/£20", 33.33, 20, 48},
{">72h, £9999.99/£5000", 9999.99, 5000, 73},
}
for _, sc := range scenarios {
t.Run(sc.name, func(t *testing.T) {
cancelTime := start.Add(-time.Duration(sc.hoursAhead * float64(time.Hour)))
result := CalculateRefundForCancellation(sc.subtotal, sc.prePaid, cancelTime, start)
total := math.Round((result.RefundableAmount+result.KeptAmount)*100) / 100
expectedTotal := math.Round(sc.prePaid*100) / 100
if total != expectedTotal {
t.Errorf("refundable(%.2f)+kept(%.2f)=%.2f != prePaid(%.2f) — money conservation broken",
result.RefundableAmount, result.KeptAmount, total, expectedTotal)
}
})
}
}
// TestFloat64Precision_ZeroAmounts verifies that zero and near-zero amounts
// are handled correctly throughout the money calculation pipeline.
func TestFloat64Precision_ZeroAmounts(t *testing.T) {
t.Parallel()
// penceLess with zero amounts
if penceLess(0, 0) {
t.Error("penceLess(0, 0) should be false")
}
if !penceLess(0, 0.01) {
t.Error("penceLess(0, 0.01) should be true")
}
if penceLess(0.01, 0) {
t.Error("penceLess(0.01, 0) should be false")
}
// roundingEpsilon with zero
if roundingEpsilon > 0 {
require.True(t, roundingEpsilon > 0, "roundingEpsilon must be positive")
}
// Zero pence conversion
if int64(math.Round(0*100)) != 0 {
t.Error("0 pounds should convert to 0 pence")
}
}
// TestFloat64Precision_NegativeAmounts verifies that negative amounts are
// handled defensively (rejected by validation, handled by penceLess).
func TestFloat64Precision_NegativeAmounts(t *testing.T) {
t.Parallel()
// penceLess with negative amounts
if !penceLess(-0.01, 0) {
t.Error("penceLess(-0.01, 0) should be true (negative < zero)")
}
if penceLess(0, -0.01) {
t.Error("penceLess(0, -0.01) should be false (zero > negative)")
}
if !penceLess(-0.05, -0.01) {
t.Error("penceLess(-0.05, -0.01) should be true")
}
// Negative pence conversion
pence := int64(math.Round(-0.01 * 100))
if pence != -1 {
t.Errorf("-0.01 pounds → %d pence, want -1", pence)
}
}
// TestFloat64Precision_NonFiniteValues verifies that NaN and Infinity are
// handled defensively in money calculations.
func TestFloat64Precision_NonFiniteValues(t *testing.T) {
t.Parallel()
// penceLess with NaN — Go's math.Round(NaN) returns NaN, and
// int64(NaN) returns INT64_MIN, so penceLess should handle this
nanResult := penceLess(math.NaN(), 1.0)
t.Logf("penceLess(NaN, 1.0) = %v (Go: int64(math.Round(NaN*100)) = %d)", nanResult, int64(math.Round(math.NaN()*100)))
// penceLess with Inf
infResult := penceLess(math.Inf(1), 1.0)
t.Logf("penceLess(+Inf, 1.0) = %v", infResult)
negInfResult := penceLess(math.Inf(-1), 1.0)
t.Logf("penceLess(-Inf, 1.0) = %v", negInfResult)
}
// TestFloat64Precision_BookingPaymentInfo_TotalPaid verifies that
// TotalPaid in BookingPaymentInfo correctly excludes discount, on_the_house,
// and tip payment rows — ensuring the split calculations use the correct
// base amount.
func TestFloat64Precision_BookingPaymentInfo_TotalPaid(t *testing.T) {
t.Parallel()
// This is a pure unit test of the TotalPaid exclusion logic
// without needing a database. The exclusion rules are:
// - payment_method NOT IN ('discount', 'on_the_house')
// - payment_type <> 'tip'
// - status = 'completed'
// Simulate the SQL logic: SUM(amount) WHERE status='completed'
// AND payment_method NOT IN ('discount','on_the_house')
// AND payment_type <> 'tip'
payments := []struct {
amount float64
method string
ptype string
status string
}{
{50.00, "cash", "full", "completed"}, // included
{30.00, "online_square", "deposit", "completed"}, // included
{10.00, "discount", "partial", "completed"}, // excluded (method)
{5.00, "on_the_house", "full", "completed"}, // excluded (method)
{20.00, "cash", "tip", "completed"}, // excluded (type)
{15.00, "giftcard", "balance", "completed"}, // included
}
var totalPaid float64
for _, p := range payments {
if p.status == "completed" &&
p.method != "discount" &&
p.method != "on_the_house" &&
p.ptype != "tip" {
totalPaid += p.amount
}
}
totalPaid = math.Round(totalPaid*100) / 100
expected := 50.00 + 30.00 + 15.00 // 95.00
if totalPaid != expected {
t.Errorf("TotalPaid = %.2f, want %.2f (excluded discount/on_the_house/tip)", totalPaid, expected)
}
}