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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user