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