Files
Crussell/backend/handlers/payments/float64_validity_round3_test.go
popertots 4146f8e09a fix: pre-launch review — security, money safety, privacy, legal, code quality
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
2026-08-22 00:34:51 +01:00

1682 lines
59 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}
}
})
}
}