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
1155 lines
38 KiB
Go
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)
|
|
}
|
|
} |