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
1682 lines
59 KiB
Go
1682 lines
59 KiB
Go
//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)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
} |