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