diff --git a/backend/handlers/payments/errors_extra_test.go b/backend/handlers/payments/errors_extra_test.go new file mode 100644 index 0000000..f82f8f0 --- /dev/null +++ b/backend/handlers/payments/errors_extra_test.go @@ -0,0 +1,74 @@ +//go:build test && dev + +package payments + +import ( + "errors" + "testing" +) + +// TestSquareRefundStatusToLocal_Table covers the full Square refund status +// space: COMPLETED/APPROVED → 'completed' terminal, FAILED/REJECTED → 'failed' +// terminal, and all other statuses (PENDING, CANCELED, unknown, empty, +// lowercase) → non-terminal with empty local status. +func TestSquareRefundStatusToLocal_Table(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + wantStatus string + wantTerminal bool + }{ + {"COMPLETED", "completed", true}, + {"APPROVED", "completed", true}, + {"FAILED", "failed", true}, + {"REJECTED", "failed", true}, + {"PENDING", "", false}, + {"CANCELED", "", false}, + {"", "", false}, + {"UNKNOWN", "", false}, + {"completed", "", false}, + {"failed", "", false}, + {"approved", "", false}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + status, terminal := SquareRefundStatusToLocal(tt.input) + if status != tt.wantStatus { + t.Errorf("SquareRefundStatusToLocal(%q) status = %q, want %q", tt.input, status, tt.wantStatus) + } + if terminal != tt.wantTerminal { + t.Errorf("SquareRefundStatusToLocal(%q) terminal = %v, want %v", tt.input, terminal, tt.wantTerminal) + } + }) + } +} + +// TestRoundingEpsilon_AtBoundary verifies roundingEpsilon is within the +// expected range (0.004 ± 0.001) so the money-safety zero threshold is stable. +func TestRoundingEpsilon_AtBoundary(t *testing.T) { + t.Parallel() + + if roundingEpsilon < 0.003 || roundingEpsilon > 0.005 { + t.Errorf("roundingEpsilon = %.6f, expected ~0.004", roundingEpsilon) + } +} + +// TestIsVerificationRequiredError_NilError safely handles nil input. +func TestIsVerificationRequiredError_NilError(t *testing.T) { + t.Parallel() + + if isVerificationRequiredError(nil) { + t.Error("nil error must not be verification required") + } +} + +// TestIsVerificationRequiredError_PlainError safely handles a non-structured +// error with no Square error code. +func TestIsVerificationRequiredError_PlainError(t *testing.T) { + t.Parallel() + + if isVerificationRequiredError(errors.New("network timeout")) { + t.Error("a plain network error must not be verification required") + } +} \ No newline at end of file diff --git a/backend/handlers/payments/giftcard_limits_test.go b/backend/handlers/payments/giftcard_limits_test.go new file mode 100644 index 0000000..f707c57 --- /dev/null +++ b/backend/handlers/payments/giftcard_limits_test.go @@ -0,0 +1,380 @@ +//go:build test && dev + +package payments + +import ( + "context" + "testing" + "time" + + "crussell/clock" + "crussell/db" + "crussell/testutils" + "crussell/testutils/fixtures" + + "github.com/stretchr/testify/require" +) + +// =========================================================================== +// userGiftCardSpentToday +// =========================================================================== + +func TestUserGiftCardSpentToday_ZeroRows(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.Equal(t, float64(0), spent, "expected 0 when no gift_card_transactions exist") +} + +func TestUserGiftCardSpentToday_SinglePurchase(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // Create a purchase row with today's date + gcID := insertGiftCard(ctx, t, tx, nil) + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 100.00) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.InDelta(t, 100.00, spent, 0.005, "expected £100.00 from single purchase") +} + +func TestUserGiftCardSpentToday_MultiplePurchases(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + gcID := insertGiftCard(ctx, t, tx, nil) + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 100.00) + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 200.00) + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 50.00) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.InDelta(t, 350.00, spent, 0.005, "expected £350.00 from 3 purchases") +} + +func TestUserGiftCardSpentToday_CapBoundary_Exactly(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // maxUserGiftCardDailyPence = 500_00 → £500.00 + capPounds := float64(maxUserGiftCardDailyPence) / 100.0 + gcID := insertGiftCard(ctx, t, tx, nil) + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", capPounds) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.InDelta(t, capPounds, spent, 0.005, "expected exactly the cap at £%.2f", capPounds) +} + +func TestUserGiftCardSpentToday_OnePennyOverCap(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + // One penny over the daily cap + capPounds := float64(maxUserGiftCardDailyPence) / 100.0 + over := capPounds + 0.01 + gcID := insertGiftCard(ctx, t, tx, nil) + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", over) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.InDelta(t, over, spent, 0.005, "expected £%.2f (one penny over cap)", over) + require.True(t, spent > capPounds, "expected spent %.2f to exceed cap %.2f", spent, capPounds) +} + +func TestUserGiftCardSpentToday_FiltersTransactionType(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + gcID := insertGiftCard(ctx, t, tx, nil) + // Only 'purchase' should be counted — insert a 'topup' row that must be excluded + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "topup", "api", 500.00) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.Equal(t, float64(0), spent, "expected 0 — 'topup' rows are not purchases") +} + +func TestUserGiftCardSpentToday_FiltersReferenceType(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + gcID := insertGiftCard(ctx, t, tx, nil) + // Only 'api' reference type should be counted — 'till_sale' must be excluded + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "till_sale", 200.00) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.Equal(t, float64(0), spent, "expected 0 — 'till_sale' reference_type is excluded") +} + +func TestUserGiftCardSpentToday_DifferentUserNotCounted(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + otherID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + gcID := insertGiftCard(ctx, t, tx, nil) + // Other user's purchase should not count toward this user + insertGiftCardTransaction(ctx, t, tx, gcID, userID, "purchase", "api", 100.00) + insertGiftCardTransaction(ctx, t, tx, gcID, otherID, "purchase", "api", 300.00) + + spent, err := userGiftCardSpentToday(ctx, tx, userID) + require.NoError(t, err) + require.InDelta(t, 100.00, spent, 0.005, "expected only this user's purchases") +} + +// =========================================================================== +// adminGiftCardValueToday +// =========================================================================== + +func TestAdminGiftCardValueToday_ZeroRows(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.Equal(t, float64(0), value, "expected 0 when no gift cards or transactions exist") +} + +func TestAdminGiftCardValueToday_CardsCreatedToday(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + // Term 1: cards created today by this admin + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 200.00}) + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 300.00}) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, 500.00, value, 0.005, "expected £500 from two cards created today") +} + +func TestAdminGiftCardValueToday_TopUpOnPreExistingCard(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + // Card created YESTERDAY (created_at = -48h) so it's NOT counted in term 1 + gcID := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour)) + + // Topup today on that pre-existing card — counted in term 2 + insertGiftCardTransaction(ctx, t, tx, gcID, adminID, "topup", "api", 50.00) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, 50.00, value, 0.005, "expected £50 from topup on pre-existing card") +} + +func TestAdminGiftCardValueToday_TillSaleOnPreExistingCard(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + // Card created YESTERDAY so it's not counted in term 1 + gcID := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour)) + + // Till sale today on that pre-existing card — counted in term 3 + insertTillSale(ctx, t, tx, adminID, gcID, 75.00, "completed") + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, 75.00, value, 0.005, "expected £75 from till sale on pre-existing card") +} + +func TestAdminGiftCardValueToday_CombinedSources(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + // Term 1: card created today by admin + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 200.00}) + + // Pre-existing card (created yesterday) + topup today (term 2) + gcPre := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour)) + insertGiftCardTransaction(ctx, t, tx, gcPre, adminID, "topup", "api", 50.00) + + // Another pre-existing card + till sale today (term 3) + gcPre2 := insertGiftCardAt(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}, clock.Now().Add(-48*time.Hour)) + insertTillSale(ctx, t, tx, adminID, gcPre2, 25.00, "completed") + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, 275.00, value, 0.005, "expected £275 from all three sources combined") +} + +func TestAdminGiftCardValueToday_DoubleCountPrevention(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + // Card created today with total_funds_added=200. This is counted in term 1. + // A topup AND till sale on that SAME card (created today) must NOT also count + // in terms 2 and 3 (the NOT IN subqueries exclude today-created cards). + gcID := insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 200.00}) + insertGiftCardTransaction(ctx, t, tx, gcID, adminID, "topup", "api", 50.00) + insertTillSale(ctx, t, tx, adminID, gcID, 25.00, "completed") + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + // Only term 1 should count: the 200 from total_funds_added. + // The topup and till_sale are on today's card, so they're double-count protected. + require.NoError(t, err) + require.InDelta(t, 200.00, value, 0.005, "expected only £200 from term 1; topup and sale must be excluded") +} + +func TestAdminGiftCardValueToday_CapBoundary(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + // maxAdminGiftCardDailyPence = 500_000 → £5,000 + capPounds := float64(maxAdminGiftCardDailyPence) / 100.0 + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: capPounds}) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, capPounds, value, 0.005, "expected exactly the admin cap at £%.2f", capPounds) +} + +func TestAdminGiftCardValueToday_OnePennyOverCap(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + capPounds := float64(maxAdminGiftCardDailyPence) / 100.0 + over := capPounds + 0.01 + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: over}) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, over, value, 0.005, "expected £%.2f (one penny over cap)", over) + require.True(t, value > capPounds, "expected value %.2f to exceed cap %.2f", value, capPounds) +} + +func TestAdminGiftCardValueToday_IgnoresOtherAdmins(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + otherAdmin, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: adminID, totalFunds: 100.00}) + insertGiftCard(ctx, t, tx, &giftCardOptions{createdBy: otherAdmin, totalFunds: 500.00}) + + value, err := adminGiftCardValueToday(ctx, tx, adminID) + require.NoError(t, err) + require.InDelta(t, 100.00, value, 0.005, "expected only this admin's card value") +} + +// =========================================================================== +// Test helpers — build test data with minimal column requirements +// =========================================================================== + +type giftCardOptions struct { + createdBy string + totalFunds float64 +} + +func insertGiftCard(ctx context.Context, t *testing.T, q db.Querier, opts *giftCardOptions) string { + t.Helper() + return insertGiftCardAt(ctx, t, q, opts, clock.Now()) +} + +func insertGiftCardAt(ctx context.Context, t *testing.T, q db.Querier, opts *giftCardOptions, createdAt time.Time) string { + t.Helper() + if opts == nil { + opts = &giftCardOptions{} + } + // created_by is nullable, pass nil when empty + if opts.createdBy != "" { + var id string + err := q.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, created_at) + VALUES ($1, $1, $2, $3) + RETURNING id + `, opts.totalFunds, opts.createdBy, createdAt).Scan(&id) + require.NoError(t, err) + return id + } + var id string + err := q.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_at) + VALUES ($1, $1, $2) + RETURNING id + `, opts.totalFunds, createdAt).Scan(&id) + require.NoError(t, err) + return id +} + +func insertGiftCardTransaction(ctx context.Context, t *testing.T, q db.Querier, giftCardID, userID, txType, refType string, amount float64) string { + t.Helper() + var id string + err := q.QueryRow(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, user_id, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + RETURNING id + `, giftCardID, txType, amount, refType, userID).Scan(&id) + require.NoError(t, err) + return id +} + +func insertTillSale(ctx context.Context, t *testing.T, q db.Querier, adminID, itemID string, totalAmount float64, status string) string { + t.Helper() + var id string + err := q.QueryRow(ctx, ` + INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) + VALUES ('gift_card', $1, 'test till sale', 1, $2, $2, 'online_square', $3, $4, NOW(), NOW()) + RETURNING id + `, itemID, totalAmount, status, adminID).Scan(&id) + require.NoError(t, err) + return id +} \ No newline at end of file diff --git a/backend/handlers/payments/service_split_records_test.go b/backend/handlers/payments/service_split_records_test.go new file mode 100644 index 0000000..488b650 --- /dev/null +++ b/backend/handlers/payments/service_split_records_test.go @@ -0,0 +1,274 @@ +//go:build test && dev + +package payments + +import ( + "math" + "testing" + "time" + + "crussell/clock" + + "github.com/stretchr/testify/require" +) + +// =========================================================================== +// buildSplitRecords — additional coverage +// =========================================================================== + +// TestBuildSplitRecords_TipOnly_PostStart verifies that when a booking is +// already fully paid and a post-start charge is made, the entire amount becomes +// a tip record. +func TestBuildSplitRecords_TipOnly_PostStart(t *testing.T) { + t.Parallel() + + // Booking total £50, already fully paid (£50). Post-start charge of £10 + // → remaining = 0, bookingPortion = min(10, 0) = 0, tipPortion = 10. + record := makeTestRecord("tip-only-post", "full", 10) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-1 * time.Hour), // past start + TotalAmount: 50, + TotalPaid: 50, + } + records, err := buildSplitRecords(record, "full", info, 10) + require.NoError(t, err) + + // Post-start path always appends the primary record even when + // bookingPortion=0 (the primary carries the Square payment ID for + // refund routing). So we get 2 records: primary (bookingPortion=0) + // + the tip record carrying the full amount. + require.Equal(t, 2, len(records), "expected 2 records (primary + tip)") + for _, r := range records { + if r.PaymentType == "tip" { + require.InDelta(t, 10.00, r.Amount, 0.005, "tip amount must be £10") + require.Equal(t, float64(0), r.Fees, "tip fees must be zero") + } + } + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + require.InDelta(t, 10.00, sum, 0.005, "records must partition the charge exactly") +} + +// TestBuildSplitRecords_PostStart_NoOverflow verifies that a post-start charge +// exactly equal to the remaining balance produces a single record with the +// original payment type — no tip carve. +func TestBuildSplitRecords_PostStart_NoOverflow(t *testing.T) { + t.Parallel() + + record := makeTestRecord("post-no-overflow", "full", 30) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(-1 * time.Hour), + TotalAmount: 50, + TotalPaid: 20, + } + records, err := buildSplitRecords(record, "full", info, 30) + require.NoError(t, err) + + require.Equal(t, 1, len(records), "expected 1 record (no tip carve)") + require.Equal(t, "full", records[0].PaymentType, "expected original payment type") + require.InDelta(t, 30.00, records[0].Amount, 0.005) +} + +// TestBuildSplitRecords_PreStart_DepositRoomExhausted verifies that when the +// paid amount already covers the 50% protected deposit maximum, no deposit +// record is produced and the remaining charge goes to balance. +func TestBuildSplitRecords_PreStart_DepositRoomExhausted(t *testing.T) { + t.Parallel() + + // Booking total £100, already paid £50 (which is exactly 50% = deposit + // max). A charge of £50 more → deposit room = 0, all £50 → balance. + record := makeTestRecord("dep-exhausted", "full", 50) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 50, + } + records, err := buildSplitRecords(record, "full", info, 50) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(records), 1) + // The first (and only non-tip) record should be a balance/full type + var foundDeposit bool + for _, r := range records { + if r.PaymentType == "deposit" { + foundDeposit = true + } + } + require.False(t, foundDeposit, "no deposit record expected when deposit room is 0") + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + require.InDelta(t, 50.00, sum, 0.005, "records must partition the charge exactly") +} + +// TestBuildSplitRecords_PreStart_ExactlyToDepositMax verifies a pre-start +// charge that exactly fills the deposit room without overflow produces a +// single deposit record. +func TestBuildSplitRecords_PreStart_ExactlyToDepositMax(t *testing.T) { + t.Parallel() + + // Booking total £100, paid £0. maxDeposit = 50% = £50. + // charge £50 → depositAmount = min(50, 50) = 50, remaining = 0. + record := makeTestRecord("exact-dep", "full", 50) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 0, + } + records, err := buildSplitRecords(record, "full", info, 50) + require.NoError(t, err) + + require.Equal(t, 1, len(records), "expected 1 record (deposit only)") + require.Equal(t, "deposit", records[0].PaymentType, "expected deposit type") + require.InDelta(t, 50.00, records[0].Amount, 0.005) +} + +// TestBuildSplitRecords_PartialDeposit verifies a pre-start charge that is +// LESS than the deposit room produces a single deposit record. +func TestBuildSplitRecords_PartialDeposit(t *testing.T) { + t.Parallel() + + // Booking total £100, paid £0. maxDeposit = £50, charge = £20. + // depositAmount = min(20, 50) = 20, remainingAfterDeposit = 0. + record := makeTestRecord("partial-dep", "full", 20) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 0, + } + records, err := buildSplitRecords(record, "full", info, 20) + require.NoError(t, err) + + require.Equal(t, 1, len(records), "expected 1 record (deposit only)") + require.Equal(t, "deposit", records[0].PaymentType) + require.InDelta(t, 20.00, records[0].Amount, 0.005) +} + +// TestBuildSplitRecords_TipOnly_PreStart_AlreadyPaid verifies a pre-start +// charge on a booking that is ALREADY fully paid — the entire charge becomes +// a tip. The tip record alone carries the whole amount (no deposit/balance +// since totalPaid already ≥ maxDeposit and totalPaid ≥ totalAmount). +func TestBuildSplitRecords_TipOnly_PreStart_AlreadyPaid(t *testing.T) { + t.Parallel() + + record := makeTestRecord("pre-tip-only", "full", 15) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 50, + TotalPaid: 50, + } + records, err := buildSplitRecords(record, "full", info, 15) + require.NoError(t, err) + + require.Equal(t, 1, len(records), "expected 1 record (tip only)") + require.Equal(t, "tip", records[0].PaymentType) + require.InDelta(t, 15.00, records[0].Amount, 0.005) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + require.InDelta(t, 15.00, sum, 0.005) +} + +// TestBuildSplitRecords_PreStart_DepositBalanceTip verifies a full split into +// deposit + balance + tip: charge exceeds the booking total so the overflow +// becomes a tip. +func TestBuildSplitRecords_PreStart_DepositBalanceTip(t *testing.T) { + t.Parallel() + + // Booking total £100, paid £0. maxDeposit = 50. + // Charge £120 → depositAmount = min(120, 50) = 50 + // remainingAfterDeposit = 70 + // bookingRemaining = max(0, 100-0-50) = 50 + // balancePortion = min(70, 50) = 50 → "full" (totalPaidAfterBalance = 0+50+50 = 100 ≥ 100) + // tipPortion = 70 - 50 = 20 + record := makeTestRecord("full-split-w-tip", "full", 120) + info := &BookingPaymentInfo{ + StartTime: clock.Now().Add(48 * time.Hour), + TotalAmount: 100, + TotalPaid: 0, + } + records, err := buildSplitRecords(record, "full", info, 120) + require.NoError(t, err) + + require.Equal(t, 3, len(records), "expected 3 records: deposit + balance + tip") + + // Check types + require.Equal(t, "deposit", records[0].PaymentType) + // balancePortion fills to totalPaidAfterBalance=100≥total=100, and since + // totalPaidAfterBalance-balancePortion=50>0 (the deposit was a previous + // payment), the type is "balance" not "full". + require.Equal(t, "balance", records[1].PaymentType) + require.Equal(t, "tip", records[2].PaymentType) + + // Check amounts + require.InDelta(t, 50.00, records[0].Amount, 0.005) + require.InDelta(t, 50.00, records[1].Amount, 0.005) + require.InDelta(t, 20.00, records[2].Amount, 0.005) + + var sum float64 + for _, r := range records { + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + require.InDelta(t, 120.00, sum, 0.005) + + // Idempotency keys should be derived + require.NotEqual(t, record.IdempotencyKey, records[1].IdempotencyKey) + require.NotEqual(t, record.IdempotencyKey, records[2].IdempotencyKey) +} + +// TestBuildSplitRecords_Rounding_SumEqualsCharge runs many random-ish +// combinations and verifies the sum invariant always holds. +func TestBuildSplitRecords_Rounding_SumEqualsCharge(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + total float64 + paid float64 + charge float64 + startTime time.Time + } + tests := []testCase{ + {"pre-start: odd amounts", 33.33, 0, 33.33, clock.Now().Add(48 * time.Hour)}, + {"pre-start: recurring decimal total", 100.00 / 3.0, 0, 33.34, clock.Now().Add(48 * time.Hour)}, + {"pre-start: small charge on large booking", 500.00, 0, 1.50, clock.Now().Add(48 * time.Hour)}, + {"pre-start: large charge near total", 200.00, 50.00, 149.99, clock.Now().Add(48 * time.Hour)}, + {"pre-start: tip overflow with odd amounts", 100.00, 50.00, 75.01, clock.Now().Add(48 * time.Hour)}, + {"post-start: tip overflow", 50.00, 30.00, 30.00, clock.Now().Add(-1 * time.Hour)}, + {"post-start: fractional remaining", 100.00, 33.33, 66.67, clock.Now().Add(-1 * time.Hour)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := makeTestRecord("rounding-"+tt.name, "full", tt.charge) + info := &BookingPaymentInfo{ + StartTime: tt.startTime, + 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 { + pence := math.Round(r.Amount * 100) + require.InDelta(t, r.Amount*100, pence, 0.001, + "record %.4f is not rounded to 2 decimal places", r.Amount) + sum += r.Amount + } + sum = math.Round(sum*100) / 100 + require.InDelta(t, tt.charge, sum, 0.005, + "split sum %.4f != charged amount %.4f", sum, tt.charge) + }) + } +} \ No newline at end of file diff --git a/backend/handlers/payments/validators_extra_test.go b/backend/handlers/payments/validators_extra_test.go new file mode 100644 index 0000000..f07c458 --- /dev/null +++ b/backend/handlers/payments/validators_extra_test.go @@ -0,0 +1,121 @@ +//go:build test && dev + +package payments + +import ( + "fmt" + "testing" +) + +// TestValidateAmount_ExtraEdgeCases extends the existing ValidateAmount coverage +// with additional boundary and edge cases for the money-validation function. +func TestValidateAmount_ExtraEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + amount int64 + wantErr bool + desc string + }{ + // Zero and negative — must be rejected + {0, true, "zero is not allowed"}, + {-1, true, "negative one penny"}, + {-100, true, "negative £1"}, + {-1000000, true, "negative £10,000 (must be rejected early, not overflow)"}, + + // Normal positive amounts — must pass + {1, false, "minimum valid: 1 penny"}, + {50, false, "50p"}, + {100, false, "£1"}, + {5000, false, "£50"}, + {50000, false, "£500"}, + {100000, false, "£1,000"}, + {500000, false, "£5,000"}, + {999999, false, "£9,999.99 — just under max"}, + {1000000, false, "£10,000 exactly — boundary allowed"}, + + // Above maximum — must be rejected + {1000001, true, "£10,000.01 — one penny over max"}, + {1000050, true, "£10,000.50 — 50p over max"}, + {2000000, true, "£20,000 — double max"}, + {99999999, true, "very large amount"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s (%d)", tt.desc, tt.amount), func(t *testing.T) { + err := ValidateAmount(tt.amount) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for amount %d (%s), got nil", tt.amount, tt.desc) + } + } else { + if err != nil { + t.Errorf("unexpected error for amount %d (%s): %v", tt.amount, tt.desc, err) + } + } + }) + } +} + +// TestValidateAmount_ErrorMessage locks the error messages for the two +// rejection paths so callers depending on string matching do not silently +// break. +func TestValidateAmount_ErrorMessage(t *testing.T) { + t.Parallel() + + if err := ValidateAmount(0); err == nil || err.Error() != "amount must be greater than 0" { + t.Errorf("zero amount error message mismatch: %v", err) + } + if err := ValidateAmount(1000001); err == nil || err.Error() != "amount exceeds maximum (£10,000)" { + t.Errorf("over-max error message mismatch: %v", err) + } +} + +// TestValidatePartialAmount_ErrorMessage locks the error message format. +func TestValidatePartialAmount_ErrorMessage(t *testing.T) { + t.Parallel() + + err := ValidatePartialAmount(5000, 2500) + if err == nil { + t.Fatal("expected error for partial exceeding remaining") + } + expected := "partial amount (£50.00) exceeds remaining balance (£25.00)" + if err.Error() != expected { + t.Errorf("error message mismatch:\n got: %s\n want: %s", err.Error(), expected) + } +} + +// TestValidatePaymentType_AllValidTypes verifies every entry in the +// validPaymentTypes map is accepted and unknown types are rejected. +func TestValidatePaymentType_AllValidTypes(t *testing.T) { + t.Parallel() + + valid := []string{"deposit", "full", "tip", "balance", "partial"} + for _, pt := range valid { + if err := ValidatePaymentType(pt); err != nil { + t.Errorf("expected valid payment type %q to be accepted, got: %v", pt, err) + } + } + + invalid := []string{"", "unknown", "deposits", "FULL", "gift_card"} + for _, pt := range invalid { + if err := ValidatePaymentType(pt); err == nil { + t.Errorf("expected invalid payment type %q to be rejected", pt) + } + } +} + +// TestValidateRefundReason_Empty verifies an empty refund reason is rejected. +func TestValidateRefundReason_Empty(t *testing.T) { + t.Parallel() + + if err := ValidateRefundReason(""); err == nil { + t.Error("expected an error for empty refund reason") + } + if err := ValidateRefundReason("customer changed mind"); err != nil { + t.Errorf("unexpected error for non-empty refund reason: %v", err) + } + if err := ValidateRefundReason(" "); err != nil { + t.Errorf("whitespace-only is arguably a reason — should not error: %v", err) + } +} \ No newline at end of file