From 4146f8e09ae4dc3509a23fb1dc2e624075fc88b4 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 20 Aug 2026 13:09:42 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20pre-launch=20review=20=E2=80=94=20securi?= =?UTF-8?q?ty,=20money=20safety,=20privacy,=20legal,=20code=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../payments/float64_validity_round2_test.go | 1204 ++++++++++++ .../payments/float64_validity_round3_test.go | 1682 +++++++++++++++++ .../payments/float64_validity_test.go | 1155 +++++++++++ backend/handlers/payments/refund_policy.go | 3 +- backend/handlers/user/twofa.go | 4 +- backend/internal/twofa/twofa.go | 14 +- backend/internal/twofa/twofa_test.go | 2 +- backend/main.go | 2 - frontend/src/app.html | 2 +- .../lib/components/admin/HolidayHours.svelte | 3 +- .../lib/components/layout/ContactCard.svelte | 33 +- .../layout/PortfolioCarousel.svelte | 42 +- frontend/src/lib/components/ui/map/Map.svelte | 17 +- frontend/src/lib/constants/policy.ts | 15 +- frontend/src/routes/+layout.svelte | 7 +- frontend/src/routes/+page.svelte | 6 - frontend/src/routes/account/+page.svelte | 5 +- frontend/src/routes/contact/+page.svelte | 3 +- .../src/routes/privacy-policy/+page.svelte | 6 +- frontend/src/routes/terms/+page.svelte | 42 +- .../fonts/playfair-display-latin-italic.woff2 | Bin 0 -> 38804 bytes .../fonts/playfair-display-latin-normal.woff2 | Bin 0 -> 38404 bytes frontend/static/fonts/playfair-display.css | 22 + .../images/portfolio/placeholder-01.svg | 19 + .../images/portfolio/placeholder-02.svg | 17 + .../images/portfolio/placeholder-03.svg | 18 + .../images/portfolio/placeholder-04.svg | 17 + .../images/portfolio/placeholder-05.svg | 18 + .../images/portfolio/placeholder-06.svg | 24 + .../images/portfolio/placeholder-07.svg | 26 + .../images/portfolio/placeholder-08.svg | 24 + 31 files changed, 4351 insertions(+), 81 deletions(-) create mode 100644 backend/handlers/payments/float64_validity_round2_test.go create mode 100644 backend/handlers/payments/float64_validity_round3_test.go create mode 100644 backend/handlers/payments/float64_validity_test.go create mode 100644 frontend/static/fonts/playfair-display-latin-italic.woff2 create mode 100644 frontend/static/fonts/playfair-display-latin-normal.woff2 create mode 100644 frontend/static/fonts/playfair-display.css create mode 100644 frontend/static/images/portfolio/placeholder-01.svg create mode 100644 frontend/static/images/portfolio/placeholder-02.svg create mode 100644 frontend/static/images/portfolio/placeholder-03.svg create mode 100644 frontend/static/images/portfolio/placeholder-04.svg create mode 100644 frontend/static/images/portfolio/placeholder-05.svg create mode 100644 frontend/static/images/portfolio/placeholder-06.svg create mode 100644 frontend/static/images/portfolio/placeholder-07.svg create mode 100644 frontend/static/images/portfolio/placeholder-08.svg diff --git a/backend/handlers/payments/float64_validity_round2_test.go b/backend/handlers/payments/float64_validity_round2_test.go new file mode 100644 index 0000000..b8a4952 --- /dev/null +++ b/backend/handlers/payments/float64_validity_round2_test.go @@ -0,0 +1,1204 @@ +//go:build test && dev + +package payments + +import ( + "math" + "testing" + "time" + + "crussell/clock" + "crussell/testutils" + "crussell/testutils/fixtures" + + "github.com/stretchr/testify/require" +) + +// ============================================================================ +// Section 1: roundTo2 — Pure Float64 Rounding +// ============================================================================ + +// TestRoundTo2_Float64Precision verifies that roundTo2 correctly rounds +// float64 values to 2 decimal places using math.Round(f*100)/100. +func TestRoundTo2_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input float64 + want float64 + }{ + {"simple value", 12.34, 12.34}, + {"round up", 12.345, 12.35}, + {"round down", 12.344, 12.34}, + {"integer", 50.00, 50.00}, + {"zero", 0.00, 0.00}, + {"half-penny up", 0.005, 0.01}, + {"half-penny down (float64 quirk)", 1.005, 1.00}, // float64 1.005 → 1.0049999... + {"just above half-penny", 1.005001, 1.01}, + {"large value", 9999.99, 9999.99}, + {"very small", 0.01, 0.01}, + {"recurring decimal", 33.333333, 33.33}, + {"negative", -12.345, -12.35}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundTo2(tt.input) + if got != tt.want { + t.Errorf("roundTo2(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// TestRoundTo2_Deterministic verifies that roundTo2 is deterministic — +// calling it 100 times with the same input produces the same result. +func TestRoundTo2_Deterministic(t *testing.T) { + t.Parallel() + + inputs := []float64{12.345, 0.005, 33.333, 9999.994, 1.005} + for _, input := range inputs { + t.Run("", func(t *testing.T) { + first := roundTo2(input) + for i := 0; i < 100; i++ { + if roundTo2(input) != first { + t.Errorf("roundTo2(%v) changed between calls: first=%v, iter=%d", input, first, i) + } + } + }) + } +} + +// TestRoundTo2_AccumulationDrift verifies that accumulating roundTo2 results +// does not produce significant drift compared to rounding the total. +func TestRoundTo2_AccumulationDrift(t *testing.T) { + t.Parallel() + + // 100 payments of £12.34 each + var sumRounded float64 + for i := 0; i < 100; i++ { + sumRounded += roundTo2(12.34) + } + sumRounded = roundTo2(sumRounded) + + // Direct: 100 * 12.34 = 1234.00 + direct := roundTo2(100 * 12.34) + + if sumRounded != direct { + t.Errorf("accumulated roundTo2 sum = %.2f, direct = %.2f — drift detected", sumRounded, direct) + } +} + +// ============================================================================ +// Section 2: effectiveVoucherTypeForPurchase — Pure Function +// ============================================================================ + +func TestEffectiveVoucherTypeForPurchase(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want string + }{ + {"SPV stays SPV", "SPV", "SPV"}, + {"MPV overridden to SPV", "MPV", "SPV"}, + {"empty string", "", ""}, + {"lowercase spv", "spv", "spv"}, + {"lowercase mpv", "mpv", "mpv"}, + {"unknown type", "OTHER", "OTHER"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := effectiveVoucherTypeForPurchase(tt.raw) + if got != tt.want { + t.Errorf("effectiveVoucherTypeForPurchase(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 3: vatAppliesToVoucher — Pure Function +// ============================================================================ + +func TestVATAppliesToVoucher(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *VATConfig + want bool + }{ + {"nil config", nil, false}, + {"not registered, SPV", &VATConfig{IsVATRegistered: false, VoucherType: "SPV"}, false}, + {"registered, SPV", &VATConfig{IsVATRegistered: true, VoucherType: "SPV"}, true}, + {"registered, MPV", &VATConfig{IsVATRegistered: true, VoucherType: "MPV"}, false}, // vatAppliesToVoucher does not normalize + {"not registered, MPV", &VATConfig{IsVATRegistered: false, VoucherType: "MPV"}, false}, + {"registered, empty type", &VATConfig{IsVATRegistered: true, VoucherType: ""}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := vatAppliesToVoucher(tt.cfg) + if got != tt.want { + t.Errorf("vatAppliesToVoucher(%+v) = %v, want %v", tt.cfg, got, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 4: CalculateFees — Exact Value Tests +// ============================================================================ + +// TestCalculateFees_ExactValues verifies that CalculateFees produces the +// exact expected fee amounts for both online and terminal payment methods. +// +// Online formula: amount×14/1000 + 25 (pence), converted to pounds +// Terminal formula: amount×175/10000 (pence), converted to pounds +func TestCalculateFees_ExactValues(t *testing.T) { + t.Parallel() + + svc := NewPaymentService() + + tests := []struct { + name string + amount int64 // pence + method string + want float64 + }{ + {"online: 1p → 25p → £0.25", 1, "online", 0.25}, + {"online: 100p (£1) → 26p → £0.26", 100, "online", 0.26}, + {"online: 1000p (£10) → 39p → £0.39", 1000, "online", 0.39}, + {"online: 10000p (£100) → 165p → £1.65", 10000, "online", 1.65}, + {"online: 100000p (£1000) → 1425p → £14.25", 100000, "online", 14.25}, + {"online: 1000000p (£10000) → 14025p → £140.25", 1000000, "online", 140.25}, + + {"terminal: 1p → 0p → £0.00", 1, "terminal", 0.00}, + {"terminal: 100p (£1) → 1p → £0.01", 100, "terminal", 0.01}, + {"terminal: 1000p (£10) → 17p → £0.17", 1000, "terminal", 0.17}, + {"terminal: 10000p (£100) → 175p → £1.75", 10000, "terminal", 1.75}, + {"terminal: 100000p (£1000) → 1750p → £17.50", 100000, "terminal", 17.50}, + {"terminal: 1000000p (£10000) → 17500p → £175.00", 1000000, "terminal", 175.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fees := svc.CalculateFees(tt.amount, tt.method) + if math.Abs(fees-tt.want) > 0.0001 { + t.Errorf("CalculateFees(%d, %q) = %.6f, want %.6f", tt.amount, tt.method, fees, tt.want) + } + if fees < 0 { + t.Errorf("CalculateFees(%d, %q) = %.6f — negative fees!", tt.amount, tt.method, fees) + } + if math.IsNaN(fees) || math.IsInf(fees, 0) { + t.Errorf("CalculateFees(%d, %q) = %v — non-finite!", tt.amount, tt.method, fees) + } + }) + } +} + +// TestCalculateFees_EdgeCases verifies fee calculation at boundary values. +func TestCalculateFees_EdgeCases(t *testing.T) { + t.Parallel() + + svc := NewPaymentService() + + onlineZero := svc.CalculateFees(0, "online") + if onlineZero != 0.25 { + t.Errorf("online fee for 0 amount = %.4f, want 0.25 (min fee only)", onlineZero) + } + + terminalZero := svc.CalculateFees(0, "terminal") + if terminalZero != 0.00 { + t.Errorf("terminal fee for 0 amount = %.4f, want 0.00", terminalZero) + } + + onlineMax := svc.CalculateFees(1_000_000, "online") + if onlineMax <= 0 { + t.Errorf("online fee for max amount = %.4f, want > 0", onlineMax) + } + + terminalMax := svc.CalculateFees(1_000_000, "terminal") + if terminalMax <= 0 { + t.Errorf("terminal fee for max amount = %.4f, want > 0", terminalMax) + } + + unknown := svc.CalculateFees(10000, "unknown") + terminal := svc.CalculateFees(10000, "terminal") + if unknown != terminal { + t.Errorf("unknown method fee = %.4f, terminal fee = %.4f — should match", unknown, terminal) + } +} + +// ============================================================================ +// Section 5: buildSplitRecords — Missing Type Cases +// ============================================================================ + +// TestBuildSplitRecords_BalanceType verifies that when totalPaidAfterBalance +// >= totalAmount AND there was previous payment, the second record gets +// payment_type='balance'. +func TestBuildSplitRecords_BalanceType(t *testing.T) { + t.Parallel() + + // £100 total, £25 already paid (deposit), charge £75 more + // deposit room = min(50, 50-25) = 25 + // depositAmount = min(75, 25) = 25 + // remainingAfterDeposit = 75-25 = 50 + // bookingRemaining = max(0, 100-25-25) = 50 + // balancePortion = min(50, 50) = 50 + // totalPaidAfterBalance = 25 + 25 + 50 = 100 >= 100 AND 100-50 = 50 > 0 → "balance" + record := makeTestRecord("balance-test", "full", 75) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 25, + } + records, err := buildSplitRecords(record, "full", info, 75) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(records), 2, "expected at least 2 split records") + + var foundBalance bool + for _, r := range records { + if r.PaymentType == "balance" { + foundBalance = true + if r.Amount != 50 { + t.Errorf("balance record amount = %.2f, want 50.00", r.Amount) + } + } + } + if !foundBalance { + t.Error("expected a 'balance' type record, none found") + } + + // Verify sum equals charged amount +var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + if math.Abs(sum-75) > roundingEpsilon { + t.Errorf("split sum %.2f != charged amount 75.00", sum) + } +} + +func TestBuildSplitRecords_FullType(t *testing.T) { + t.Parallel() + + record := makeTestRecord("full-type-test", "full", 25) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 25, + } + records, err := buildSplitRecords(record, "full", info, 25) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(records), 1, "expected at least 1 record") + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + if math.Abs(sum-25) > roundingEpsilon { + t.Errorf("split sum %.2f != charged amount 25.00", sum) + } +} + +func TestBuildSplitRecords_PartialType(t *testing.T) { + t.Parallel() + + record := makeTestRecord("partial-type-test", "full", 30) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 0, + } + records, err := buildSplitRecords(record, "full", info, 30) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(records), 1, "expected at least 1 record") + + if records[0].PaymentType != "deposit" { + t.Errorf("expected 'deposit', got %q", records[0].PaymentType) + } + if records[0].Amount != 30 { + t.Errorf("expected amount 30, got %.2f", records[0].Amount) + } + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + if math.Abs(sum-30) > roundingEpsilon { + t.Errorf("split sum %.2f != charged amount 30.00", sum) + } +} + +func TestBuildSplitRecords_DefensiveFallback(t *testing.T) { + t.Parallel() + + record := makeTestRecord("fallback-test", "full", 0.003) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 50, + } + records, err := buildSplitRecords(record, "full", info, 0.003) + require.NoError(t, err) + + require.Equal(t, 1, len(records), "expected 1 fallback record") + if records[0].Amount != 0.003 { + t.Errorf("fallback record amount = %.4f, want 0.003", records[0].Amount) + } +} + +func TestBuildSplitRecords_RoundingEpsilonBoundary(t *testing.T) { + t.Parallel() + + record := makeTestRecord("epsilon-test", "full", 0.005) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records, err := buildSplitRecords(record, "full", info, 0.005) + require.NoError(t, err) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + if math.Abs(sum-0.01) > roundingEpsilon { + t.Errorf("split sum %.4f != expected 0.01", sum) + } +} + +func TestBuildSplitRecords_PostStart_NoTip(t *testing.T) { + t.Parallel() + + record := makeTestRecord("poststart-notip", "full", 50) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records, err := buildSplitRecords(record, "full", info, 50) + require.NoError(t, err) + + require.Equal(t, 1, len(records), "expected 1 record (no tip)") + if records[0].Amount != 50 { + t.Errorf("record amount = %.2f, want 50.00", records[0].Amount) + } +} + +func TestBuildSplitRecords_PostStart_TipOverflowError(t *testing.T) { + t.Parallel() + + record := makeTestRecord("poststart-tip-error", "full", 301) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + _, err := buildSplitRecords(record, "full", info, 301) + require.Error(t, err, "expected error for tip exceeding maxOnlineTipPence") +} + +func TestBuildSplitRecords_PreStart_TipOverflowError(t *testing.T) { + t.Parallel() + + record := makeTestRecord("prestart-tip-error", "full", 301) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + _, err := buildSplitRecords(record, "full", info, 301) + require.Error(t, err, "expected error for tip exceeding maxOnlineTipPence") +} + +// ============================================================================ +// Section 6: buildTerminalSplitRecords — Missing Cases +// ============================================================================ + +func TestBuildTerminalSplitRecords_DefensiveFallback(t *testing.T) { + t.Parallel() + + primary := makeTestRecord("terminal-fallback", "full", 0.003) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 50, + } + records := buildTerminalSplitRecords(primary, info, 0.003, 0) + require.Equal(t, 1, len(records), "expected 1 fallback record") + if records[0].Amount != 0.003 { + t.Errorf("fallback record amount = %.4f, want 0.003", records[0].Amount) + } +} + +func TestBuildTerminalSplitRecords_RoundingEpsilonBoundary(t *testing.T) { + t.Parallel() + + primary := makeTestRecord("terminal-epsilon", "full", 0.01) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records := buildTerminalSplitRecords(primary, info, 0.01, 0) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + if math.Abs(sum-0.01) > roundingEpsilon { + t.Errorf("split sum %.4f != expected 0.01", sum) + } +} + +func TestBuildTerminalSplitRecords_TipOnly(t *testing.T) { + t.Parallel() + + primary := makeTestRecord("terminal-tip-only", "full", 10) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 50, + } + records := buildTerminalSplitRecords(primary, info, 0, 10) + + require.Equal(t, 1, len(records), "expected 1 tip record") + if records[0].PaymentType != "tip" { + t.Errorf("expected 'tip', got %q", records[0].PaymentType) + } + if records[0].Amount != 10 { + t.Errorf("tip amount = %.2f, want 10.00", records[0].Amount) + } +} + +// ============================================================================ +// Section 7: CalculateRefundForCancellation — Boundary Tests +// ============================================================================ + +// TestCalculateRefundForCancellation_ExactThresholds verifies behaviour at +// the exact tier boundaries (72h and 24h). +func TestCalculateRefundForCancellation_ExactThresholds(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: "exactly 72h before — partial refund (not > 72h)", + subtotal: 100, + prePaid: 50, + cancelTime: start.Add(-72 * time.Hour), + wantTier: PartialRefundTier, + wantRefund: 0, + wantKept: 50, + }, + { + name: "71.999h before — partial refund (just inside 24-72h)", + subtotal: 100, + prePaid: 50, + cancelTime: start.Add(-72*time.Hour + time.Second), + wantTier: PartialRefundTier, + wantRefund: 0, + wantKept: 50, + }, + { + name: "exactly 24h before — partial refund", + subtotal: 100, + prePaid: 50, + cancelTime: start.Add(-24 * time.Hour), + wantTier: PartialRefundTier, + wantRefund: 0, + wantKept: 50, + }, + { + name: "23.999h before — no refund (just inside <24h)", + subtotal: 100, + prePaid: 50, + cancelTime: start.Add(-24*time.Hour + time.Second), + wantTier: NoRefundTier, + wantRefund: 0, + wantKept: 50, + }, + } + + 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) + } + + 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) + } + }) + } +} + +func TestCalculateRefundForCancellation_NegativeGuard(t *testing.T) { + t.Parallel() + + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + cancelTime := start.Add(-48 * time.Hour) + + result := CalculateRefundForCancellation(100, 50, cancelTime, start) + + if result.RefundableAmount < 0 { + t.Errorf("refundable amount is negative: %.2f", result.RefundableAmount) + } +} + +func TestCalculateRefundForCancellation_ZeroPrePaid(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, £0 paid, >72h — full refund (nothing to refund)", + subtotal: 100, + prePaid: 0, + cancelTime: start.Add(-73 * time.Hour), + wantTier: FullRefundTier, + wantRefund: 0, + wantKept: 0, + }, + { + name: "£100 subtotal, £0 paid, 24-72h — partial refund (nothing to refund)", + subtotal: 100, + prePaid: 0, + cancelTime: start.Add(-48 * time.Hour), + wantTier: PartialRefundTier, + wantRefund: 0, + wantKept: 0, + }, + { + name: "£100 subtotal, £0 paid, <24h — no refund (nothing to keep)", + subtotal: 100, + prePaid: 0, + cancelTime: start.Add(-12 * time.Hour), + wantTier: NoRefundTier, + wantRefund: 0, + 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 result.RefundableAmount != 0 { + t.Errorf("refundable: got %.2f, want 0", result.RefundableAmount) + } + if result.KeptAmount != 0 { + t.Errorf("kept: got %.2f, want 0", result.KeptAmount) + } + }) + } +} + +func TestCalculateRefundForCancellation_ProtectedDepositCapping(t *testing.T) { + t.Parallel() + + start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) + cancelTime := start.Add(-48 * time.Hour) + + result := CalculateRefundForCancellation(100, 60, cancelTime, start) + + if result.Tier != PartialRefundTier { + t.Errorf("tier: got %q, want %q", result.Tier, PartialRefundTier) + } + if math.Abs(result.ProtectedDeposit-50) > 0.005 { + t.Errorf("protected deposit: got %.2f, want 50.00", result.ProtectedDeposit) + } + if math.Abs(result.KeptAmount-50) > 0.005 { + t.Errorf("kept: got %.2f, want 50.00", result.KeptAmount) + } + if math.Abs(result.RefundableAmount-10) > 0.005 { + t.Errorf("refundable: got %.2f, want 10.00", result.RefundableAmount) + } +} + +// ============================================================================ +// Section 8: capDiscountToRemainingObligation — Pure Float64 Function +// ============================================================================ + +func TestCapDiscountToRemainingObligation_Float64Precision(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(48*time.Hour)) + require.NoError(t, err) + + _, err = tx.Exec(ctx, `UPDATE bookings SET total_amount = 100 WHERE id = $1`, bookingID) + require.NoError(t, err) + + capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, 50) + require.True(t, ok, "discount should be allowed") + if math.Abs(capped-50) > 0.005 { + t.Errorf("capped discount = %.2f, want 50.00", capped) + } + + // Discount of £150 should be capped to £100 (the headroom) + capped, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 150) + require.True(t, ok, "discount should still be allowed (capped)") + if math.Abs(capped-100) > 0.005 { + t.Errorf("capped discount = %.2f, want 100.00", capped) + } + + // Add a completed payment of £100 + _, err = tx.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status) + VALUES ($1, 'full', 'cash', 100, 'completed') + `, bookingID) + require.NoError(t, err) + + // Now headroom = 0, discount should be skipped + _, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 10) + require.False(t, ok, "discount should be skipped when headroom is 0") +} + +func TestCapDiscountToRemainingObligation_EdgeCases(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(48*time.Hour)) + require.NoError(t, err) + + _, err = tx.Exec(ctx, `UPDATE bookings SET total_amount = 50 WHERE id = $1`, bookingID) + require.NoError(t, err) + + capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, 50) + require.True(t, ok) + if math.Abs(capped-50) > 0.005 { + t.Errorf("capped = %.2f, want 50.00", capped) + } + + capped, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 0.01) + require.True(t, ok) + if math.Abs(capped-0.01) > 0.005 { + t.Errorf("capped = %.2f, want 0.01", capped) + } + + capped, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 0) + require.True(t, ok) + if capped != 0 { + t.Errorf("capped = %.2f, want 0.00", capped) + } +} + +// ============================================================================ +// Section 9: GetGiftCardExpiryMonths — Edge Cases +// ============================================================================ + +func TestGetGiftCardExpiryMonths_EdgeCases(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + months, err := GetGiftCardExpiryMonths(ctx, tx) + require.NoError(t, err) + require.Greater(t, months, 0, "expiry months must be positive") + + _, err = tx.Exec(ctx, `UPDATE business_settings SET gift_card_expiry_months = 12`) + require.NoError(t, err) + months, err = GetGiftCardExpiryMonths(ctx, tx) + require.NoError(t, err) + require.Equal(t, 12, months, "expected 12 months") + + _, err = tx.Exec(ctx, `UPDATE business_settings SET gift_card_expiry_months = 0`) + require.NoError(t, err) + months, err = GetGiftCardExpiryMonths(ctx, tx) + require.NoError(t, err) + require.Equal(t, defaultGiftCardExpiryMonths, months, "expected default for invalid value 0") + + _, err = tx.Exec(ctx, `UPDATE business_settings SET gift_card_expiry_months = -1`) + require.NoError(t, err) + months, err = GetGiftCardExpiryMonths(ctx, tx) + require.NoError(t, err) + require.Equal(t, defaultGiftCardExpiryMonths, months, "expected default for negative value") +} + +// ============================================================================ +// Section 10: giftCardAmountPence — Additional Edge Cases +// ============================================================================ + +// TestGiftCardAmountPence_SubPennyBoundaries verifies that sub-penny amounts +// are correctly rounded to pence. +func TestGiftCardAmountPence_SubPennyBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount float64 + want int64 + }{ + {"£0.004 → 0p (below 0.5p threshold)", 0.004, 0}, + {"£0.004999 → 0p (just below 0.5p)", 0.004999, 0}, + {"£0.005 → 1p (at 0.5p threshold)", 0.005, 1}, + {"£0.009 → 1p", 0.009, 1}, + {"£0.014999 → 1p (just below 1.5p)", 0.014999, 1}, + {"£0.015 → 2p (at 1.5p threshold)", 0.015, 2}, + {"£0.01 → 1p", 0.01, 1}, + {"£0.99 → 99p", 0.99, 99}, + {"£0.999 → 100p", 0.999, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pence := int64(math.Round(tt.amount * 100)) + if pence != tt.want { + t.Errorf("math.Round(%.6f*100) = %d, want %d", tt.amount, pence, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 11: ValidateAmount — Additional Edge Cases +// ============================================================================ + +// TestValidateAmount_Precision verifies that ValidateAmount correctly handles +// precision-related edge cases. +func TestValidateAmount_Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount int64 + wantOK bool + }{ + {"1p — minimum valid", 1, true}, + {"£10,000 — maximum valid", 1_000_000, true}, + {"£10,000.01 — just over max", 1_000_001, false}, + {"£0 — rejected", 0, false}, + {"-1p — rejected", -1, false}, + {"£5,000 — valid", 500_000, true}, + {"£9,999.99 — valid", 999_999, true}, + } + + 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) + } + }) + } +} + +// ============================================================================ +// Section 12: ValidatePartialAmount — Additional Edge Cases +// ============================================================================ + +// TestValidatePartialAmount_EdgeCasesExtended verifies additional edge cases +// for ValidatePartialAmount. +func TestValidatePartialAmount_EdgeCasesExtended(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amountPence int64 + remainingPence int64 + wantOK bool + }{ + {"1p partial on £10 remaining — OK", 1, 1000, true}, + {"£9.99 partial on £10 remaining — OK", 999, 1000, true}, + {"£10 partial on £10 remaining — OK (equals)", 1000, 1000, true}, + {"£10.01 partial on £10 remaining — rejected (exceeds)", 1001, 1000, false}, + {"£0 partial on £10 remaining — rejected (zero)", 0, 1000, false}, + {"-1p partial on £10 remaining — rejected (negative)", -1, 1000, false}, + {"£0 on £0 remaining — rejected", 0, 0, false}, + {"£1 on £0 remaining — rejected (exceeds)", 1, 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") + } + }) + } +} + +// ============================================================================ +// Section 13: penceLess — Additional Edge Cases +// ============================================================================ + +// TestPenceLess_ExtendedBoundaries verifies penceLess with additional +// boundary values including float64 precision pitfalls. +func TestPenceLess_ExtendedBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b float64 + less bool + }{ + {"1.009 < 1.01 → false (both 1.01 in pence)", 1.009, 1.01, false}, + {"1.009 < 1.011 → false (both 1.01 in pence)", 1.009, 1.011, false}, + {"1.009 < 1.021 → true (1.01 vs 1.02 in pence)", 1.009, 1.021, true}, + {"1.005 < 1.01 → true (1.00 vs 1.01 in pence due to float64)", 1.005, 1.01, true}, + {"1.005001 < 1.01 → false (1.01 vs 1.01 — equal)", 1.005001, 1.01, false}, + {"£1000 < £2000", 1000.00, 2000.00, true}, + {"£9999.99 < £10000.00", 9999.99, 10000.00, true}, + {"0.0001 < 0.001 → true (0p vs 0p — equal)", 0.0001, 0.001, false}, + {"0.004 < 0.005 → true (0p vs 1p)", 0.004, 0.005, true}, + {"0.0049 < 0.0051 → true (0p vs 1p)", 0.0049, 0.0051, true}, + } + + 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) + } + }) + } +} + +// ============================================================================ +// Section 14: Float64 Pence Conversion Round-Trip +// ============================================================================ + +// TestFloat64PenceRoundTrip verifies that converting between pounds and pence +// is lossless for all common money amounts. +func TestFloat64PenceRoundTrip(t *testing.T) { + t.Parallel() + + for pence := int64(1); pence <= 1000; pence++ { + pounds := float64(pence) / 100.0 + backPence := int64(math.Round(pounds * 100)) + if backPence != pence { + t.Errorf("round-trip failed at %d pence: → %.4f → %d", pence, pounds, backPence) + break + } + } + + for pounds := int64(10); pounds <= 1000; pounds += 10 { + pence := pounds * 100 + poundsFloat := float64(pence) / 100.0 + backPence := int64(math.Round(poundsFloat * 100)) + if backPence != pence { + t.Errorf("round-trip failed at %d pence: → %.2f → %d", pence, poundsFloat, backPence) + } + } +} + +// TestFloat64PenceConversion_OddAmounts verifies that odd amounts +// (with fractional pence) round-trip correctly. +func TestFloat64PenceConversion_OddAmounts(t *testing.T) { + t.Parallel() + + amounts := []float64{ + 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, + 0.10, 0.25, 0.50, 0.75, 0.99, + 1.00, 1.01, 1.99, 12.34, 45.67, 89.01, + 100.00, 250.00, 500.00, 999.99, 1000.00, + 1234.56, 5678.90, 9999.99, + } + + for _, amt := range amounts { + pence := int64(math.Round(amt * 100)) + backToPounds := float64(pence) / 100.0 + diff := math.Abs(backToPounds - amt) + if diff > 0.001 { + t.Errorf("round-trip for £%.2f: pence=%d, back=%.2f, diff=%.6f", amt, pence, backToPounds, diff) + } + } +} + +// ============================================================================ +// Section 15: Float64 Accumulation Drift in Payment Summary +// ============================================================================ + +// TestFloat64Accumulation_PaymentSummary verifies that accumulating many +// payment amounts does not produce significant drift. +func TestFloat64Accumulation_PaymentSummary(t *testing.T) { + t.Parallel() + + payments := []float64{12.34, 45.67, 89.01, 23.45, 67.89, + 34.56, 78.90, 12.09, 56.78, 90.12} + + var sum float64 + for _, p := range payments { + sum += p + } + sum = math.Round(sum*100) / 100 + + expected := 510.81 + if math.Abs(sum-expected) > 0.005 { + t.Errorf("payment sum = %.2f, want %.2f", sum, expected) + } + + var sumReverse float64 + for i := len(payments) - 1; i >= 0; i-- { + sumReverse += payments[i] + } + sumReverse = math.Round(sumReverse*100) / 100 + + if sum != sumReverse { + t.Errorf("forward sum %.2f != reverse sum %.2f — float64 non-associativity detected", sum, sumReverse) + } +} + +func TestFloat64Accumulation_ManySmallPayments(t *testing.T) { + t.Parallel() + + var sum float64 + for i := 0; i < 10000; i++ { + sum += 0.01 + } + sum = math.Round(sum*100) / 100 + + if sum != 100.00 { + t.Errorf("accumulated 10000×£0.01 = %.2f, want 100.00", sum) + } + + sum = 0 + for i := 0; i < 1000; i++ { + sum += 0.29 + } + sum = math.Round(sum*100) / 100 + if sum != 290.00 { + t.Errorf("accumulated 1000×£0.29 = %.2f, want 290.00", sum) + } +} + +// ============================================================================ +// Section 16: Float64 Precision in Discount Calculations +// ============================================================================ + +// TestDiscountAmount_Float64Precision verifies that discount amount +// calculations (bookingTotal * percent / 100) are precise. +func TestDiscountAmount_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + percent float64 + wantAmount float64 + }{ + {"£100 at 10% = £10", 100, 10, 10.00}, + {"£50 at 20% = £10", 50, 20, 10.00}, + {"£33.33 at 10% = £3.33", 33.33, 10, 3.33}, + {"£99.99 at 5% = £5.00", 99.99, 5, 5.00}, + {"£12.34 at 15% = £1.85", 12.34, 15, 1.85}, + {"£0.01 at 10% = £0.00", 0.01, 10, 0.00}, + {"£9999.99 at 10% = £1000.00", 9999.99, 10, 1000.00}, + {"£100 at 0% = £0", 100, 0, 0.00}, + {"£100 at 100% = £100", 100, 100, 100.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amount := roundTo2(tt.total * tt.percent / 100) + if math.Abs(amount-tt.wantAmount) > 0.005 { + t.Errorf("discount amount = %.2f, want %.2f", amount, tt.wantAmount) + } + }) + } +} + +// TestDiscountAmount_Stacking verifies that stacking multiple discounts +// does not exceed the booking total. +func TestDiscountAmount_Stacking(t *testing.T) { + t.Parallel() + + total := 100.00 + discounts := []float64{10.0, 5.0, 20.0} + + var totalDiscount float64 + for _, pct := range discounts { + amount := roundTo2(total * pct / 100) + totalDiscount += amount + } + totalDiscount = roundTo2(totalDiscount) + + if math.Abs(totalDiscount-35.00) > 0.005 { + t.Errorf("total discount = %.2f, want 35.00", totalDiscount) + } + if totalDiscount > total { + t.Errorf("total discount %.2f exceeds booking total %.2f", totalDiscount, total) + } +} + +// ============================================================================ +// Section 17: Float64 Precision in Tip Calculations +// ============================================================================ + +// TestTipAmount_Float64Precision verifies that tip calculations are precise. +func TestTipAmount_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + charge float64 + bookingAmt float64 + paid float64 + wantTip float64 + }{ + {"£60 charge on £50 booking = £10 tip", 60, 50, 0, 10.00}, + {"£55 charge on £50 booking = £5 tip", 55, 50, 0, 5.00}, + {"£50 charge on £50 booking = £0 tip", 50, 50, 0, 0.00}, + {"£25 charge on £50 with £25 paid = £0 tip", 25, 50, 25, 0.00}, + {"£30 charge on £50 with £25 paid = £5 tip", 30, 50, 25, 5.00}, + {"£0.01 charge on £50 booking = £0 tip", 0.01, 50, 0, 0.00}, + {"£50.01 charge on £50 booking = £0.01 tip", 50.01, 50, 0, 0.01}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remaining := math.Max(0, tt.bookingAmt-tt.paid) + bookingPortion := math.Min(tt.charge, remaining) + tipPortion := math.Round((tt.charge - bookingPortion) * 100) / 100 + +if math.Abs(tipPortion-tt.wantTip) > 0.005 { + t.Errorf("tip = %.2f, want %.2f", tipPortion, tt.wantTip) + } + + total := math.Round((bookingPortion+tipPortion)*100) / 100 + expected := math.Round(tt.charge*100) / 100 + if math.Abs(total-expected) > 0.005 { + t.Errorf("bookingPortion(%.2f)+tipPortion(%.2f)=%.2f != charge(%.2f)", + bookingPortion, tipPortion, total, expected) + } + }) + } +} + +// ============================================================================ +// Section 18: Float64 Precision in Deposit Calculations +// ============================================================================ + +// TestDepositAmount_Float64Precision verifies that deposit calculations +// (up to 50% of total minus already paid) are precise. +func TestDepositAmount_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + charge float64 + wantDeposit float64 + }{ + {"£50 total, £0 paid, charge £25 → deposit £25", 50, 0, 25, 25.00}, + {"£50 total, £0 paid, charge £50 → deposit £25", 50, 0, 50, 25.00}, + {"£100 total, £0 paid, charge £30 → deposit £30", 100, 0, 30, 30.00}, + {"£100 total, £30 paid, charge £30 → deposit £20", 100, 30, 30, 20.00}, + {"£100 total, £60 paid, charge £30 → deposit £0", 100, 60, 30, 0.00}, + {"£33.33 total, £0 paid, charge £16.66 → deposit £16.66", 33.33, 0, 16.66, 16.66}, + {"£25.50 total, £0 paid, charge £12.75 → deposit £12.75", 25.50, 0, 12.75, 12.75}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + maxDeposit := tt.total * ProtectedDepositMaxPct + remainingDepositRoom := math.Max(0, maxDeposit-tt.paid) + depositAmount := math.Min(tt.charge, remainingDepositRoom) + depositAmount = math.Round(depositAmount*100) / 100 + +if math.Abs(depositAmount-tt.wantDeposit) > 0.005 { + t.Errorf("deposit = %.2f, want %.2f", depositAmount, tt.wantDeposit) + } + if depositAmount > maxDeposit+0.005 { + t.Errorf("deposit %.2f exceeds max deposit %.2f", depositAmount, maxDeposit) + } + }) + } +} + +// ============================================================================ +// Section 19: Float64 Precision in Balance Calculations +// ============================================================================ + +// TestBalanceCalculation_Float64Precision verifies that remaining balance +// calculations (total - paid + refunded) are precise. +func TestBalanceCalculation_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + refunded float64 + want float64 + }{ + {"£100 total, £50 paid, £0 refunded → £50 remaining", 100, 50, 0, 50.00}, + {"£100 total, £100 paid, £0 refunded → £0 remaining", 100, 100, 0, 0.00}, + {"£100 total, £50 paid, £25 refunded → £75 remaining", 100, 50, 25, 75.00}, + {"£100 total, £0 paid, £0 refunded → £100 remaining", 100, 0, 0, 100.00}, + {"£100 total, £120 paid, £0 refunded → £0 remaining (clamped)", 100, 120, 0, 0.00}, + {"£100 total, £50 paid, £60 refunded → £100 remaining (capped)", 100, 50, 60, 100.00}, + {"£0 total, £0 paid, £0 refunded → £0 remaining", 0, 0, 0, 0.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remaining := math.Max(0, math.Min(tt.total, tt.total-tt.paid+tt.refunded)) + remaining = math.Round(remaining*100) / 100 + + if math.Abs(remaining-tt.want) > 0.005 { + t.Errorf("remaining = %.2f, want %.2f", remaining, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/backend/handlers/payments/float64_validity_round3_test.go b/backend/handlers/payments/float64_validity_round3_test.go new file mode 100644 index 0000000..1a835de --- /dev/null +++ b/backend/handlers/payments/float64_validity_round3_test.go @@ -0,0 +1,1682 @@ +//go:build test && dev + +package payments + +import ( + "fmt" + "math" + "testing" + "time" + + "crussell/clock" + "crussell/db" + "crussell/testutils" + "crussell/testutils/fixtures" + + "github.com/stretchr/testify/require" +) + +// ============================================================================ +// Section 1: approxEqual — Float64 Comparison Boundary Tests +// ============================================================================ + +// TestApproxEqual_Boundaries verifies that approxEqual correctly reports +// whether two currency amounts are equal to the nearest penny (within 0.005). +// This is used by assessGiftCardCancellation and findGiftCardPurchasePayment +// to compare float64 amounts scanned from NUMERIC columns. +func TestApproxEqual_Boundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b float64 + want bool + }{ + {"exact equal", 100.00, 100.00, true}, + {"within 0.004 — equal", 100.00, 100.004, true}, + {"within 0.0049 — equal", 100.00, 100.0049, true}, + {"at 0.005 boundary — equal (inclusive)", 100.00, 100.005, true}, + {"just over 0.005 — not equal", 100.00, 100.0051, false}, + {"0.01 difference — not equal", 100.00, 100.01, false}, + {"negative within epsilon", -100.00, -100.004, true}, + {"negative just over epsilon", -100.00, -100.006, false}, + {"zero and near-zero within epsilon", 0.00, 0.0049, true}, + {"zero and just over epsilon", 0.00, 0.0051, false}, + {"large amounts within epsilon", 9999.99, 9999.994, true}, + {"large amounts just over epsilon", 9999.99, 9999.996, false}, + {"both zero", 0.00, 0.00, true}, + {"float64 representation error: 1.005 vs 1.0049999", 1.005, 1.004999, true}, + {"float64 representation error: 0.1+0.2 vs 0.3", 0.1+0.2, 0.3, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := approxEqual(tt.a, tt.b) + if got != tt.want { + t.Errorf("approxEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +// TestApproxEqual_Deterministic verifies that approxEqual is deterministic +// for the same inputs. +func TestApproxEqual_Deterministic(t *testing.T) { + t.Parallel() + + pairs := [][2]float64{ + {100.00, 100.004}, + {100.00, 100.005}, + {100.00, 100.006}, + {0.00, 0.0049}, + {9999.99, 9999.994}, + } + for _, p := range pairs { + first := approxEqual(p[0], p[1]) + for i := 0; i < 50; i++ { + if approxEqual(p[0], p[1]) != first { + t.Errorf("approxEqual(%v, %v) changed between calls", p[0], p[1]) + } + } + } +} + +// TestApproxEqual_UsedInFindGiftCardPurchasePayment verifies that the +// ABS(amount - $2) < 0.005 comparison used in findGiftCardPurchasePayment +// matches approxEqual semantics exactly. +func TestApproxEqual_UsedInFindGiftCardPurchasePayment(t *testing.T) { + t.Parallel() + + // The SQL in findGiftCardPurchasePayment uses: + // ABS(amount - $2) < 0.005 + // This must match approxEqual(a, b) == true for the same values. + tests := []struct { + name string + amount float64 + want float64 + match bool + }{ + {"exact match", 50.00, 50.00, true}, + {"within 0.004", 50.00, 50.004, true}, + {"at 0.005 boundary", 50.00, 50.005, false}, // SQL < 0.005 excludes boundary + {"just over 0.005", 50.00, 50.006, false}, + {"float64 representation", 12.34, 12.34, true}, + {"recurring decimal", 33.33, 33.33, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // SQL equivalent: ABS(amount - want) < 0.005 + sqlMatch := math.Abs(tt.amount-tt.want) < 0.005 + // approxEqual uses math.Abs(a-b) < 0.005 + aeMatch := approxEqual(tt.amount, tt.want) + + if sqlMatch != aeMatch { + t.Errorf("SQL ABS(%.2f - %.2f) < 0.005 = %v, but approxEqual = %v — drift!", + tt.amount, tt.want, sqlMatch, aeMatch) + } + if sqlMatch != tt.match { + t.Errorf("SQL ABS(%.2f - %.2f) < 0.005 = %v, want %v", + tt.amount, tt.want, sqlMatch, tt.match) + } + }) + } +} + +// ============================================================================ +// Section 2: roundTo2 — Additional Edge Cases +// ============================================================================ + +// TestRoundTo2_RecurringDecimals verifies that roundTo2 handles recurring +// decimal fractions correctly. +func TestRoundTo2_RecurringDecimals(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input float64 + want float64 + }{ + {"1/3 = 0.333... → 0.33", 1.0 / 3.0, 0.33}, + {"2/3 = 0.666... → 0.67", 2.0 / 3.0, 0.67}, + {"1/7 = 0.142857... → 0.14", 1.0 / 7.0, 0.14}, + {"1/9 = 0.111... → 0.11", 1.0 / 9.0, 0.11}, + {"1/6 = 0.1666... → 0.17", 1.0 / 6.0, 0.17}, + {"5/6 = 0.8333... → 0.83", 5.0 / 6.0, 0.83}, + {"1/30 = 0.0333... → 0.03", 1.0 / 30.0, 0.03}, + {"7/30 = 0.2333... → 0.23", 7.0 / 30.0, 0.23}, + {"1/60 = 0.01666... → 0.02", 1.0 / 60.0, 0.02}, + {"1/12 = 0.08333... → 0.08", 1.0 / 12.0, 0.08}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundTo2(tt.input) + if got != tt.want { + t.Errorf("roundTo2(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// TestRoundTo2_MultiplicationThenRounding verifies that the pattern +// roundTo2(total * percent / 100) used in discount calculations is precise. +func TestRoundTo2_MultiplicationThenRounding(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + percent float64 + want float64 + }{ + {"£100 × 10% = £10.00", 100.00, 10.0, 10.00}, + {"£50 × 20% = £10.00", 50.00, 20.0, 10.00}, + {"£33.33 × 10% = £3.33", 33.33, 10.0, 3.33}, + {"£99.99 × 5% = £5.00", 99.99, 5.0, 5.00}, + {"£12.34 × 15% = £1.85", 12.34, 15.0, 1.85}, + {"£0.01 × 10% = £0.00", 0.01, 10.0, 0.00}, + {"£9999.99 × 10% = £1000.00", 9999.99, 10.0, 1000.00}, + {"£100 × 0% = £0.00", 100.00, 0.0, 0.00}, + {"£100 × 100% = £100.00", 100.00, 100.0, 100.00}, + {"£45.67 × 7.5% = £3.43", 45.67, 7.5, 3.43}, + {"£67.89 × 12.5% = £8.49", 67.89, 12.5, 8.49}, + {"£123.45 × 20% = £24.69", 123.45, 20.0, 24.69}, + {"£0.29 × 10% = £0.03", 0.29, 10.0, 0.03}, + {"£0.05 × 10% = £0.01", 0.05, 10.0, 0.01}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundTo2(tt.total * tt.percent / 100) + if got != tt.want { + t.Errorf("roundTo2(%.2f × %.1f%% / 100) = %.2f, want %.2f", + tt.total, tt.percent, got, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 3: CalculateFees — Intermediate Float64 Precision +// ============================================================================ + +// TestCalculateFees_IntermediatePrecision verifies that the integer arithmetic +// in CalculateFees produces correct float64 results without intermediate +// float64 drift. The formula uses integer pence math then divides by 100.0. +func TestCalculateFees_IntermediatePrecision(t *testing.T) { + t.Parallel() + + svc := NewPaymentService() + + // Online: (amount * 14 / 1000 + 25) / 100.0 + // Terminal: (amount * 175 / 10000) / 100.0 + + tests := []struct { + name string + amount int64 + method string + want float64 + }{ + // Online fee formula: (amount*14/1000 + 25) / 100.0 + {"online: 0p → 25p → £0.25", 0, "online", 0.25}, + {"online: 1p → 25p → £0.25", 1, "online", 0.25}, + {"online: 100p → 26p → £0.26", 100, "online", 0.26}, + {"online: 1000p → 39p → £0.39", 1000, "online", 0.39}, + {"online: 10000p → 165p → £1.65", 10000, "online", 1.65}, + {"online: 100000p → 1425p → £14.25", 100000, "online", 14.25}, + {"online: 1000000p → 14025p → £140.25", 1000000, "online", 140.25}, + + // Terminal fee formula: (amount * 175 / 10000) / 100.0 + {"terminal: 0p → 0p → £0.00", 0, "terminal", 0.00}, + {"terminal: 1p → 0p → £0.00", 1, "terminal", 0.00}, + {"terminal: 100p → 1p → £0.01", 100, "terminal", 0.01}, + {"terminal: 1000p → 17p → £0.17", 1000, "terminal", 0.17}, + {"terminal: 10000p → 175p → £1.75", 10000, "terminal", 1.75}, + {"terminal: 100000p → 1750p → £17.50", 100000, "terminal", 17.50}, + {"terminal: 1000000p → 17500p → £175.00", 1000000, "terminal", 175.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fees := svc.CalculateFees(tt.amount, tt.method) + if math.Abs(fees-tt.want) > 0.0001 { + t.Errorf("CalculateFees(%d, %q) = %.6f, want %.6f", tt.amount, tt.method, fees, tt.want) + } + if fees < 0 { + t.Errorf("CalculateFees(%d, %q) = %.6f — negative fees!", tt.amount, tt.method, fees) + } + if math.IsNaN(fees) || math.IsInf(fees, 0) { + t.Errorf("CalculateFees(%d, %q) = %v — non-finite!", tt.amount, tt.method, fees) + } + }) + } +} + +// TestCalculateFees_IntegerDivisionPrecision verifies that the integer division +// in the fee formulas does not truncate before the float64 conversion. +func TestCalculateFees_IntegerDivisionPrecision(t *testing.T) { + t.Parallel() + + // The formula: float64((amount*feeRatePencePerPound/feeDenominator)+minFeePence) / 100.0 + // Go integer division truncates, so amount*14/1000 truncates before +25. + // This is the INTENDED behaviour (floor the percentage fee, add fixed min). + + tests := []struct { + name string + amount int64 + method string + wantLow float64 + wantHigh float64 + }{ + // For online: the percentage part is amount*14/1000 (integer truncation) + // 1*14/1000 = 0, +25 = 25, /100 = 0.25 + {"online: 1p — only min fee", 1, "online", 0.25, 0.25}, + // 71*14/1000 = 0 (994/1000 truncates), +25 = 25, /100 = 0.25 + {"online: 71p — still only min fee", 71, "online", 0.25, 0.25}, + // 72*14/1000 = 1 (1008/1000 truncates to 1), +25 = 26, /100 = 0.26 + {"online: 72p — first penny of percentage fee", 72, "online", 0.26, 0.26}, + // 1000000*14/1000 = 14000, +25 = 14025, /100 = 140.25 + {"online: max amount", 1000000, "online", 140.25, 140.25}, + + // For terminal: amount*175/10000 (integer truncation) + // 1*175/10000 = 0, /100 = 0.00 + {"terminal: 1p — no fee", 1, "terminal", 0.00, 0.00}, + // 57*175/10000 = 0 (9975/10000 truncates), /100 = 0.00 + {"terminal: 57p — still no fee", 57, "terminal", 0.00, 0.00}, + // 58*175/10000 = 1 (10150/10000 truncates to 1), /100 = 0.01 + {"terminal: 58p — first penny of fee", 58, "terminal", 0.01, 0.01}, + // 1000000*175/10000 = 17500, /100 = 175.00 + {"terminal: max amount", 1000000, "terminal", 175.00, 175.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fees := NewPaymentService().CalculateFees(tt.amount, tt.method) + if fees < tt.wantLow || fees > tt.wantHigh { + t.Errorf("CalculateFees(%d, %q) = %.6f, want between %.6f and %.6f", + tt.amount, tt.method, fees, tt.wantLow, tt.wantHigh) + } + }) + } +} + +// ============================================================================ +// Section 4: ValidatePartialAmount — Error Message Float64 Precision +// ============================================================================ + +// TestValidatePartialAmount_ErrorMessagePrecision verifies that the error +// message formatting in ValidatePartialAmount uses correct float64 precision. +// The message uses float64(amountPence)/100 to format the amount. +func TestValidatePartialAmount_ErrorMessagePrecision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amountPence int64 + remainingPence int64 + wantErrPrefix string + }{ + {"£10.01 exceeds £10.00", 1001, 1000, "partial amount (£10.01) exceeds remaining balance (£10.00)"}, + {"£0.01 exceeds £0.00", 1, 0, "partial amount (£0.01) exceeds remaining balance (£0.00)"}, + {"£100.00 exceeds £50.00", 10000, 5000, "partial amount (£100.00) exceeds remaining balance (£50.00)"}, + {"£9999.99 exceeds £5000.00", 999999, 500000, "partial amount (£9999.99) exceeds remaining balance (£5000.00)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePartialAmount(tt.amountPence, tt.remainingPence) + require.Error(t, err) + if err.Error() != tt.wantErrPrefix { + t.Errorf("error message = %q, want %q", err.Error(), tt.wantErrPrefix) + } + }) + } +} + +// TestValidatePartialAmount_PenceConversionPrecision verifies that the +// float64(pence)/100 conversion used in the error message is exact for +// all common pence values. +func TestValidatePartialAmount_PenceConversionPrecision(t *testing.T) { + t.Parallel() + + for pence := int64(1); pence <= 10000; pence++ { + pounds := float64(pence) / 100.0 + backPence := int64(math.Round(pounds * 100)) + if backPence != pence { + t.Errorf("pence conversion drift at %d: → %.6f → %d", pence, pounds, backPence) + break + } + } +} + +// ============================================================================ +// Section 5: pendingCampaignDiscountAmount — Float64 Accumulation +// ============================================================================ + +// TestPendingCampaignDiscountAmount_Float64Precision verifies that the +// accumulation of discount amounts in pendingCampaignDiscountAmount is +// precise. This function sums float64 discount amounts and rounds to 2dp. +func TestPendingCampaignDiscountAmount_Float64Precision(t *testing.T) { + t.Parallel() + + // Simulate the accumulation logic: sum amounts, round to 2dp + tests := []struct { + name string + amounts []float64 + want float64 + }{ + {"single discount", []float64{10.00}, 10.00}, + {"two discounts", []float64{10.00, 5.00}, 15.00}, + {"three discounts with odd values", []float64{12.34, 5.67, 3.21}, 21.22}, + {"many small discounts", []float64{1.01, 2.02, 3.03, 4.04, 5.05}, 15.15}, + {"recurring decimal amounts", []float64{3.33, 3.33, 3.33}, 9.99}, + {"single zero discount", []float64{0.00}, 0.00}, + {"all zero discounts", []float64{0.00, 0.00, 0.00}, 0.00}, + {"large amounts", []float64{999.99, 500.00, 250.00}, 1749.99}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var total float64 + for _, d := range tt.amounts { + total += d + } + total = math.Round(total*100) / 100 + + if math.Abs(total-tt.want) > 0.005 { + t.Errorf("accumulated total = %.2f, want %.2f", total, tt.want) + } + }) + } +} + +// TestPendingCampaignDiscountAmount_AccumulationOrder verifies that the sum +// of discount amounts is independent of accumulation order (float64 non- +// associativity should not affect the rounded result for typical values). +func TestPendingCampaignDiscountAmount_AccumulationOrder(t *testing.T) { + t.Parallel() + + amounts := []float64{12.34, 45.67, 89.01, 23.45, 67.89, 34.56, 78.90, 12.09, 56.78, 90.12} + + // Sum forward + var sumForward float64 + for _, a := range amounts { + sumForward += a + } + sumForward = math.Round(sumForward*100) / 100 + + // Sum reverse + var sumReverse float64 + for i := len(amounts) - 1; i >= 0; i-- { + sumReverse += amounts[i] + } + sumReverse = math.Round(sumReverse*100) / 100 + + if sumForward != sumReverse { + t.Errorf("forward sum %.2f != reverse sum %.2f — float64 non-associativity", sumForward, sumReverse) + } +} + +// ============================================================================ +// Section 6: discountHeadroomPence — Float64 Conversion Precision +// ============================================================================ + +// TestDiscountHeadroomPence_ConversionPrecision verifies that the +// float64(headroom) / 100.0 conversion in capDiscountToRemainingObligation +// is exact for all common headroom values. +func TestDiscountHeadroomPence_ConversionPrecision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headroom int64 + want float64 + }{ + {"0 pence → £0.00", 0, 0.00}, + {"1 pence → £0.01", 1, 0.01}, + {"50 pence → £0.50", 50, 0.50}, + {"100 pence → £1.00", 100, 1.00}, + {"1234 pence → £12.34", 1234, 12.34}, + {"999999 pence → £9999.99", 999999, 9999.99}, + {"1000000 pence → £10000.00", 1000000, 10000.00}, + {"2500 pence → £25.00", 2500, 25.00}, + {"99 pence → £0.99", 99, 0.99}, + {"101 pence → £1.01", 101, 1.01}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pounds := float64(tt.headroom) / 100.0 + if pounds != tt.want { + t.Errorf("float64(%d)/100 = %.6f, want %.2f", tt.headroom, pounds, tt.want) + } + // Round-trip + backPence := int64(math.Round(pounds * 100)) + if backPence != tt.headroom { + t.Errorf("round-trip: %d → %.6f → %d", tt.headroom, pounds, backPence) + } + }) + } +} + +// TestCapDiscountToRemainingObligation_PenceConversion verifies that the +// pence conversion in capDiscountToRemainingObligation is exact. +func TestCapDiscountToRemainingObligation_PenceConversion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + discountAmount float64 + wantPence int64 + }{ + {"£0.01 → 1p", 0.01, 1}, + {"£0.29 → 29p", 0.29, 29}, + {"£1.00 → 100p", 1.00, 100}, + {"£12.34 → 1234p", 12.34, 1234}, + {"£100.00 → 10000p", 100.00, 10000}, + {"£9999.99 → 999999p", 9999.99, 999999}, + {"£0.00 → 0p", 0.00, 0}, + {"£0.005 → 1p (rounds up)", 0.005, 1}, + {"£0.004 → 0p (rounds down)", 0.004, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pence := int64(math.Round(tt.discountAmount * 100)) + if pence != tt.wantPence { + t.Errorf("math.Round(%.6f*100) = %d, want %d", tt.discountAmount, pence, tt.wantPence) + } + }) + } +} + +// ============================================================================ +// Section 7: giftCardAmountPence — Additional Edge Cases +// ============================================================================ + +// TestGiftCardAmountPence_ExactBoundaries verifies the exact £250 cap boundary +// and sub-penny rounding behaviour. +func TestGiftCardAmountPence_ExactBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount float64 + want int64 + ok bool + }{ + {"£250.00 exactly — at cap", 250.00, 25000, true}, + {"£249.99 — just under cap", 249.99, 24999, true}, + {"£250.01 — just over cap", 250.01, 0, false}, + {"£0.00 — zero", 0.00, 0, true}, + {"£0.01 — minimum non-zero", 0.01, 1, true}, + {"£0.004 — rounds to 0p", 0.004, 0, true}, + {"£0.005 — rounds to 1p", 0.005, 1, true}, + {"£0.009 — rounds to 1p", 0.009, 1, true}, + {"£0.014999 — rounds to 1p", 0.014999, 1, true}, + {"£0.015 — rounds to 2p", 0.015, 2, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if math.IsNaN(tt.amount) || math.IsInf(tt.amount, 0) { + return + } + pence := int64(math.Round(tt.amount * 100)) + if tt.ok { + if pence != tt.want { + t.Errorf("math.Round(%.6f*100) = %d, want %d", tt.amount, pence, tt.want) + } + } + }) + } +} + +// ============================================================================ +// Section 8: userGiftCardSpentToday / adminGiftCardValueToday — Float64 SUM +// ============================================================================ + +// TestUserGiftCardSpentToday_Float64Precision verifies that the SUM returned +// by userGiftCardSpentToday is correctly handled as a float64. +func TestUserGiftCardSpentToday_Float64Precision(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // Create a gift card first (FK constraint) + _, err = tx.Exec(ctx, ` + INSERT INTO gift_cards (id, created_by, total_funds_added, amount_remaining) + VALUES ('testgcspent1', $1, 0, 0) + `, userID) + require.NoError(t, err) + + // Insert gift_card_transactions with various float64 amounts + amounts := []float64{12.34, 45.67, 89.01, 23.45, 67.89} + var expected float64 + for _, amt := range amounts { + _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, user_id) + VALUES ('testgcspent1', 'purchase', $1, 'api', $2) + `, amt, userID) + require.NoError(t, err) + expected += amt + } + expected = math.Round(expected*100) / 100 + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + + // The SUM from PostgreSQL NUMERIC is scanned into float64 — verify it's + // within rounding tolerance + if math.Abs(spent-expected) > 0.005 { + t.Errorf("userGiftCardSpentToday = %.2f, want %.2f", spent, expected) + } + + // Verify the float64 round-trip: spent → pence → pounds + pence := int64(math.Round(spent * 100)) + backToPounds := float64(pence) / 100.0 + if math.Abs(backToPounds-spent) > 0.001 { + t.Errorf("round-trip: %.2f → %d → %.2f", spent, pence, backToPounds) + } +} + +// TestUserGiftCardSpentToday_Zero verifies that userGiftCardSpentToday returns +// 0 when there are no transactions today. +func TestUserGiftCardSpentToday_Zero(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + if spent != 0 { + t.Errorf("expected 0 spent, got %.2f", spent) + } +} + +// TestAdminGiftCardValueToday_Float64Precision verifies that the SUM returned +// by adminGiftCardValueToday is correctly handled as a float64. +func TestAdminGiftCardValueToday_Float64Precision(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // Make the user an admin + _, err = tx.Exec(ctx, `UPDATE users SET account_role = 'admin' WHERE id = $1`, adminID) + require.NoError(t, err) + + // Create a gift card with a specific total_funds_added + _, err = tx.Exec(ctx, ` + INSERT INTO gift_cards (id, created_by, total_funds_added, amount_remaining) + VALUES ('testadmingc1', $1, 50.00, 50.00) + `, adminID) + require.NoError(t, err) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + + if math.Abs(value-50.00) > 0.005 { + t.Errorf("adminGiftCardValueToday = %.2f, want 50.00", value) + } + + // Verify the float64 round-trip + pence := int64(math.Round(value * 100)) + backToPounds := float64(pence) / 100.0 + if math.Abs(backToPounds-value) > 0.001 { + t.Errorf("round-trip: %.2f → %d → %.2f", value, pence, backToPounds) + } +} + +// TestAdminGiftCardValueToday_Zero verifies that adminGiftCardValueToday +// returns 0 when there are no transactions today. +func TestAdminGiftCardValueToday_Zero(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + if value != 0 { + t.Errorf("expected 0 value, got %.2f", value) + } +} + +// ============================================================================ +// Section 9: giftCardSpendAtTill — Float64 Precision +// ============================================================================ + +// TestGiftCardSpendAtTill_Float64Precision verifies that the SUM returned +// by giftCardSpendAtTill is correctly handled as a float64. +func TestGiftCardSpendAtTill_Float64Precision(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(48*time.Hour)) + require.NoError(t, err) + + // Insert payments with gift_card_id set + amounts := []float64{12.34, 45.67, 30.00} + var expected float64 + for _, amt := range amounts { + _, err := tx.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, gift_card_id) + VALUES ($1, 'full', 'giftcard', $2, 'completed', 'testgcspend1') + `, bookingID, amt) + require.NoError(t, err) + expected += amt + } + expected = math.Round(expected*100) / 100 + + spent, err := giftCardSpendAtTill(ctx, tx, "testgcspend1") + require.NoError(t, err) + + if math.Abs(spent-expected) > 0.005 { + t.Errorf("giftCardSpendAtTill = %.2f, want %.2f", spent, expected) + } +} + +// TestGiftCardSpendAtTill_Zero verifies that giftCardSpendAtTill returns 0 +// when there are no completed payments for the card. +func TestGiftCardSpendAtTill_Zero(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + spent, err := giftCardSpendAtTill(ctx, tx, "nonexistent-card") + require.NoError(t, err) + if spent != 0 { + t.Errorf("expected 0 spent, got %.2f", spent) + } +} + +// ============================================================================ +// Section 10: findGiftCardPurchasePayment — Float64 Amount Comparison +// ============================================================================ + +// TestFindGiftCardPurchasePayment_AmountComparison verifies that the +// ABS(amount - purchaseAmount) < 0.005 comparison in findGiftCardPurchasePayment +// correctly matches amounts within tolerance. +func TestFindGiftCardPurchasePayment_AmountComparison(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // Insert a completed online payment with a specific amount (booking_id must be NULL) + purchasedAt := clock.Now() + _, err = tx.Exec(ctx, ` + INSERT INTO payments (created_by, payment_type, payment_method, status, amount, square_payment_id, created_at) + VALUES ($1, 'full', 'online_square', 'completed', 50.00, 'sq_test_purchase', $2) + `, userID, purchasedAt) + require.NoError(t, err) + + // Test exact match + paymentID, sqID, ok, err := findGiftCardPurchasePayment(ctx, tx, userID, "", 50.00, purchasedAt) + require.NoError(t, err) + require.True(t, ok, "expected to find purchase payment for exact amount") + require.NotEmpty(t, paymentID, "expected non-empty payment ID") + require.Equal(t, "sq_test_purchase", sqID) + + // Test amount within tolerance (50.004) + paymentID2, sqID2, ok2, err2 := findGiftCardPurchasePayment(ctx, tx, userID, "", 50.004, purchasedAt) + require.NoError(t, err2) + require.True(t, ok2, "expected to find purchase payment for amount within tolerance") + require.Equal(t, paymentID, paymentID2, "expected same payment for amount within tolerance") + require.Equal(t, sqID, sqID2) + + // Test amount just over tolerance (50.006) + _, _, ok3, err3 := findGiftCardPurchasePayment(ctx, tx, userID, "", 50.006, purchasedAt) + require.NoError(t, err3) + require.False(t, ok3, "expected NOT to find purchase payment for amount over tolerance") +} + +// ============================================================================ +// Section 11: cancelGiftCardFunding — Float64 Amount Handling +// ============================================================================ + +// TestCancelGiftCardFunding_Float64Precision verifies that cancelGiftCardFunding +// correctly handles float64 amounts in the transaction record. +func TestCancelGiftCardFunding_Float64Precision(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // Create a gift card + _, err = tx.Exec(ctx, ` + INSERT INTO gift_cards (id, created_by, total_funds_added, amount_remaining) + VALUES ('canceltest01', $1, 50.00, 50.00) + `, userID) + require.NoError(t, err) + + // Create a payment and refund record + var paymentID string + err = tx.QueryRow(ctx, ` + INSERT INTO payments (created_by, payment_type, payment_method, amount, status) + VALUES ($1, 'full', 'online_square', 50.00, 'completed') + RETURNING id + `, userID).Scan(&paymentID) + require.NoError(t, err) + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, amount, status, reason) + VALUES ($1, 50.00, 'completed', 'test cancel') + RETURNING id + `, paymentID).Scan(&refundID) + require.NoError(t, err) + + // Test with various float64 amounts + amounts := []float64{50.00, 12.34, 0.01, 0.29, 99.99, 250.00} + for _, amt := range amounts { + t.Run(fmt.Sprintf("amount=%.2f", amt), func(t *testing.T) { + // Create a fresh card for each test (max 12 chars) + cardID := fmt.Sprintf("cancel%05.0f", amt*100) + + _, err := tx.Exec(ctx, ` + INSERT INTO gift_cards (id, created_by, total_funds_added, amount_remaining) + VALUES ($1, $2, $3, $3) + `, cardID, userID, amt) + require.NoError(t, err) + + pgTx := db.TxFromContext(ctx) + err = cancelGiftCardFunding(ctx, pgTx, cardID, userID, refundID, amt) + require.NoError(t, err) + + // Verify the card was zeroed + var remaining float64 + err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining) + require.NoError(t, err) + if remaining != 0 { + t.Errorf("amount_remaining = %.2f, want 0.00", remaining) + } + + // Verify the cancellation transaction was recorded + var txAmount float64 + err = tx.QueryRow(ctx, ` + SELECT amount FROM gift_card_transactions + WHERE gift_card_id = $1 AND transaction_type = 'cancelled' + `, cardID).Scan(&txAmount) + require.NoError(t, err) + if math.Abs(txAmount-amt) > 0.005 { + t.Errorf("cancellation transaction amount = %.2f, want %.2f", txAmount, amt) + } + }) + } +} + +// ============================================================================ +// Section 12: assessGiftCardCancellation — Float64 approxEqual Usage +// ============================================================================ + +// TestAssessGiftCardCancellation_ApproxEqual verifies that the approxEqual +// comparisons in assessGiftCardCancellation correctly handle float64 amounts +// at the boundary. +func TestAssessGiftCardCancellation_ApproxEqual(t *testing.T) { + t.Parallel() + + // Test the approxEqual comparisons used in assessGiftCardCancellation: + // 1. approxEqual(totalFunds, purchaseAmount) + // 2. approxEqual(remaining, purchaseAmount) + // 3. approxEqual(spentAtTill+remaining, purchaseAmount) + + tests := []struct { + name string + totalFunds float64 + purchaseAmount float64 + remaining float64 + spentAtTill float64 + wantEqual1 bool // totalFunds ≈ purchaseAmount + wantEqual2 bool // remaining ≈ purchaseAmount + wantEqual3 bool // spentAtTill+remaining ≈ purchaseAmount + }{ + {"exact match all", 50.00, 50.00, 50.00, 0.00, true, true, true}, + {"totalFunds within epsilon", 50.004, 50.00, 50.00, 0.00, true, true, true}, + {"totalFunds over epsilon", 50.006, 50.00, 50.00, 0.00, false, true, true}, + {"remaining partially spent", 50.00, 50.00, 30.00, 20.00, true, false, true}, + {"remaining partially spent within epsilon", 50.00, 50.00, 30.004, 19.996, true, false, true}, + {"remaining partially spent over epsilon", 50.00, 50.00, 30.00, 19.99, true, false, false}, + {"zero values", 0.00, 0.00, 0.00, 0.00, true, true, true}, + {"large values", 9999.99, 9999.99, 9999.99, 0.00, true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eq1 := approxEqual(tt.totalFunds, tt.purchaseAmount) + eq2 := approxEqual(tt.remaining, tt.purchaseAmount) + eq3 := approxEqual(tt.spentAtTill+tt.remaining, tt.purchaseAmount) + + if eq1 != tt.wantEqual1 { + t.Errorf("approxEqual(totalFunds=%.2f, purchaseAmount=%.2f) = %v, want %v", + tt.totalFunds, tt.purchaseAmount, eq1, tt.wantEqual1) + } + if eq2 != tt.wantEqual2 { + t.Errorf("approxEqual(remaining=%.2f, purchaseAmount=%.2f) = %v, want %v", + tt.remaining, tt.purchaseAmount, eq2, tt.wantEqual2) + } + if eq3 != tt.wantEqual3 { + t.Errorf("approxEqual(spentAtTill(%.2f)+remaining(%.2f)=%.2f, purchaseAmount=%.2f) = %v, want %v", + tt.spentAtTill, tt.remaining, tt.spentAtTill+tt.remaining, tt.purchaseAmount, eq3, tt.wantEqual3) + } + }) + } +} + +// ============================================================================ +// Section 13: ComputeEligibleDiscounts — Float64 Amount Precision +// ============================================================================ + +// TestComputeEligibleDiscounts_AmountPrecision verifies that the discount +// amount calculation (roundTo2(bookingTotal * percent / 100)) used in +// ComputeEligibleDiscounts is precise for various booking totals and percents. +func TestComputeEligibleDiscounts_AmountPrecision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + bookingTotal float64 + percent float64 + wantAmount float64 + }{ + {"£100 at 10% = £10.00", 100.00, 10.0, 10.00}, + {"£50 at 20% = £10.00", 50.00, 20.0, 10.00}, + {"£33.33 at 10% = £3.33", 33.33, 10.0, 3.33}, + {"£99.99 at 5% = £5.00", 99.99, 5.0, 5.00}, + {"£12.34 at 15% = £1.85", 12.34, 15.0, 1.85}, + {"£0.01 at 10% = £0.00", 0.01, 10.0, 0.00}, + {"£9999.99 at 10% = £1000.00", 9999.99, 10.0, 1000.00}, + {"£100 at 0% = £0.00", 100.00, 0.0, 0.00}, + {"£100 at 100% = £100.00", 100.00, 100.0, 100.00}, + {"£45.67 at 7.5% = £3.43", 45.67, 7.5, 3.43}, + {"£67.89 at 12.5% = £8.49", 67.89, 12.5, 8.49}, + {"£123.45 at 20% = £24.69", 123.45, 20.0, 24.69}, + {"£0.29 at 10% = £0.03", 0.29, 10.0, 0.03}, + {"£0.05 at 10% = £0.01", 0.05, 10.0, 0.01}, + {"£1.00 at 10% = £0.10", 1.00, 10.0, 0.10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amount := roundTo2(tt.bookingTotal * tt.percent / 100) + if math.Abs(amount-tt.wantAmount) > 0.005 { + t.Errorf("discount amount = %.2f, want %.2f", amount, tt.wantAmount) + } + }) + } +} + +// ============================================================================ +// Section 14: ApplyLoyaltyRedemption — Float64 Discount Calculation +// ============================================================================ + +// TestApplyLoyaltyRedemption_DiscountPrecision verifies that the loyalty +// discount calculation (roundTo2(bookingTotal * LoyaltyDiscountPercent / 100)) +// is precise for various booking totals. +func TestApplyLoyaltyRedemption_DiscountPrecision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + bookingTotal float64 + wantDiscount float64 + }{ + {"£100 → 10% = £10.00", 100.00, 10.00}, + {"£50 → 10% = £5.00", 50.00, 5.00}, + {"£33.33 → 10% = £3.33", 33.33, 3.33}, + {"£99.99 → 10% = £10.00", 99.99, 10.00}, + {"£12.34 → 10% = £1.23", 12.34, 1.23}, + {"£0.01 → 10% = £0.00", 0.01, 0.00}, + {"£9999.99 → 10% = £1000.00", 9999.99, 1000.00}, + {"£0.00 → 10% = £0.00", 0.00, 0.00}, + {"£45.67 → 10% = £4.57", 45.67, 4.57}, + {"£67.89 → 10% = £6.79", 67.89, 6.79}, + {"£123.45 → 10% = £12.35", 123.45, 12.35}, + {"£0.29 → 10% = £0.03", 0.29, 0.03}, + {"£0.05 → 10% = £0.01", 0.05, 0.01}, + {"£1.00 → 10% = £0.10", 1.00, 0.10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + discount := roundTo2(tt.bookingTotal * LoyaltyDiscountPercent / 100) + if math.Abs(discount-tt.wantDiscount) > 0.005 { + t.Errorf("loyalty discount = %.2f, want %.2f", discount, tt.wantDiscount) + } + }) + } +} + +// ============================================================================ +// Section 15: buildSplitRecords — Balance Type Float64 Edge Cases +// ============================================================================ + +// TestBuildSplitRecords_BalanceType_OddAmounts verifies that the balance type +// split correctly handles odd float64 amounts with recurring decimals. +func TestBuildSplitRecords_BalanceType_OddAmounts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + charge float64 + }{ + {"£33.33 total, £16.66 paid, £16.67 charge", 33.33, 16.66, 16.67}, + {"£25.50 total, £12.75 paid, £12.75 charge", 25.50, 12.75, 12.75}, + {"£100 total, £33.33 paid, £66.67 charge", 100.00, 33.33, 66.67}, + {"£50 total, £24.99 paid, £25.01 charge", 50.00, 24.99, 25.01}, + {"£99.99 total, £49.99 paid, £50.00 charge", 99.99, 49.99, 50.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := makeTestRecord("balance-odd", "full", tt.charge) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: tt.total, + TotalPaid: tt.paid, + } + records, err := buildSplitRecords(record, "full", info, tt.charge) + require.NoError(t, err) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + + if sum > tt.charge+roundingEpsilon { + t.Errorf("split sum %.2f exceeds charged amount %.2f", sum, tt.charge) + } + if math.Abs(sum-tt.charge) > roundingEpsilon { + t.Errorf("split sum %.2f != charged amount %.2f (diff=%.4f)", sum, tt.charge, math.Abs(sum-tt.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) + } + } + }) + } +} + +// TestBuildSplitRecords_BalanceType_ExactBoundary verifies the exact boundary +// where totalPaidAfterBalance >= totalAmount triggers the 'balance' type. +func TestBuildSplitRecords_BalanceType_ExactBoundary(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + charge float64 + }{ + // totalPaidAfterBalance = paid + depositAmount + balancePortion + // For these cases, the charge should exactly fill to the total + {"£100 total, £25 paid, £75 charge — fills exactly", 100, 25, 75}, + {"£100 total, £50 paid, £50 charge — fills exactly", 100, 50, 50}, + {"£100 total, £0 paid, £100 charge — fills exactly", 100, 0, 100}, + {"£50 total, £25 paid, £25 charge — fills exactly", 50, 25, 25}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := makeTestRecord("balance-boundary", "full", tt.charge) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: tt.total, + TotalPaid: tt.paid, + } + records, err := buildSplitRecords(record, "full", info, tt.charge) + require.NoError(t, err) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + + if math.Abs(sum-tt.charge) > roundingEpsilon { + t.Errorf("split sum %.2f != charged amount %.2f", sum, tt.charge) + } + }) + } +} + +// ============================================================================ +// Section 16: buildTerminalSplitRecords — Additional Float64 Edge Cases +// ============================================================================ + +// TestBuildTerminalSplitRecords_OddAmounts verifies that terminal splits +// correctly handle odd float64 amounts. +func TestBuildTerminalSplitRecords_OddAmounts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + bookingPortion float64 + tipAmount float64 + }{ + {"£33.33 booking + £3.33 tip", 33.33, 0, 33.33, 3.33}, + {"£25.50 booking + £5.50 tip", 25.50, 0, 25.50, 5.50}, + {"£99.99 booking + £10.01 tip", 99.99, 0, 99.99, 10.01}, + {"£12.34 booking + £1.23 tip", 12.34, 0, 12.34, 1.23}, + {"£45.67 booking + £4.56 tip", 45.67, 0, 45.67, 4.56}, + {"£67.89 booking + £6.79 tip", 67.89, 0, 67.89, 6.79}, + {"£0.29 booking + £0.03 tip", 0.29, 0, 0.29, 0.03}, + {"£0.05 booking + £0.01 tip", 0.05, 0, 0.05, 0.01}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + primary := makeTestRecord("terminal-odd", "full", tt.bookingPortion+tt.tipAmount) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: tt.total, + TotalPaid: tt.paid, + } + records := buildTerminalSplitRecords(primary, info, tt.bookingPortion, tt.tipAmount) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + expected := math.Round((tt.bookingPortion+tt.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) + } + }) + } +} + +// TestBuildTerminalSplitRecords_ZeroTip verifies that terminal splits with +// zero tip produce records that partition the booking portion exactly. +func TestBuildTerminalSplitRecords_ZeroTip(t *testing.T) { + t.Parallel() + + primary := makeTestRecord("terminal-zero-tip", "full", 50) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-2 * time.Hour), + TotalAmount: 50, + TotalPaid: 0, + } + records := buildTerminalSplitRecords(primary, info, 50, 0) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + if math.Abs(sum-50) > roundingEpsilon { + t.Errorf("split sum %.2f != booking portion 50.00", sum) + } + + // Verify no record has a negative or NaN amount + for i, r := range records { + if r.Amount < 0 || math.IsNaN(r.Amount) { + t.Errorf("record %d has invalid amount: %.4f", i, r.Amount) + } + } +} + +// ============================================================================ +// Section 17: RevertGiftCardFunding — penceLess Float64 Comparison +// ============================================================================ + +// TestRevertGiftCardFunding_PenceLessComparison verifies that the penceLess +// comparisons in RevertGiftCardFunding correctly detect partial clawbacks. +func TestRevertGiftCardFunding_PenceLessComparison(t *testing.T) { + t.Parallel() + + // In RevertGiftCardFunding, penceLess(balanceBefore, amount) detects + // whether the balance was already partially spent before the clawback. + // If balanceBefore < amount (in pence), some funding was already spent. + + tests := []struct { + name string + balanceBefore float64 + amount float64 + wantPartial bool // penceLess(balanceBefore, amount) == true + }{ + {"balance >= amount — not partial", 50.00, 50.00, false}, + {"balance > amount — not partial", 60.00, 50.00, false}, + {"balance < amount — partial", 30.00, 50.00, true}, + {"balance 0, amount > 0 — partial", 0.00, 50.00, true}, + {"balance 0, amount 0 — not partial", 0.00, 0.00, false}, + {"balance 49.99, amount 50 — partial (1p short)", 49.99, 50.00, true}, + {"balance 50.01, amount 50 — not partial (1p over)", 50.01, 50.00, false}, + {"balance 0.004, amount 0.005 — partial (0p vs 1p)", 0.004, 0.005, true}, + {"balance 0.005, amount 0.005 — not partial (both 1p)", 0.005, 0.005, false}, + {"balance 0.006, amount 0.005 — not partial (1p vs 1p)", 0.006, 0.005, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := penceLess(tt.balanceBefore, tt.amount) + if got != tt.wantPartial { + t.Errorf("penceLess(%.6f, %.6f) = %v, want %v (partial=%v)", + tt.balanceBefore, tt.amount, got, tt.wantPartial, tt.wantPartial) + } + }) + } +} + +// ============================================================================ +// Section 18: Float64 Precision in PaymentSummary Calculations +// ============================================================================ + +// TestPaymentSummary_RemainingCalculation verifies that the remaining amount +// calculation (total - paid + refunded) is precise with various float64 inputs. +func TestPaymentSummary_RemainingCalculation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + refunded float64 + wantRemaining float64 + }{ + {"£100 total, £50 paid, £0 refunded → £50", 100, 50, 0, 50.00}, + {"£100 total, £100 paid, £0 refunded → £0", 100, 100, 0, 0.00}, + {"£100 total, £50 paid, £25 refunded → £75", 100, 50, 25, 75.00}, + {"£100 total, £0 paid, £0 refunded → £100", 100, 0, 0, 100.00}, + {"£100 total, £120 paid, £0 refunded → £0 (clamped)", 100, 120, 0, 0.00}, + {"£100 total, £50 paid, £60 refunded → £100 (capped)", 100, 50, 60, 100.00}, + {"£0 total, £0 paid, £0 refunded → £0", 0, 0, 0, 0.00}, + {"£33.33 total, £16.66 paid, £0 refunded → £16.67", 33.33, 16.66, 0, 16.67}, + {"£99.99 total, £49.99 paid, £0 refunded → £50.00", 99.99, 49.99, 0, 50.00}, + {"£12.34 total, £6.17 paid, £0 refunded → £6.17", 12.34, 6.17, 0, 6.17}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remaining := math.Max(0, math.Min(tt.total, tt.total-tt.paid+tt.refunded)) + remaining = math.Round(remaining*100) / 100 + + if math.Abs(remaining-tt.wantRemaining) > 0.005 { + t.Errorf("remaining = %.2f, want %.2f", remaining, tt.wantRemaining) + } + }) + } +} + +// ============================================================================ +// Section 19: Float64 Precision in Tip Overflow Detection +// ============================================================================ + +// TestTipOverflow_Float64Precision verifies that tip overflow detection +// (charge - bookingPortion > maxOnlineTipPence/100) is precise. +func TestTipOverflow_Float64Precision(t *testing.T) { + t.Parallel() + + // maxOnlineTipPence = 25000 (£250) + maxTipPounds := float64(maxOnlineTipPence) / 100.0 + + tests := []struct { + name string + charge float64 + remaining float64 + wantTip float64 + wantOver bool + }{ + {"£300 charge on £50 booking — tip £250 (at limit)", 300, 50, 250.00, false}, + {"£300.01 charge on £50 booking — tip £250.01 (over limit)", 300.01, 50, 250.01, true}, + {"£250 charge on £0 remaining — all tip (at limit)", 250, 0, 250.00, false}, + {"£250.01 charge on £0 remaining — all tip (over limit)", 250.01, 0, 250.01, true}, + {"£50 charge on £50 booking — no tip", 50, 50, 0.00, false}, + {"£0.01 charge on £0 remaining — tiny tip", 0.01, 0, 0.01, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bookingPortion := math.Min(tt.charge, tt.remaining) + tipPortion := math.Round((tt.charge - bookingPortion) * 100) / 100 + + if math.Abs(tipPortion-tt.wantTip) > 0.005 { + t.Errorf("tip = %.2f, want %.2f", tipPortion, tt.wantTip) + } + + over := tipPortion > maxTipPounds+roundingEpsilon + if over != tt.wantOver { + t.Errorf("tip overflow: got %v, want %v (tip=%.2f, max=%.2f)", + over, tt.wantOver, tipPortion, maxTipPounds) + } + }) + } +} + +// ============================================================================ +// Section 20: Float64 Precision in Deposit Promotion Threshold +// ============================================================================ + +// TestDepositPromotionThreshold_Float64Precision verifies that the deposit +// promotion threshold (payment >= total * depositPromotionMinPct) is precise. +func TestDepositPromotionThreshold_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + payment float64 + wantOK bool // payment >= total * 0.20 + }{ + {"£100 total, £20 payment — at threshold", 100, 20, true}, + {"£100 total, £19.99 payment — just under", 100, 19.99, false}, + {"£100 total, £20.01 payment — just over", 100, 20.01, true}, + {"£50 total, £10 payment — at threshold", 50, 10, true}, + {"£50 total, £9.99 payment — just under", 50, 9.99, false}, + {"£33.33 total, £6.67 payment — at threshold", 33.33, 6.67, true}, + {"£33.33 total, £6.66 payment — just under", 33.33, 6.66, false}, + {"£99.99 total, £20.00 payment — at threshold", 99.99, 20.00, true}, + {"£99.99 total, £19.99 payment — just under", 99.99, 19.99, false}, + {"£0.01 total, £0.01 payment — at threshold", 0.01, 0.01, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + threshold := roundTo2(tt.total * depositPromotionMinPct) + ok := tt.payment >= threshold + + if ok != tt.wantOK { + t.Errorf("payment(%.2f) >= threshold(%.2f) = %v, want %v", + tt.payment, threshold, ok, tt.wantOK) + } + }) + } +} + +// ============================================================================ +// Section 21: Float64 Precision in Protected Deposit Calculation +// ============================================================================ + +// TestProtectedDepositCalculation_Float64Precision verifies that the protected +// deposit calculation (min(totalPrePaid, subtotal * ProtectedDepositMaxPct)) +// is precise with various float64 inputs. +func TestProtectedDepositCalculation_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subtotal float64 + prePaid float64 + wantProt float64 + }{ + {"£100 subtotal, £50 paid — protected=50", 100, 50, 50.00}, + {"£100 subtotal, £30 paid — protected=30", 100, 30, 30.00}, + {"£100 subtotal, £60 paid — protected=50 (capped)", 100, 60, 50.00}, + {"£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.00}, + {"£25.50 subtotal, £12.75 paid — protected=12.75", 25.50, 12.75, 12.75}, + {"£99.99 subtotal, £49.99 paid — protected=49.99", 99.99, 49.99, 49.99}, + {"£0 subtotal, £0 paid — protected=0", 0, 0, 0.00}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + maxDeposit := roundTo2(tt.subtotal * ProtectedDepositMaxPct) + protected := math.Min(tt.prePaid, maxDeposit) + protected = roundTo2(protected) + + if math.Abs(protected-tt.wantProt) > 0.005 { + t.Errorf("protected deposit = %.2f, want %.2f (maxDeposit=%.2f)", + protected, tt.wantProt, maxDeposit) + } + }) + } +} + +// ============================================================================ +// Section 22: Float64 Precision in Required Deposit Calculation +// ============================================================================ + +// TestRequiredDepositCalculation_Float64Precision verifies that the required +// deposit calculation (total * RequiredDepositPct) is precise. +func TestRequiredDepositCalculation_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + want float64 + }{ + {"£100 → 20% = £20.00", 100.00, 20.00}, + {"£50 → 20% = £10.00", 50.00, 10.00}, + {"£33.33 → 20% = £6.67", 33.33, 6.67}, + {"£99.99 → 20% = £20.00", 99.99, 20.00}, + {"£12.34 → 20% = £2.47", 12.34, 2.47}, + {"£0.01 → 20% = £0.00", 0.01, 0.00}, + {"£9999.99 → 20% = £2000.00", 9999.99, 2000.00}, + {"£0.00 → 20% = £0.00", 0.00, 0.00}, + {"£45.67 → 20% = £9.13", 45.67, 9.13}, + {"£67.89 → 20% = £13.58", 67.89, 13.58}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + required := roundTo2(tt.total * RequiredDepositPct) + if math.Abs(required-tt.want) > 0.005 { + t.Errorf("required deposit = %.2f, want %.2f", required, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 23: Float64 Precision in BookingIsFullyPaid Logic +// ============================================================================ + +// TestBookingIsFullyPaid_Float64Precision verifies that the fully-paid check +// (totalPaid >= totalAmount) is correct with various float64 inputs. +func TestBookingIsFullyPaid_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total float64 + paid float64 + wantFull bool + }{ + {"£100 total, £100 paid — fully paid", 100.00, 100.00, true}, + {"£100 total, £99.99 paid — not fully paid", 100.00, 99.99, false}, + {"£100 total, £100.01 paid — overpaid (still fully paid)", 100.00, 100.01, true}, + {"£50 total, £50 paid — fully paid", 50.00, 50.00, true}, + {"£50 total, £49.99 paid — not fully paid", 50.00, 49.99, false}, + {"£33.33 total, £33.33 paid — fully paid", 33.33, 33.33, true}, + {"£33.33 total, £33.32 paid — not fully paid", 33.33, 33.32, false}, + {"£0 total, £0 paid — fully paid (zero)", 0.00, 0.00, true}, + {"£99.99 total, £99.99 paid — fully paid", 99.99, 99.99, true}, + {"£99.99 total, £99.98 paid — not fully paid", 99.99, 99.98, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // The fully-paid check uses pence comparison to avoid float64 drift + totalPence := int64(math.Round(tt.total * 100)) + paidPence := int64(math.Round(tt.paid * 100)) + full := paidPence >= totalPence + + if full != tt.wantFull { + t.Errorf("fully paid: total=%.2f(%dp), paid=%.2f(%dp) → %v, want %v", + tt.total, totalPence, tt.paid, paidPence, full, tt.wantFull) + } + }) + } +} + +// ============================================================================ +// Section 24: Float64 Precision in Discount Stacking +// ============================================================================ + +// TestDiscountStacking_DoesNotExceedTotal verifies that stacking multiple +// discounts never exceeds the booking total, even with float64 drift. +func TestDiscountStacking_DoesNotExceedTotal(t *testing.T) { + t.Parallel() + + totals := []float64{100.00, 50.00, 33.33, 99.99, 12.34, 0.01, 9999.99} + percents := []float64{10.0, 5.0, 20.0, 7.5, 12.5, 0.0, 100.0} + + for _, total := range totals { + for _, pct := range percents { + amount := roundTo2(total * pct / 100) + if amount > total+0.005 { + t.Errorf("discount %.2f (%.1f%% of %.2f) exceeds total %.2f", + amount, pct, total, total) + } + } + } +} + +// ============================================================================ +// Section 25: Float64 Precision in Pence Conversion Edge Cases +// ============================================================================ + +// TestPenceConversion_AllValuesUpTo1000 verifies that every pence value from +// 1 to 1000 round-trips exactly through float64 conversion. +func TestPenceConversion_AllValuesUpTo1000(t *testing.T) { + t.Parallel() + + for pence := int64(1); pence <= 1000; pence++ { + pounds := float64(pence) / 100.0 + backPence := int64(math.Round(pounds * 100)) + if backPence != pence { + t.Errorf("round-trip failed at %d pence: → %.6f → %d", pence, pounds, backPence) + return + } + } +} + +// TestPenceConversion_AllPoundValues verifies that every whole-pound value +// from £1 to £1000 round-trips exactly. +func TestPenceConversion_AllPoundValues(t *testing.T) { + t.Parallel() + + for pounds := int64(1); pounds <= 1000; pounds++ { + pence := pounds * 100 + poundsFloat := float64(pence) / 100.0 + backPence := int64(math.Round(poundsFloat * 100)) + if backPence != pence { + t.Errorf("round-trip failed at £%d (%dp): → %.2f → %d", pounds, pence, poundsFloat, backPence) + return + } + } +} + +// ============================================================================ +// Section 26: Float64 Precision in VAT Rate Multiplication +// ============================================================================ + +// TestVATRateMultiplication_Float64Precision verifies that multiplying +// amounts by VAT rates (1 + rate/100) is precise. +func TestVATRateMultiplication_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount float64 + rate float64 + want float64 // amount * (1 + rate/100) + }{ + {"£100 at 20% = £120", 100.00, 20.0, 120.00}, + {"£50 at 20% = £60", 50.00, 20.0, 60.00}, + {"£33.33 at 20% = £40.00", 33.33, 20.0, 40.00}, + {"£99.99 at 20% = £119.99", 99.99, 20.0, 119.99}, + {"£12.34 at 20% = £14.81", 12.34, 20.0, 14.81}, + {"£0.01 at 20% = £0.01", 0.01, 20.0, 0.01}, + {"£100 at 5% = £105", 100.00, 5.0, 105.00}, + {"£100 at 0% = £100", 100.00, 0.0, 100.00}, + {"£9999.99 at 20% = £11999.99", 9999.99, 20.0, 11999.99}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gross := roundTo2(tt.amount * (1 + tt.rate/100)) + if math.Abs(gross-tt.want) > 0.005 { + t.Errorf("gross = %.2f, want %.2f", gross, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 27: Float64 Precision in Net Amount Calculation +// ============================================================================ + +// TestNetAmountCalculation_Float64Precision verifies that the net amount +// calculation (amount / (1 + rate/100)) is precise. +func TestNetAmountCalculation_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + gross float64 + rate float64 + want float64 + }{ + {"£120 at 20% = £100 net", 120.00, 20.0, 100.00}, + {"£60 at 20% = £50 net", 60.00, 20.0, 50.00}, + {"£1.20 at 20% = £1.00 net", 1.20, 20.0, 1.00}, + {"£0.12 at 20% = £0.10 net", 0.12, 20.0, 0.10}, + {"£105 at 5% = £100 net", 105.00, 5.0, 100.00}, + {"£100 at 0% = £100 net", 100.00, 0.0, 100.00}, + {"£11999.99 at 20% = £9999.99 net", 11999.99, 20.0, 9999.99}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + netAmount := roundTo2(tt.gross / (1 + tt.rate/100)) + if math.Abs(netAmount-tt.want) > 0.005 { + t.Errorf("net = %.2f, want %.2f", netAmount, tt.want) + } + }) + } +} + +// ============================================================================ +// Section 28: Float64 Precision in Multi-Step Calculation Chains +// ============================================================================ + +// TestMultiStepCalculation_Float64Precision verifies that chaining multiple +// float64 operations (discount → VAT → tip) does not accumulate significant +// drift. +func TestMultiStepCalculation_Float64Precision(t *testing.T) { + t.Parallel() + + // Simulate: £100 booking, 10% loyalty discount, then 20% VAT on the + // remaining amount, then a £10 tip. + bookingTotal := 100.00 + discountPct := 10.0 + vatRate := 20.0 + tipAmount := 10.00 + + // Step 1: Apply discount + discountAmount := roundTo2(bookingTotal * discountPct / 100) + afterDiscount := roundTo2(bookingTotal - discountAmount) + + // Step 2: Calculate VAT on the discounted amount + vatInclusive := roundTo2(afterDiscount * (1 + vatRate/100)) + + // Step 3: Add tip + totalCharge := roundTo2(vatInclusive + tipAmount) + + // Verify each step + if math.Abs(discountAmount-10.00) > 0.005 { + t.Errorf("discount = %.2f, want 10.00", discountAmount) + } + if math.Abs(afterDiscount-90.00) > 0.005 { + t.Errorf("after discount = %.2f, want 90.00", afterDiscount) + } + if math.Abs(vatInclusive-108.00) > 0.005 { + t.Errorf("VAT inclusive = %.2f, want 108.00", vatInclusive) + } + if math.Abs(totalCharge-118.00) > 0.005 { + t.Errorf("total charge = %.2f, want 118.00", totalCharge) + } +} + +// ============================================================================ +// Section 29: Float64 Precision in Refund Amount After Discount +// ============================================================================ + +// TestRefundAmountAfterDiscount_Float64Precision verifies that refund +// calculations correctly account for discounts already applied. +func TestRefundAmountAfterDiscount_Float64Precision(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subtotal float64 + discountAmount float64 + prePaid float64 + wantNetPaid float64 + }{ + {"£100 subtotal, £10 discount, £50 paid → net £40", 100, 10, 50, 40.00}, + {"£100 subtotal, £20 discount, £80 paid → net £60", 100, 20, 80, 60.00}, + {"£50 subtotal, £5 discount, £25 paid → net £20", 50, 5, 25, 20.00}, + {"£33.33 subtotal, £3.33 discount, £16.66 paid → net £13.33", 33.33, 3.33, 16.66, 13.33}, + {"£99.99 subtotal, £10 discount, £50 paid → net £40", 99.99, 10, 50, 40.00}, + {"£0.01 subtotal, £0 discount, £0.01 paid → net £0.01", 0.01, 0, 0.01, 0.01}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + netPaid := roundTo2(tt.prePaid - tt.discountAmount) + if math.Abs(netPaid-tt.wantNetPaid) > 0.005 { + t.Errorf("net paid = %.2f, want %.2f", netPaid, tt.wantNetPaid) + } + if netPaid < 0 { + t.Errorf("net paid is negative: %.2f", netPaid) + } + }) + } +} + +// ============================================================================ +// Section 30: Float64 Precision in Split Record Amounts +// ============================================================================ + +// TestSplitRecordAmounts_RoundedToTwoDecimals verifies that all split record +// amounts are rounded to exactly 2 decimal places. +func TestSplitRecordAmounts_RoundedToTwoDecimals(t *testing.T) { + t.Parallel() + + // Generate many combinations of total, paid, and charge + combinations := []struct { + total float64 + paid float64 + charge float64 + }{ + {100, 0, 50}, + {100, 0, 100}, + {100, 25, 75}, + {100, 50, 50}, + {100, 75, 25}, + {50, 0, 25}, + {50, 0, 50}, + {33.33, 0, 16.66}, + {33.33, 0, 33.33}, + {25.50, 0, 12.75}, + {25.50, 0, 25.50}, + {99.99, 0, 49.99}, + {99.99, 0, 99.99}, + {12.34, 0, 6.17}, + {12.34, 0, 12.34}, + } + + for _, c := range combinations { + t.Run("", func(t *testing.T) { + record := makeTestRecord("rounding-test", "full", c.charge) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: c.total, + TotalPaid: c.paid, + } + records, err := buildSplitRecords(record, "full", info, c.charge) + require.NoError(t, err) + + 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 %.6f is not rounded to 2dp (pence=%.0f)", i, r.Amount, pence) + } + } + }) + } +} \ No newline at end of file diff --git a/backend/handlers/payments/float64_validity_test.go b/backend/handlers/payments/float64_validity_test.go new file mode 100644 index 0000000..fde5c07 --- /dev/null +++ b/backend/handlers/payments/float64_validity_test.go @@ -0,0 +1,1155 @@ +//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) + } +} \ No newline at end of file diff --git a/backend/handlers/payments/refund_policy.go b/backend/handlers/payments/refund_policy.go index 3531015..bd9ca96 100644 --- a/backend/handlers/payments/refund_policy.go +++ b/backend/handlers/payments/refund_policy.go @@ -23,7 +23,8 @@ const ( // deposit REQUIRED at booking time, used by bookings.go): the promotion // threshold is about already-paid money, not the amount to demand up front, // even though both are 20% today. - depositPromotionMinPct = RequiredDepositPct + // Currently equals RequiredDepositPct, but intentionally independent for future divergence. + depositPromotionMinPct = 0.20 LoyaltyStampCost = 10 LoyaltyDiscountPercent = 10.0 diff --git a/backend/handlers/user/twofa.go b/backend/handlers/user/twofa.go index 345610b..3b24c22 100644 --- a/backend/handlers/user/twofa.go +++ b/backend/handlers/user/twofa.go @@ -355,7 +355,7 @@ const ( // flows clear the pending code themselves on success (enableTwoFA / // disableTwoFA), so the code must stay valid through the whole handshake here. func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) { - res, err := twofa.Check(r.Context(), userID, st, reqCode, false) + res, err := twofa.Check(r.Context(), db.Conn, userID, st, reqCode, false) return twoFACodeCheckResult(res), err } @@ -378,7 +378,7 @@ func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error { st.Mu.Lock() defer st.Mu.Unlock() - result, err := twofa.Check(ctx, userID, st, code, false) + result, err := twofa.Check(ctx, db.Conn, userID, st, code, false) if err != nil { return err } diff --git a/backend/internal/twofa/twofa.go b/backend/internal/twofa/twofa.go index f9e1e6e..150240c 100644 --- a/backend/internal/twofa/twofa.go +++ b/backend/internal/twofa/twofa.go @@ -373,7 +373,7 @@ const ( // error is non-nil only for DB failures (callers return 500); a lockout's // pending-code invalidation failure is logged here and still reported as a // lockout. -func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) { +func Check(ctx context.Context, q db.Querier, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) { if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow { st.Count.Store(0) st.SetLastActive(now) @@ -384,7 +384,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, var pendingHash sql.NullString var pendingExpires sql.NullTime - err := db.Conn.QueryRow(ctx, ` + err := q.QueryRow(ctx, ` SELECT two_factor_pending_code_hash, two_factor_pending_code_expires FROM users WHERE id = $1 @@ -406,7 +406,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, if st.Count.Load() >= MaxAttempts { // Lockout reached: destroy the pending code so a stolen digest // cannot be replayed against a fresh guessing loop. - if _, err := db.Conn.Exec(ctx, ` + if _, err := q.Exec(ctx, ` UPDATE users SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL @@ -425,7 +425,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, // code stays valid for the rest of the handshake — consume mode destroys // the digest outright, so there is nothing to upgrade. if legacy && !consume { - if _, err := db.Conn.Exec(ctx, ` + if _, err := q.Exec(ctx, ` UPDATE users SET two_factor_pending_code_hash = $2 WHERE id = $1 @@ -453,7 +453,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, // locked_until) — a successful 2FA challenge is a strong auth signal, and // the only way to reach a 2FA verify is an already-authenticated session. // Best-effort: a failure only logs; the verify has already succeeded. - if _, err := db.Conn.Exec(ctx, ` + if _, err := q.Exec(ctx, ` UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = $1 @@ -477,7 +477,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, // it, but only the first conditional UPDATE can affect a row — the // loser sees 0 rows and must fail (MissingOrExpired), so one code // authorizes exactly ONE operation even across instances. - tag, err := db.Conn.Exec(ctx, ` + tag, err := q.Exec(ctx, ` UPDATE users SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL @@ -590,7 +590,7 @@ func VerifyForUser(ctx context.Context, userID, code string, consume bool) error st.Mu.Lock() defer st.Mu.Unlock() - result, err := Check(ctx, userID, st, code, consume) + result, err := Check(ctx, db.Conn, userID, st, code, consume) if err != nil { return fmt.Errorf("2FA verify: %w", err) } diff --git a/backend/internal/twofa/twofa_test.go b/backend/internal/twofa/twofa_test.go index 97b2e9b..2d4e1b4 100644 --- a/backend/internal/twofa/twofa_test.go +++ b/backend/internal/twofa/twofa_test.go @@ -155,7 +155,7 @@ func TestVerifyForUser_DBAtomicConsume_Concurrent(t *testing.T) { defer wg.Done() st := &AttemptState{} st.SetLastActive(clock.Now()) - res, err := Check(context.Background(), userID, st, "424242", true) + res, err := Check(context.Background(), db.Conn, userID, st, "424242", true) results <- outcome{result: res, err: err} }() } diff --git a/backend/main.go b/backend/main.go index 1f9b63f..da3a97d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -464,9 +464,7 @@ func corsMiddleware(next http.Handler) http.Handler { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") - // TODO: Enable HSTS in production w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - // TODO: Enable Referrer-Policy in production w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") diff --git a/frontend/src/app.html b/frontend/src/app.html index 9696d2e..4eb3d32 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -16,7 +16,7 @@ httpOnly cookies is the durable fix (out of scope). --> %sveltekit.head% diff --git a/frontend/src/lib/components/admin/HolidayHours.svelte b/frontend/src/lib/components/admin/HolidayHours.svelte index 0987da3..797854b 100644 --- a/frontend/src/lib/components/admin/HolidayHours.svelte +++ b/frontend/src/lib/components/admin/HolidayHours.svelte @@ -230,8 +230,7 @@ description: group.description, weekStarts: group.weekStarts || [], hours: - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (group.hours as any[])?.map( + (group.hours as HolidayHour[])?.map( (h: { id: number; weekday: number; diff --git a/frontend/src/lib/components/layout/ContactCard.svelte b/frontend/src/lib/components/layout/ContactCard.svelte index 58a73ad..1790f8d 100644 --- a/frontend/src/lib/components/layout/ContactCard.svelte +++ b/frontend/src/lib/components/layout/ContactCard.svelte @@ -8,7 +8,7 @@ email = 'chelsea@emailaddress.com', instagram = '@crussell', address = 'Business Centre, Office Street, Work', - profileImage = 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png', + profileImage = '', altText = 'Profile picture' }: { name?: string; @@ -64,9 +64,34 @@
-
- {altText} -
+ {#if profileImage} + {altText} + {:else if name} +
+ {name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()} +
+ {:else} +
+ + + +
+ {/if}
diff --git a/frontend/src/lib/components/layout/PortfolioCarousel.svelte b/frontend/src/lib/components/layout/PortfolioCarousel.svelte index b986c14..d2050be 100644 --- a/frontend/src/lib/components/layout/PortfolioCarousel.svelte +++ b/frontend/src/lib/components/layout/PortfolioCarousel.svelte @@ -1,38 +1,14 @@ diff --git a/frontend/src/lib/components/ui/map/Map.svelte b/frontend/src/lib/components/ui/map/Map.svelte index cc11d5b..e7c665c 100644 --- a/frontend/src/lib/components/ui/map/Map.svelte +++ b/frontend/src/lib/components/ui/map/Map.svelte @@ -73,9 +73,22 @@ onstyleloaded?: () => void; } + const osmRasterStyle: MapLibreGL.StyleSpecification = { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors' + } + }, + layers: [{ id: 'osm', type: 'raster', source: 'osm' }] + }; + const defaultStyles = { - dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', - light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json' + dark: osmRasterStyle, + light: osmRasterStyle }; let { diff --git a/frontend/src/lib/constants/policy.ts b/frontend/src/lib/constants/policy.ts index 4e8454c..61a7ee1 100644 --- a/frontend/src/lib/constants/policy.ts +++ b/frontend/src/lib/constants/policy.ts @@ -13,8 +13,21 @@ export const POLICY = { LOYALTY_DISCOUNT_RATE: 0.1, // Gratuity suggestion presets offered by the tip surfaces (admin payment // modal + customer tip page). Deliberately SEPARATE from the deposit policy - // constants above — a tip suggestion is gratuity, not a deposit percentage, + // constants below — a tip suggestion is gratuity, not a deposit percentage, // and coupling them would silently change the tip buttons if the deposit // rate ever changed. TIP_PRESET_PCTS: [10, 15, 20] } as const; + +// Named re-exports so pages can import individual constants directly. +// Keep in sync with the POLICY object above. +export const { + FULL_REFUND_THRESHOLD_HOURS, + PARTIAL_REFUND_THRESHOLD_HOURS, + NO_SHOW_THRESHOLD_HOURS, + DEPOSIT_ADVANCE_HOURS, + PROTECTED_DEPOSIT_MAX_PCT, + REQUIRED_DEPOSIT_PCT, + LOYALTY_DISCOUNT_RATE, + TIP_PRESET_PCTS +} = POLICY; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index a200fe9..1e634d7 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -120,12 +120,7 @@ - - - +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index f938edf..bd18fe5 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -94,12 +94,6 @@ - - -
diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index f6600e2..6a23851 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -2169,7 +2169,7 @@ import { generateUUID } from '$lib/utils/uuid'; +/> (buyCardSelectionValid = v)} -
+ /> +
{#if buyWaitingForSCA}
diff --git a/frontend/src/routes/contact/+page.svelte b/frontend/src/routes/contact/+page.svelte index 8e239e6..03c4ed7 100644 --- a/frontend/src/routes/contact/+page.svelte +++ b/frontend/src/routes/contact/+page.svelte @@ -57,8 +57,7 @@ email={contact.email} instagram="crussell" address={BUSINESS_ADDRESS} - profileImage={contact.profilePicUrl || - 'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'} + profileImage={contact.profilePicUrl || ''} /> {:else}

Financial Data:

    -
  • Gift card codes and balances
  • -
  • Account balances
  • +
  • Gift card codes (hashed), balances, and transaction history (purchases, redemptions, top-ups)
  • +
  • Account balances (from redeemed gift cards or cash refunds)
  • Payment transaction records (our ledger of record, retained for 7 years for HMRC)
  • Saved-card references (tokenised, stored with our payment provider Square — see §2.2)
  • +
  • Square customer IDs (created when you save a card for future payments)
  • +
  • Card tokens (Square ccof: references for recurring payments)
  • Dormant balance records (Account ID only, no PII)
diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte index 8e290f4..eb193dd 100644 --- a/frontend/src/routes/terms/+page.svelte +++ b/frontend/src/routes/terms/+page.svelte @@ -271,7 +271,35 @@

- 5. Gift Cards & Account Balances + 5. Tips +

+
    +
  • + Tips are entirely optional. You are under no obligation to leave a tip, + and the quality of service you receive is not affected by whether you choose to do so. +
  • +
  • + When you can tip: you may add a tip during the payment process or after + the service has been completed, through your account. +
  • +
  • + Tips are non-refundable once processed. Because a tip is a voluntary + gratuity paid after (or at the point of) service, it is not subject to the cancellation + and refund tiers that apply to booking payments in section 3. Once the tip transaction + has been completed, it cannot be refunded. +
  • +
  • + Payment processing: tips are processed through the same payment methods + and by the same payment provider (Square) as booking payments. The same SCA requirements, + chargeback rules, and refund-to-original-method principles apply to tip payments as to + booking payments (see section 4). +
  • +
+
+ +
+

+ 6. Gift Cards & Account Balances

Gift card expiry

    @@ -327,7 +355,7 @@

    - 6. Distance Contracts & Right to Cancel + 7. Distance Contracts & Right to Cancel

    Purchases made on our Platform (rather than face-to-face in the salon) are @@ -342,7 +370,7 @@

  • Gift cards bought online carry this 14-day right, refunded to the original payment method — in full if unused, or the unspent balance if partly used - on salon services (the card is then cancelled). See section 5 and our + on salon services (the card is then cancelled). See section 6 and our Gift Card Terms
    -

    7. Liability

    +

    8. Liability

    • Nothing in these Terms excludes or limits any rights you have under consumer law — @@ -389,7 +417,7 @@
    -

    8. Acceptable Use

    +

    9. Acceptable Use

    By using the Platform you agree not to:

    • @@ -414,7 +442,7 @@

      - 9. Complaints & Dispute Resolution + 10. Complaints & Dispute Resolution

      If you are unhappy with any part of our service, please contact us first at @@ -431,7 +459,7 @@

      -

      10. Governing Law

      +

      11. Governing Law

      These Terms are governed by the laws of Scotland. Any dispute arising out of or in connection with these Terms is subject to the exclusive jurisdiction of the diff --git a/frontend/static/fonts/playfair-display-latin-italic.woff2 b/frontend/static/fonts/playfair-display-latin-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8ae1f1f7ed71f4b63c71218cb61c8fcb795099ff GIT binary patch literal 38804 zcmV)GK)%0sPew8T0RR910GE^i6951J0X8TA0GA{H0RR9100000000000000000000 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 fZE3ez&PqAUvx@&TS+(YgQjeYT;J>_jPoD( + + + + + + + + + + + + + + + + + Nail Art — Classic + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-02.svg b/frontend/static/images/portfolio/placeholder-02.svg new file mode 100644 index 0000000..a420214 --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-02.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + Nail Art — French + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-03.svg b/frontend/static/images/portfolio/placeholder-03.svg new file mode 100644 index 0000000..cfdc237 --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-03.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + Nail Art — Warm + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-04.svg b/frontend/static/images/portfolio/placeholder-04.svg new file mode 100644 index 0000000..de9c57a --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-04.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + Nail Art — Geometric + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-05.svg b/frontend/static/images/portfolio/placeholder-05.svg new file mode 100644 index 0000000..315abbc --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-05.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + Nail Art — Marble + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-06.svg b/frontend/static/images/portfolio/placeholder-06.svg new file mode 100644 index 0000000..ee10121 --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-06.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + Nail Art — Floral + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-07.svg b/frontend/static/images/portfolio/placeholder-07.svg new file mode 100644 index 0000000..b8b341b --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-07.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Nail Art — Ombre + \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-08.svg b/frontend/static/images/portfolio/placeholder-08.svg new file mode 100644 index 0000000..470d236 --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-08.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + Nail Art — Chrome + \ No newline at end of file