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 @@
Financial Data:
ccof: references for recurring payments)Gift card expiry
Purchases made on our Platform (rather than face-to-face in the salon) are @@ -342,7 +370,7 @@
By using the Platform you agree not to:
If you are unhappy with any part of our service, please contact us first at @@ -431,7 +459,7 @@
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 0000000..8ae1f1f Binary files /dev/null and b/frontend/static/fonts/playfair-display-latin-italic.woff2 differ diff --git a/frontend/static/fonts/playfair-display-latin-normal.woff2 b/frontend/static/fonts/playfair-display-latin-normal.woff2 new file mode 100644 index 0000000..5a3fbbd Binary files /dev/null and b/frontend/static/fonts/playfair-display-latin-normal.woff2 differ diff --git a/frontend/static/fonts/playfair-display.css b/frontend/static/fonts/playfair-display.css new file mode 100644 index 0000000..bf65738 --- /dev/null +++ b/frontend/static/fonts/playfair-display.css @@ -0,0 +1,22 @@ +/* Playfair Display — self-hosted (Latin subset only) */ +@font-face { + font-family: 'Playfair Display'; + font-style: normal; + font-weight: 400 900; + font-display: swap; + src: url('/fonts/playfair-display-latin-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} + +@font-face { + font-family: 'Playfair Display'; + font-style: italic; + font-weight: 400 900; + font-display: swap; + src: url('/fonts/playfair-display-latin-italic.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} \ No newline at end of file diff --git a/frontend/static/images/portfolio/placeholder-01.svg b/frontend/static/images/portfolio/placeholder-01.svg new file mode 100644 index 0000000..0b3dcd1 --- /dev/null +++ b/frontend/static/images/portfolio/placeholder-01.svg @@ -0,0 +1,19 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ 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 @@ + \ No newline at end of file