Security (P0): - IsJTIRevoked fails closed on DB error (previously accepted revoked tokens) - Remove dead consume parameter from SCA gate (prevented token replay) - Rate limiter map TTL-based eviction (prevented memory exhaustion) - 2FA attempt map already had LRU eviction (verified) Money Safety (P1): - Gift card transfer refuses expired destination cards - Gift card balance deduction has WHERE balance >= amount guard - Webhook clawback acquires till-sale advisory lock - Sweep/retry lock keys aligned Privacy/Cookies (P2): - Self-host Google Fonts (Playfair Display woff2) - Replace CARTO map tiles with OpenStreetMap raster tiles - Replace Wikimedia/icon-icons external images with local SVGs - Remove external image URLs from CSP Legal (P3): - Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies) - Terms: add Tips section (optionality, non-refundable, same processing as bookings) Code Quality (P4): - twofa.Check accepts db.Querier for testability - depositPromotionMinPct uses literal 0.20 (not misleading alias) - HolidayHours.svelte uses proper type (not as any[]) - Remove stale TODO comments from main.go Testing (P5): - 94 new float64 money validity tests across 3 test files - Cover VAT, splits, refunds, gift cards, rounding, precision boundaries - All 27 backend test packages pass
1204 lines
38 KiB
Go
1204 lines
38 KiB
Go
//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)
|
||
}
|
||
})
|
||
}
|
||
} |