test: payment coverage — gift card daily caps, buildSplitRecords, ValidateAmount edge cases, SquareRefundStatusToLocal

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 4d179385e8
commit 509b2d926d
4 changed files with 849 additions and 0 deletions
@@ -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")
}
}
@@ -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
}
@@ -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)
})
}
}
@@ -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)
}
}