Files
Crussell/backend/handlers/payments/float64_validity_round2_test.go
T
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

1204 lines
38 KiB
Go
Raw 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 (
"math"
"testing"
"time"
"crussell/clock"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/require"
)
// ============================================================================
// Section 1: roundTo2 — Pure Float64 Rounding
// ============================================================================
// TestRoundTo2_Float64Precision verifies that roundTo2 correctly rounds
// float64 values to 2 decimal places using math.Round(f*100)/100.
func TestRoundTo2_Float64Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input float64
want float64
}{
{"simple value", 12.34, 12.34},
{"round up", 12.345, 12.35},
{"round down", 12.344, 12.34},
{"integer", 50.00, 50.00},
{"zero", 0.00, 0.00},
{"half-penny up", 0.005, 0.01},
{"half-penny down (float64 quirk)", 1.005, 1.00}, // float64 1.005 → 1.0049999...
{"just above half-penny", 1.005001, 1.01},
{"large value", 9999.99, 9999.99},
{"very small", 0.01, 0.01},
{"recurring decimal", 33.333333, 33.33},
{"negative", -12.345, -12.35},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := roundTo2(tt.input)
if got != tt.want {
t.Errorf("roundTo2(%v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// TestRoundTo2_Deterministic verifies that roundTo2 is deterministic —
// calling it 100 times with the same input produces the same result.
func TestRoundTo2_Deterministic(t *testing.T) {
t.Parallel()
inputs := []float64{12.345, 0.005, 33.333, 9999.994, 1.005}
for _, input := range inputs {
t.Run("", func(t *testing.T) {
first := roundTo2(input)
for i := 0; i < 100; i++ {
if roundTo2(input) != first {
t.Errorf("roundTo2(%v) changed between calls: first=%v, iter=%d", input, first, i)
}
}
})
}
}
// TestRoundTo2_AccumulationDrift verifies that accumulating roundTo2 results
// does not produce significant drift compared to rounding the total.
func TestRoundTo2_AccumulationDrift(t *testing.T) {
t.Parallel()
// 100 payments of £12.34 each
var sumRounded float64
for i := 0; i < 100; i++ {
sumRounded += roundTo2(12.34)
}
sumRounded = roundTo2(sumRounded)
// Direct: 100 * 12.34 = 1234.00
direct := roundTo2(100 * 12.34)
if sumRounded != direct {
t.Errorf("accumulated roundTo2 sum = %.2f, direct = %.2f — drift detected", sumRounded, direct)
}
}
// ============================================================================
// Section 2: effectiveVoucherTypeForPurchase — Pure Function
// ============================================================================
func TestEffectiveVoucherTypeForPurchase(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
want string
}{
{"SPV stays SPV", "SPV", "SPV"},
{"MPV overridden to SPV", "MPV", "SPV"},
{"empty string", "", ""},
{"lowercase spv", "spv", "spv"},
{"lowercase mpv", "mpv", "mpv"},
{"unknown type", "OTHER", "OTHER"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := effectiveVoucherTypeForPurchase(tt.raw)
if got != tt.want {
t.Errorf("effectiveVoucherTypeForPurchase(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
// ============================================================================
// Section 3: vatAppliesToVoucher — Pure Function
// ============================================================================
func TestVATAppliesToVoucher(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg *VATConfig
want bool
}{
{"nil config", nil, false},
{"not registered, SPV", &VATConfig{IsVATRegistered: false, VoucherType: "SPV"}, false},
{"registered, SPV", &VATConfig{IsVATRegistered: true, VoucherType: "SPV"}, true},
{"registered, MPV", &VATConfig{IsVATRegistered: true, VoucherType: "MPV"}, false}, // vatAppliesToVoucher does not normalize
{"not registered, MPV", &VATConfig{IsVATRegistered: false, VoucherType: "MPV"}, false},
{"registered, empty type", &VATConfig{IsVATRegistered: true, VoucherType: ""}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := vatAppliesToVoucher(tt.cfg)
if got != tt.want {
t.Errorf("vatAppliesToVoucher(%+v) = %v, want %v", tt.cfg, got, tt.want)
}
})
}
}
// ============================================================================
// Section 4: CalculateFees — Exact Value Tests
// ============================================================================
// TestCalculateFees_ExactValues verifies that CalculateFees produces the
// exact expected fee amounts for both online and terminal payment methods.
//
// Online formula: amount×14/1000 + 25 (pence), converted to pounds
// Terminal formula: amount×175/10000 (pence), converted to pounds
func TestCalculateFees_ExactValues(t *testing.T) {
t.Parallel()
svc := NewPaymentService()
tests := []struct {
name string
amount int64 // pence
method string
want float64
}{
{"online: 1p → 25p → £0.25", 1, "online", 0.25},
{"online: 100p (£1) → 26p → £0.26", 100, "online", 0.26},
{"online: 1000p (£10) → 39p → £0.39", 1000, "online", 0.39},
{"online: 10000p (£100) → 165p → £1.65", 10000, "online", 1.65},
{"online: 100000p (£1000) → 1425p → £14.25", 100000, "online", 14.25},
{"online: 1000000p (£10000) → 14025p → £140.25", 1000000, "online", 140.25},
{"terminal: 1p → 0p → £0.00", 1, "terminal", 0.00},
{"terminal: 100p (£1) → 1p → £0.01", 100, "terminal", 0.01},
{"terminal: 1000p (£10) → 17p → £0.17", 1000, "terminal", 0.17},
{"terminal: 10000p (£100) → 175p → £1.75", 10000, "terminal", 1.75},
{"terminal: 100000p (£1000) → 1750p → £17.50", 100000, "terminal", 17.50},
{"terminal: 1000000p (£10000) → 17500p → £175.00", 1000000, "terminal", 175.00},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fees := svc.CalculateFees(tt.amount, tt.method)
if math.Abs(fees-tt.want) > 0.0001 {
t.Errorf("CalculateFees(%d, %q) = %.6f, want %.6f", tt.amount, tt.method, fees, tt.want)
}
if fees < 0 {
t.Errorf("CalculateFees(%d, %q) = %.6f — negative fees!", tt.amount, tt.method, fees)
}
if math.IsNaN(fees) || math.IsInf(fees, 0) {
t.Errorf("CalculateFees(%d, %q) = %v — non-finite!", tt.amount, tt.method, fees)
}
})
}
}
// TestCalculateFees_EdgeCases verifies fee calculation at boundary values.
func TestCalculateFees_EdgeCases(t *testing.T) {
t.Parallel()
svc := NewPaymentService()
onlineZero := svc.CalculateFees(0, "online")
if onlineZero != 0.25 {
t.Errorf("online fee for 0 amount = %.4f, want 0.25 (min fee only)", onlineZero)
}
terminalZero := svc.CalculateFees(0, "terminal")
if terminalZero != 0.00 {
t.Errorf("terminal fee for 0 amount = %.4f, want 0.00", terminalZero)
}
onlineMax := svc.CalculateFees(1_000_000, "online")
if onlineMax <= 0 {
t.Errorf("online fee for max amount = %.4f, want > 0", onlineMax)
}
terminalMax := svc.CalculateFees(1_000_000, "terminal")
if terminalMax <= 0 {
t.Errorf("terminal fee for max amount = %.4f, want > 0", terminalMax)
}
unknown := svc.CalculateFees(10000, "unknown")
terminal := svc.CalculateFees(10000, "terminal")
if unknown != terminal {
t.Errorf("unknown method fee = %.4f, terminal fee = %.4f — should match", unknown, terminal)
}
}
// ============================================================================
// Section 5: buildSplitRecords — Missing Type Cases
// ============================================================================
// TestBuildSplitRecords_BalanceType verifies that when totalPaidAfterBalance
// >= totalAmount AND there was previous payment, the second record gets
// payment_type='balance'.
func TestBuildSplitRecords_BalanceType(t *testing.T) {
t.Parallel()
// £100 total, £25 already paid (deposit), charge £75 more
// deposit room = min(50, 50-25) = 25
// depositAmount = min(75, 25) = 25
// remainingAfterDeposit = 75-25 = 50
// bookingRemaining = max(0, 100-25-25) = 50
// balancePortion = min(50, 50) = 50
// totalPaidAfterBalance = 25 + 25 + 50 = 100 >= 100 AND 100-50 = 50 > 0 → "balance"
record := makeTestRecord("balance-test", "full", 75)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 100,
TotalPaid: 25,
}
records, err := buildSplitRecords(record, "full", info, 75)
require.NoError(t, err)
require.GreaterOrEqual(t, len(records), 2, "expected at least 2 split records")
var foundBalance bool
for _, r := range records {
if r.PaymentType == "balance" {
foundBalance = true
if r.Amount != 50 {
t.Errorf("balance record amount = %.2f, want 50.00", r.Amount)
}
}
}
if !foundBalance {
t.Error("expected a 'balance' type record, none found")
}
// Verify sum equals charged amount
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if math.Abs(sum-75) > roundingEpsilon {
t.Errorf("split sum %.2f != charged amount 75.00", sum)
}
}
func TestBuildSplitRecords_FullType(t *testing.T) {
t.Parallel()
record := makeTestRecord("full-type-test", "full", 25)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 25,
}
records, err := buildSplitRecords(record, "full", info, 25)
require.NoError(t, err)
require.GreaterOrEqual(t, len(records), 1, "expected at least 1 record")
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if math.Abs(sum-25) > roundingEpsilon {
t.Errorf("split sum %.2f != charged amount 25.00", sum)
}
}
func TestBuildSplitRecords_PartialType(t *testing.T) {
t.Parallel()
record := makeTestRecord("partial-type-test", "full", 30)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 100,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 30)
require.NoError(t, err)
require.GreaterOrEqual(t, len(records), 1, "expected at least 1 record")
if records[0].PaymentType != "deposit" {
t.Errorf("expected 'deposit', got %q", records[0].PaymentType)
}
if records[0].Amount != 30 {
t.Errorf("expected amount 30, got %.2f", records[0].Amount)
}
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if math.Abs(sum-30) > roundingEpsilon {
t.Errorf("split sum %.2f != charged amount 30.00", sum)
}
}
func TestBuildSplitRecords_DefensiveFallback(t *testing.T) {
t.Parallel()
record := makeTestRecord("fallback-test", "full", 0.003)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 50,
}
records, err := buildSplitRecords(record, "full", info, 0.003)
require.NoError(t, err)
require.Equal(t, 1, len(records), "expected 1 fallback record")
if records[0].Amount != 0.003 {
t.Errorf("fallback record amount = %.4f, want 0.003", records[0].Amount)
}
}
func TestBuildSplitRecords_RoundingEpsilonBoundary(t *testing.T) {
t.Parallel()
record := makeTestRecord("epsilon-test", "full", 0.005)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 0.005)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if math.Abs(sum-0.01) > roundingEpsilon {
t.Errorf("split sum %.4f != expected 0.01", sum)
}
}
func TestBuildSplitRecords_PostStart_NoTip(t *testing.T) {
t.Parallel()
record := makeTestRecord("poststart-notip", "full", 50)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 50)
require.NoError(t, err)
require.Equal(t, 1, len(records), "expected 1 record (no tip)")
if records[0].Amount != 50 {
t.Errorf("record amount = %.2f, want 50.00", records[0].Amount)
}
}
func TestBuildSplitRecords_PostStart_TipOverflowError(t *testing.T) {
t.Parallel()
record := makeTestRecord("poststart-tip-error", "full", 301)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
_, err := buildSplitRecords(record, "full", info, 301)
require.Error(t, err, "expected error for tip exceeding maxOnlineTipPence")
}
func TestBuildSplitRecords_PreStart_TipOverflowError(t *testing.T) {
t.Parallel()
record := makeTestRecord("prestart-tip-error", "full", 301)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
_, err := buildSplitRecords(record, "full", info, 301)
require.Error(t, err, "expected error for tip exceeding maxOnlineTipPence")
}
// ============================================================================
// Section 6: buildTerminalSplitRecords — Missing Cases
// ============================================================================
func TestBuildTerminalSplitRecords_DefensiveFallback(t *testing.T) {
t.Parallel()
primary := makeTestRecord("terminal-fallback", "full", 0.003)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 50,
}
records := buildTerminalSplitRecords(primary, info, 0.003, 0)
require.Equal(t, 1, len(records), "expected 1 fallback record")
if records[0].Amount != 0.003 {
t.Errorf("fallback record amount = %.4f, want 0.003", records[0].Amount)
}
}
func TestBuildTerminalSplitRecords_RoundingEpsilonBoundary(t *testing.T) {
t.Parallel()
primary := makeTestRecord("terminal-epsilon", "full", 0.01)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records := buildTerminalSplitRecords(primary, info, 0.01, 0)
var sum float64
for _, r := range records {
sum += r.Amount
}
sum = math.Round(sum*100) / 100
if math.Abs(sum-0.01) > roundingEpsilon {
t.Errorf("split sum %.4f != expected 0.01", sum)
}
}
func TestBuildTerminalSplitRecords_TipOnly(t *testing.T) {
t.Parallel()
primary := makeTestRecord("terminal-tip-only", "full", 10)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 50,
}
records := buildTerminalSplitRecords(primary, info, 0, 10)
require.Equal(t, 1, len(records), "expected 1 tip record")
if records[0].PaymentType != "tip" {
t.Errorf("expected 'tip', got %q", records[0].PaymentType)
}
if records[0].Amount != 10 {
t.Errorf("tip amount = %.2f, want 10.00", records[0].Amount)
}
}
// ============================================================================
// Section 7: CalculateRefundForCancellation — Boundary Tests
// ============================================================================
// TestCalculateRefundForCancellation_ExactThresholds verifies behaviour at
// the exact tier boundaries (72h and 24h).
func TestCalculateRefundForCancellation_ExactThresholds(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
subtotal float64
prePaid float64
cancelTime time.Time
wantTier string
wantRefund float64
wantKept float64
}{
{
name: "exactly 72h before — partial refund (not > 72h)",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-72 * time.Hour),
wantTier: PartialRefundTier,
wantRefund: 0,
wantKept: 50,
},
{
name: "71.999h before — partial refund (just inside 24-72h)",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-72*time.Hour + time.Second),
wantTier: PartialRefundTier,
wantRefund: 0,
wantKept: 50,
},
{
name: "exactly 24h before — partial refund",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-24 * time.Hour),
wantTier: PartialRefundTier,
wantRefund: 0,
wantKept: 50,
},
{
name: "23.999h before — no refund (just inside <24h)",
subtotal: 100,
prePaid: 50,
cancelTime: start.Add(-24*time.Hour + time.Second),
wantTier: NoRefundTier,
wantRefund: 0,
wantKept: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CalculateRefundForCancellation(tt.subtotal, tt.prePaid, tt.cancelTime, start)
if result.Tier != tt.wantTier {
t.Errorf("tier: got %q, want %q", result.Tier, tt.wantTier)
}
if math.Abs(result.RefundableAmount-tt.wantRefund) > 0.005 {
t.Errorf("refundable: got %.2f, want %.2f", result.RefundableAmount, tt.wantRefund)
}
if math.Abs(result.KeptAmount-tt.wantKept) > 0.005 {
t.Errorf("kept: got %.2f, want %.2f", result.KeptAmount, tt.wantKept)
}
total := math.Round((result.RefundableAmount+result.KeptAmount)*100) / 100
expectedTotal := math.Round(tt.prePaid*100) / 100
if total != expectedTotal {
t.Errorf("refundable+kept=%.2f, but prePaid=%.2f — money conservation broken", total, expectedTotal)
}
})
}
}
func TestCalculateRefundForCancellation_NegativeGuard(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
cancelTime := start.Add(-48 * time.Hour)
result := CalculateRefundForCancellation(100, 50, cancelTime, start)
if result.RefundableAmount < 0 {
t.Errorf("refundable amount is negative: %.2f", result.RefundableAmount)
}
}
func TestCalculateRefundForCancellation_ZeroPrePaid(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
subtotal float64
prePaid float64
cancelTime time.Time
wantTier string
wantRefund float64
wantKept float64
}{
{
name: "£100 subtotal, £0 paid, >72h — full refund (nothing to refund)",
subtotal: 100,
prePaid: 0,
cancelTime: start.Add(-73 * time.Hour),
wantTier: FullRefundTier,
wantRefund: 0,
wantKept: 0,
},
{
name: "£100 subtotal, £0 paid, 24-72h — partial refund (nothing to refund)",
subtotal: 100,
prePaid: 0,
cancelTime: start.Add(-48 * time.Hour),
wantTier: PartialRefundTier,
wantRefund: 0,
wantKept: 0,
},
{
name: "£100 subtotal, £0 paid, <24h — no refund (nothing to keep)",
subtotal: 100,
prePaid: 0,
cancelTime: start.Add(-12 * time.Hour),
wantTier: NoRefundTier,
wantRefund: 0,
wantKept: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CalculateRefundForCancellation(tt.subtotal, tt.prePaid, tt.cancelTime, start)
if result.Tier != tt.wantTier {
t.Errorf("tier: got %q, want %q", result.Tier, tt.wantTier)
}
if result.RefundableAmount != 0 {
t.Errorf("refundable: got %.2f, want 0", result.RefundableAmount)
}
if result.KeptAmount != 0 {
t.Errorf("kept: got %.2f, want 0", result.KeptAmount)
}
})
}
}
func TestCalculateRefundForCancellation_ProtectedDepositCapping(t *testing.T) {
t.Parallel()
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
cancelTime := start.Add(-48 * time.Hour)
result := CalculateRefundForCancellation(100, 60, cancelTime, start)
if result.Tier != PartialRefundTier {
t.Errorf("tier: got %q, want %q", result.Tier, PartialRefundTier)
}
if math.Abs(result.ProtectedDeposit-50) > 0.005 {
t.Errorf("protected deposit: got %.2f, want 50.00", result.ProtectedDeposit)
}
if math.Abs(result.KeptAmount-50) > 0.005 {
t.Errorf("kept: got %.2f, want 50.00", result.KeptAmount)
}
if math.Abs(result.RefundableAmount-10) > 0.005 {
t.Errorf("refundable: got %.2f, want 10.00", result.RefundableAmount)
}
}
// ============================================================================
// Section 8: capDiscountToRemainingObligation — Pure Float64 Function
// ============================================================================
func TestCapDiscountToRemainingObligation_Float64Precision(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(48*time.Hour))
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE bookings SET total_amount = 100 WHERE id = $1`, bookingID)
require.NoError(t, err)
capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, 50)
require.True(t, ok, "discount should be allowed")
if math.Abs(capped-50) > 0.005 {
t.Errorf("capped discount = %.2f, want 50.00", capped)
}
// Discount of £150 should be capped to £100 (the headroom)
capped, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 150)
require.True(t, ok, "discount should still be allowed (capped)")
if math.Abs(capped-100) > 0.005 {
t.Errorf("capped discount = %.2f, want 100.00", capped)
}
// Add a completed payment of £100
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
VALUES ($1, 'full', 'cash', 100, 'completed')
`, bookingID)
require.NoError(t, err)
// Now headroom = 0, discount should be skipped
_, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 10)
require.False(t, ok, "discount should be skipped when headroom is 0")
}
func TestCapDiscountToRemainingObligation_EdgeCases(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(48*time.Hour))
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE bookings SET total_amount = 50 WHERE id = $1`, bookingID)
require.NoError(t, err)
capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, 50)
require.True(t, ok)
if math.Abs(capped-50) > 0.005 {
t.Errorf("capped = %.2f, want 50.00", capped)
}
capped, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 0.01)
require.True(t, ok)
if math.Abs(capped-0.01) > 0.005 {
t.Errorf("capped = %.2f, want 0.01", capped)
}
capped, ok = capDiscountToRemainingObligation(ctx, tx, bookingID, 0)
require.True(t, ok)
if capped != 0 {
t.Errorf("capped = %.2f, want 0.00", capped)
}
}
// ============================================================================
// Section 9: GetGiftCardExpiryMonths — Edge Cases
// ============================================================================
func TestGetGiftCardExpiryMonths_EdgeCases(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
months, err := GetGiftCardExpiryMonths(ctx, tx)
require.NoError(t, err)
require.Greater(t, months, 0, "expiry months must be positive")
_, err = tx.Exec(ctx, `UPDATE business_settings SET gift_card_expiry_months = 12`)
require.NoError(t, err)
months, err = GetGiftCardExpiryMonths(ctx, tx)
require.NoError(t, err)
require.Equal(t, 12, months, "expected 12 months")
_, err = tx.Exec(ctx, `UPDATE business_settings SET gift_card_expiry_months = 0`)
require.NoError(t, err)
months, err = GetGiftCardExpiryMonths(ctx, tx)
require.NoError(t, err)
require.Equal(t, defaultGiftCardExpiryMonths, months, "expected default for invalid value 0")
_, err = tx.Exec(ctx, `UPDATE business_settings SET gift_card_expiry_months = -1`)
require.NoError(t, err)
months, err = GetGiftCardExpiryMonths(ctx, tx)
require.NoError(t, err)
require.Equal(t, defaultGiftCardExpiryMonths, months, "expected default for negative value")
}
// ============================================================================
// Section 10: giftCardAmountPence — Additional Edge Cases
// ============================================================================
// TestGiftCardAmountPence_SubPennyBoundaries verifies that sub-penny amounts
// are correctly rounded to pence.
func TestGiftCardAmountPence_SubPennyBoundaries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amount float64
want int64
}{
{"£0.004 → 0p (below 0.5p threshold)", 0.004, 0},
{"£0.004999 → 0p (just below 0.5p)", 0.004999, 0},
{"£0.005 → 1p (at 0.5p threshold)", 0.005, 1},
{"£0.009 → 1p", 0.009, 1},
{"£0.014999 → 1p (just below 1.5p)", 0.014999, 1},
{"£0.015 → 2p (at 1.5p threshold)", 0.015, 2},
{"£0.01 → 1p", 0.01, 1},
{"£0.99 → 99p", 0.99, 99},
{"£0.999 → 100p", 0.999, 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pence := int64(math.Round(tt.amount * 100))
if pence != tt.want {
t.Errorf("math.Round(%.6f*100) = %d, want %d", tt.amount, pence, tt.want)
}
})
}
}
// ============================================================================
// Section 11: ValidateAmount — Additional Edge Cases
// ============================================================================
// TestValidateAmount_Precision verifies that ValidateAmount correctly handles
// precision-related edge cases.
func TestValidateAmount_Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amount int64
wantOK bool
}{
{"1p — minimum valid", 1, true},
{"£10,000 — maximum valid", 1_000_000, true},
{"£10,000.01 — just over max", 1_000_001, false},
{"£0 — rejected", 0, false},
{"-1p — rejected", -1, false},
{"£5,000 — valid", 500_000, true},
{"£9,999.99 — valid", 999_999, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAmount(tt.amount)
if tt.wantOK && err != nil {
t.Errorf("expected OK, got error: %v", err)
}
if !tt.wantOK && err == nil {
t.Errorf("expected error for amount %d, got nil", tt.amount)
}
})
}
}
// ============================================================================
// Section 12: ValidatePartialAmount — Additional Edge Cases
// ============================================================================
// TestValidatePartialAmount_EdgeCasesExtended verifies additional edge cases
// for ValidatePartialAmount.
func TestValidatePartialAmount_EdgeCasesExtended(t *testing.T) {
t.Parallel()
tests := []struct {
name string
amountPence int64
remainingPence int64
wantOK bool
}{
{"1p partial on £10 remaining — OK", 1, 1000, true},
{"£9.99 partial on £10 remaining — OK", 999, 1000, true},
{"£10 partial on £10 remaining — OK (equals)", 1000, 1000, true},
{"£10.01 partial on £10 remaining — rejected (exceeds)", 1001, 1000, false},
{"£0 partial on £10 remaining — rejected (zero)", 0, 1000, false},
{"-1p partial on £10 remaining — rejected (negative)", -1, 1000, false},
{"£0 on £0 remaining — rejected", 0, 0, false},
{"£1 on £0 remaining — rejected (exceeds)", 1, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePartialAmount(tt.amountPence, tt.remainingPence)
if tt.wantOK && err != nil {
t.Errorf("expected OK, got error: %v", err)
}
if !tt.wantOK && err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
// ============================================================================
// Section 13: penceLess — Additional Edge Cases
// ============================================================================
// TestPenceLess_ExtendedBoundaries verifies penceLess with additional
// boundary values including float64 precision pitfalls.
func TestPenceLess_ExtendedBoundaries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
a, b float64
less bool
}{
{"1.009 < 1.01 → false (both 1.01 in pence)", 1.009, 1.01, false},
{"1.009 < 1.011 → false (both 1.01 in pence)", 1.009, 1.011, false},
{"1.009 < 1.021 → true (1.01 vs 1.02 in pence)", 1.009, 1.021, true},
{"1.005 < 1.01 → true (1.00 vs 1.01 in pence due to float64)", 1.005, 1.01, true},
{"1.005001 < 1.01 → false (1.01 vs 1.01 — equal)", 1.005001, 1.01, false},
{"£1000 < £2000", 1000.00, 2000.00, true},
{"£9999.99 < £10000.00", 9999.99, 10000.00, true},
{"0.0001 < 0.001 → true (0p vs 0p — equal)", 0.0001, 0.001, false},
{"0.004 < 0.005 → true (0p vs 1p)", 0.004, 0.005, true},
{"0.0049 < 0.0051 → true (0p vs 1p)", 0.0049, 0.0051, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := penceLess(tt.a, tt.b)
if got != tt.less {
t.Errorf("penceLess(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.less)
}
})
}
}
// ============================================================================
// Section 14: Float64 Pence Conversion Round-Trip
// ============================================================================
// TestFloat64PenceRoundTrip verifies that converting between pounds and pence
// is lossless for all common money amounts.
func TestFloat64PenceRoundTrip(t *testing.T) {
t.Parallel()
for pence := int64(1); pence <= 1000; pence++ {
pounds := float64(pence) / 100.0
backPence := int64(math.Round(pounds * 100))
if backPence != pence {
t.Errorf("round-trip failed at %d pence: → %.4f → %d", pence, pounds, backPence)
break
}
}
for pounds := int64(10); pounds <= 1000; pounds += 10 {
pence := pounds * 100
poundsFloat := float64(pence) / 100.0
backPence := int64(math.Round(poundsFloat * 100))
if backPence != pence {
t.Errorf("round-trip failed at %d pence: → %.2f → %d", pence, poundsFloat, backPence)
}
}
}
// TestFloat64PenceConversion_OddAmounts verifies that odd amounts
// (with fractional pence) round-trip correctly.
func TestFloat64PenceConversion_OddAmounts(t *testing.T) {
t.Parallel()
amounts := []float64{
0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09,
0.10, 0.25, 0.50, 0.75, 0.99,
1.00, 1.01, 1.99, 12.34, 45.67, 89.01,
100.00, 250.00, 500.00, 999.99, 1000.00,
1234.56, 5678.90, 9999.99,
}
for _, amt := range amounts {
pence := int64(math.Round(amt * 100))
backToPounds := float64(pence) / 100.0
diff := math.Abs(backToPounds - amt)
if diff > 0.001 {
t.Errorf("round-trip for £%.2f: pence=%d, back=%.2f, diff=%.6f", amt, pence, backToPounds, diff)
}
}
}
// ============================================================================
// Section 15: Float64 Accumulation Drift in Payment Summary
// ============================================================================
// TestFloat64Accumulation_PaymentSummary verifies that accumulating many
// payment amounts does not produce significant drift.
func TestFloat64Accumulation_PaymentSummary(t *testing.T) {
t.Parallel()
payments := []float64{12.34, 45.67, 89.01, 23.45, 67.89,
34.56, 78.90, 12.09, 56.78, 90.12}
var sum float64
for _, p := range payments {
sum += p
}
sum = math.Round(sum*100) / 100
expected := 510.81
if math.Abs(sum-expected) > 0.005 {
t.Errorf("payment sum = %.2f, want %.2f", sum, expected)
}
var sumReverse float64
for i := len(payments) - 1; i >= 0; i-- {
sumReverse += payments[i]
}
sumReverse = math.Round(sumReverse*100) / 100
if sum != sumReverse {
t.Errorf("forward sum %.2f != reverse sum %.2f — float64 non-associativity detected", sum, sumReverse)
}
}
func TestFloat64Accumulation_ManySmallPayments(t *testing.T) {
t.Parallel()
var sum float64
for i := 0; i < 10000; i++ {
sum += 0.01
}
sum = math.Round(sum*100) / 100
if sum != 100.00 {
t.Errorf("accumulated 10000×£0.01 = %.2f, want 100.00", sum)
}
sum = 0
for i := 0; i < 1000; i++ {
sum += 0.29
}
sum = math.Round(sum*100) / 100
if sum != 290.00 {
t.Errorf("accumulated 1000×£0.29 = %.2f, want 290.00", sum)
}
}
// ============================================================================
// Section 16: Float64 Precision in Discount Calculations
// ============================================================================
// TestDiscountAmount_Float64Precision verifies that discount amount
// calculations (bookingTotal * percent / 100) are precise.
func TestDiscountAmount_Float64Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
total float64
percent float64
wantAmount float64
}{
{"£100 at 10% = £10", 100, 10, 10.00},
{"£50 at 20% = £10", 50, 20, 10.00},
{"£33.33 at 10% = £3.33", 33.33, 10, 3.33},
{"£99.99 at 5% = £5.00", 99.99, 5, 5.00},
{"£12.34 at 15% = £1.85", 12.34, 15, 1.85},
{"£0.01 at 10% = £0.00", 0.01, 10, 0.00},
{"£9999.99 at 10% = £1000.00", 9999.99, 10, 1000.00},
{"£100 at 0% = £0", 100, 0, 0.00},
{"£100 at 100% = £100", 100, 100, 100.00},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
amount := roundTo2(tt.total * tt.percent / 100)
if math.Abs(amount-tt.wantAmount) > 0.005 {
t.Errorf("discount amount = %.2f, want %.2f", amount, tt.wantAmount)
}
})
}
}
// TestDiscountAmount_Stacking verifies that stacking multiple discounts
// does not exceed the booking total.
func TestDiscountAmount_Stacking(t *testing.T) {
t.Parallel()
total := 100.00
discounts := []float64{10.0, 5.0, 20.0}
var totalDiscount float64
for _, pct := range discounts {
amount := roundTo2(total * pct / 100)
totalDiscount += amount
}
totalDiscount = roundTo2(totalDiscount)
if math.Abs(totalDiscount-35.00) > 0.005 {
t.Errorf("total discount = %.2f, want 35.00", totalDiscount)
}
if totalDiscount > total {
t.Errorf("total discount %.2f exceeds booking total %.2f", totalDiscount, total)
}
}
// ============================================================================
// Section 17: Float64 Precision in Tip Calculations
// ============================================================================
// TestTipAmount_Float64Precision verifies that tip calculations are precise.
func TestTipAmount_Float64Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
charge float64
bookingAmt float64
paid float64
wantTip float64
}{
{"£60 charge on £50 booking = £10 tip", 60, 50, 0, 10.00},
{"£55 charge on £50 booking = £5 tip", 55, 50, 0, 5.00},
{"£50 charge on £50 booking = £0 tip", 50, 50, 0, 0.00},
{"£25 charge on £50 with £25 paid = £0 tip", 25, 50, 25, 0.00},
{"£30 charge on £50 with £25 paid = £5 tip", 30, 50, 25, 5.00},
{"£0.01 charge on £50 booking = £0 tip", 0.01, 50, 0, 0.00},
{"£50.01 charge on £50 booking = £0.01 tip", 50.01, 50, 0, 0.01},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
remaining := math.Max(0, tt.bookingAmt-tt.paid)
bookingPortion := math.Min(tt.charge, remaining)
tipPortion := math.Round((tt.charge - bookingPortion) * 100) / 100
if math.Abs(tipPortion-tt.wantTip) > 0.005 {
t.Errorf("tip = %.2f, want %.2f", tipPortion, tt.wantTip)
}
total := math.Round((bookingPortion+tipPortion)*100) / 100
expected := math.Round(tt.charge*100) / 100
if math.Abs(total-expected) > 0.005 {
t.Errorf("bookingPortion(%.2f)+tipPortion(%.2f)=%.2f != charge(%.2f)",
bookingPortion, tipPortion, total, expected)
}
})
}
}
// ============================================================================
// Section 18: Float64 Precision in Deposit Calculations
// ============================================================================
// TestDepositAmount_Float64Precision verifies that deposit calculations
// (up to 50% of total minus already paid) are precise.
func TestDepositAmount_Float64Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
total float64
paid float64
charge float64
wantDeposit float64
}{
{"£50 total, £0 paid, charge £25 → deposit £25", 50, 0, 25, 25.00},
{"£50 total, £0 paid, charge £50 → deposit £25", 50, 0, 50, 25.00},
{"£100 total, £0 paid, charge £30 → deposit £30", 100, 0, 30, 30.00},
{"£100 total, £30 paid, charge £30 → deposit £20", 100, 30, 30, 20.00},
{"£100 total, £60 paid, charge £30 → deposit £0", 100, 60, 30, 0.00},
{"£33.33 total, £0 paid, charge £16.66 → deposit £16.66", 33.33, 0, 16.66, 16.66},
{"£25.50 total, £0 paid, charge £12.75 → deposit £12.75", 25.50, 0, 12.75, 12.75},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
maxDeposit := tt.total * ProtectedDepositMaxPct
remainingDepositRoom := math.Max(0, maxDeposit-tt.paid)
depositAmount := math.Min(tt.charge, remainingDepositRoom)
depositAmount = math.Round(depositAmount*100) / 100
if math.Abs(depositAmount-tt.wantDeposit) > 0.005 {
t.Errorf("deposit = %.2f, want %.2f", depositAmount, tt.wantDeposit)
}
if depositAmount > maxDeposit+0.005 {
t.Errorf("deposit %.2f exceeds max deposit %.2f", depositAmount, maxDeposit)
}
})
}
}
// ============================================================================
// Section 19: Float64 Precision in Balance Calculations
// ============================================================================
// TestBalanceCalculation_Float64Precision verifies that remaining balance
// calculations (total - paid + refunded) are precise.
func TestBalanceCalculation_Float64Precision(t *testing.T) {
t.Parallel()
tests := []struct {
name string
total float64
paid float64
refunded float64
want float64
}{
{"£100 total, £50 paid, £0 refunded → £50 remaining", 100, 50, 0, 50.00},
{"£100 total, £100 paid, £0 refunded → £0 remaining", 100, 100, 0, 0.00},
{"£100 total, £50 paid, £25 refunded → £75 remaining", 100, 50, 25, 75.00},
{"£100 total, £0 paid, £0 refunded → £100 remaining", 100, 0, 0, 100.00},
{"£100 total, £120 paid, £0 refunded → £0 remaining (clamped)", 100, 120, 0, 0.00},
{"£100 total, £50 paid, £60 refunded → £100 remaining (capped)", 100, 50, 60, 100.00},
{"£0 total, £0 paid, £0 refunded → £0 remaining", 0, 0, 0, 0.00},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
remaining := math.Max(0, math.Min(tt.total, tt.total-tt.paid+tt.refunded))
remaining = math.Round(remaining*100) / 100
if math.Abs(remaining-tt.want) > 0.005 {
t.Errorf("remaining = %.2f, want %.2f", remaining, tt.want)
}
})
}
}