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