Files
Crussell/backend/handlers/payments/refunds_test.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

1049 lines
34 KiB
Go

//go:build test && dev
// +build test,dev
package payments
import (
"context"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// =============================================================================
// CalculateRefundForCancellation - Pure function tests
// =============================================================================
func TestCalculateRefundForCancellation_FullRefund_Over72h(t *testing.T) {
t.Parallel()
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) // >72h away
result := CalculateRefundForCancellation(100, 50, now, start)
if result.Tier != "full_refund_72h" {
t.Errorf("expected tier 'full_refund_72h', got %q", result.Tier)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
if result.KeptAmount != 0 {
t.Errorf("expected kept 0, got %.2f", result.KeptAmount)
}
if result.ProtectedDeposit != 50 {
t.Errorf("expected protected deposit 50, got %.2f", result.ProtectedDeposit)
}
}
func TestCalculateRefundForCancellation_PartialRefund_24to72h(t *testing.T) {
now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC) // ~50h before
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result := CalculateRefundForCancellation(100, 80, now, start)
if result.Tier != "partial_refund_24h_72h" {
t.Errorf("expected tier 'partial_refund_24h_72h', got %q", result.Tier)
}
// Protected deposit: min(80, 50) = 50
// Refundable: 80 - 50 = 30
if result.ProtectedDeposit != 50 {
t.Errorf("expected protected deposit 50, got %.2f", result.ProtectedDeposit)
}
if result.RefundableAmount != 30 {
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
}
if result.KeptAmount != 50 {
t.Errorf("expected kept 50, got %.2f", result.KeptAmount)
}
}
func TestCalculateRefundForCancellation_NoRefund_Under24h(t *testing.T) {
now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC) // 1h before
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result := CalculateRefundForCancellation(100, 100, now, start)
if result.Tier != "no_refund_under_24h" {
t.Errorf("expected tier 'no_refund_under_24h', got %q", result.Tier)
}
if result.RefundableAmount != 0 {
t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount)
}
if result.KeptAmount != 100 {
t.Errorf("expected kept 100, got %.2f", result.KeptAmount)
}
}
func TestCalculateRefundForCancellation_NoShow_KeptAll(t *testing.T) {
now := time.Date(2099, 12, 31, 12, 0, 0, 0, time.UTC) // past start
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result := CalculateRefundForCancellation(100, 50, now, start)
if result.Tier != "no_refund_under_24h" {
t.Errorf("expected tier 'no_refund_under_24h', got %q", result.Tier)
}
if result.RefundableAmount != 0 {
t.Errorf("expected refundable 0 for no-show, got %.2f", result.RefundableAmount)
}
}
func TestCalculateRefundForCancellation_ProtectedDepositCappedAt50Pct(t *testing.T) {
now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
// Paid 200 on a 300 total — protected deposit caps at 150 (50% of 300)
result := CalculateRefundForCancellation(300, 200, now, start)
if result.ProtectedDeposit != 150 {
t.Errorf("expected protected deposit 150 (50%% of 300), got %.2f", result.ProtectedDeposit)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50 (200-150), got %.2f", result.RefundableAmount)
}
}
func TestCalculateRefundForCancellation_PaidLessThan50Pct(t *testing.T) {
now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
// Paid 30 on a 200 total — protected deposit = min(30, 100) = 30
result := CalculateRefundForCancellation(200, 30, now, start)
if result.ProtectedDeposit != 30 {
t.Errorf("expected protected deposit 30, got %.2f", result.ProtectedDeposit)
}
if result.RefundableAmount != 0 {
t.Errorf("expected refundable 0 (30-30), got %.2f", result.RefundableAmount)
}
}
func TestCalculateRefundForCancellation_Exact72hBoundary(t *testing.T) {
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
now := start.Add(-72 * time.Hour) // exactly 72h before (not >72)
result := CalculateRefundForCancellation(100, 100, now, start)
// Exactly 72h is NOT >72 — falls into partial refund tier
if result.Tier != "partial_refund_24h_72h" {
t.Errorf("expected partial refund at exactly 72h, got %q", result.Tier)
}
}
func TestCalculateRefundForCancellation_Exact24hBoundary(t *testing.T) {
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
now := start.Add(-24 * time.Hour) // exactly 24h before
result := CalculateRefundForCancellation(100, 100, now, start)
// Exactly 24h should be >=24 — partial refund
if result.Tier != "partial_refund_24h_72h" {
t.Errorf("expected partial refund at exactly 24h, got %q", result.Tier)
}
}
// =============================================================================
// ProcessCancellationRefund - Integration tests
// =============================================================================
func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx,
"UPDATE bookings SET deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set deposit_required: %v", err)
}
// Add a completed payment
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Cancel >72h before — full refund expected
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(ctx, bookingID, 50, 50, start, now, "client_cancelled", &userID)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Check refund record was created
var refundCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if refundCount != 1 {
t.Errorf("expected 1 refund record, got %d", refundCount)
}
}
func TestProcessCancellationRefund_NoRefundWhenNotNeeded(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Cancel <24h before — refundable should be 0
now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(ctx, bookingID, 100, 0, start, now, "no_show", &userID)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.RefundableAmount != 0 {
t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount)
}
}
func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(ctx, bookingID, 100, 0, start, now, "client_cancelled", &userID)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 0 {
t.Errorf("expected refundable 0 when nothing paid, got %.2f", result.RefundableAmount)
}
var refundCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if refundCount != 0 {
t.Errorf("expected 0 refund records, got %d", refundCount)
}
}
// =============================================================================
// ProcessCancellationRefund — gift card refund routing
// =============================================================================
func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
var giftCardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date, last_used_at)
VALUES (100, 40, $1, false, NULL, NOW())
RETURNING id
`, userID).Scan(&giftCardID); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
var paymentID string
if err := tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
VALUES ($1, 'full', 'giftcard', 'completed', 60, $2, NOW(), NOW())
RETURNING id
`, bookingID, giftCardID).Scan(&paymentID); err != nil {
t.Fatalf("failed to create giftcard payment: %v", err)
}
// Booking is far in the future — full refund.
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 60,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 60 {
t.Errorf("expected refundable 60 (full refund >72h), got %.2f", result.RefundableAmount)
}
var amountRemaining float64
err = tx.QueryRow(ctx,
"SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card balance: %v", err)
}
if amountRemaining != 100 {
t.Errorf("expected gift card amount_remaining 100 (40 + 60), got %.2f", amountRemaining)
}
// Verify refund record exists (primary audit trail for cancellation refunds).
var refundCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund record, got %d", refundCount)
}
var txCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount)
if err != nil {
t.Fatalf("failed to query gift card transactions: %v", err)
}
if txCount != 1 {
t.Errorf("expected 1 gift card refund transaction, got %d", txCount)
}
}
func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a cash payment of 30.
_, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 30,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 30 {
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
}
// Verify user balance was credited.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query balance: %v", err)
}
if balance != 30 {
t.Errorf("expected user balance 30, got %.2f", balance)
}
}
func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an online_square payment — this will be handled by Square mock.
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 100,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 100 {
t.Errorf("expected refundable 100, got %.2f", result.RefundableAmount)
}
// Square API refund is processed AFTER the transaction commits (see
// ProcessPendingSquareRefunds). In dev/test the payment has no
// square_payment_id, so the pending refund is marked "completed" without
// a Square API call. No balance credit is generated — the refund record
// existence is the authoritative record of the refund.
var status string
err = tx.QueryRow(ctx, "SELECT status FROM refunds WHERE booking_id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
// No balance credit should have been created (Square payment method uses
// post-commit refund processing, not balance credits).
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
// No row = no balance credit — this is the expected outcome.
// The refund was processed as a direct record, not a balance credit.
t.Logf("no balance row (expected): %v", err)
} else if balance > 0 {
t.Errorf("expected no balance credit for Square payment, got %.2f", balance)
}
}
// =============================================================================
// ProcessCancellationRefund — non-money payment methods (discount, on_the_house)
// =============================================================================
func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a discount payment (no real money exchanged).
_, err = fixtures.CreateTestPayment(tx, bookingID, 20, "discount", "partial", "completed")
if err != nil {
t.Fatalf("failed to create discount payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 20,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 20 {
t.Errorf("expected refundable 20 (full refund >72h), got %.2f", result.RefundableAmount)
}
// Discount payments should NOT create a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
balance = 0
}
if balance != 0 {
t.Errorf("expected no balance credit for discount payment, got %.2f", balance)
}
}
func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an on_the_house payment (no real money exchanged).
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "on_the_house", "full", "completed")
if err != nil {
t.Fatalf("failed to create on_the_house payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 100,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 100 {
t.Errorf("expected refundable 100 (full refund >72h), got %.2f", result.RefundableAmount)
}
// on_the_house payments should NOT create a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
balance = 0
}
if balance != 0 {
t.Errorf("expected no balance credit for on_the_house payment, got %.2f", balance)
}
}
// =============================================================================
// ProcessCancellationRefund — missing user_id edge case
// =============================================================================
func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a cash payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
// Set user_id to NULL on the booking to simulate a purged guest account.
_, err = tx.Exec(ctx, "UPDATE bookings SET user_id = NULL WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to nullify booking user_id: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 50,
farFuture, clock.Now(), "client_cancelled", nil,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Refund record should still be created even without user_id.
var refundCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund record (user_id-less), got %d", refundCount)
}
}
// =============================================================================
// ProcessCancellationRefund — guest users must NOT get balance credits
// =============================================================================
func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create a user and promote them to guest role.
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set guest role: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a gift card payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "giftcard", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create giftcard payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 50,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Guest must NOT have a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
// No row means balance is 0 — this is the expected outcome.
balance = 0
}
if balance != 0 {
t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance)
}
// Refund record should still exist.
var refundCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund record for guest, got %d", refundCount)
}
}
func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set guest role: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a cash payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 30,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 30 {
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
}
// Guest must NOT have a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
balance = 0
}
if balance != 0 {
t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance)
}
}
// =============================================================================
// Refund with split payments — verify dedup when 2 records share square_payment_id
// =============================================================================
func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *testing.T) {
// When a single Square charge is split into 2 DB payment records (deposit + balance)
// sharing the same square_payment_id, the refund loop must only call Square once.
// The second record should be credited to the user balance instead.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
sameSquareID := "sqp_split_dedup_test"
now := clock.Now()
// Create 2 payment records sharing the same square_payment_id — simulating a
// split charge where one Square payment was recorded as deposit + balance.
svc := NewPaymentService()
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "online_square",
Status: "completed",
Amount: 25.00,
SquarePaymentID: &sameSquareID,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create deposit record: %v", err)
}
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "balance",
PaymentMethod: "online_square",
Status: "completed",
Amount: 25.00,
SquarePaymentID: &sameSquareID,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create balance record: %v", err)
}
// Cancel 72+ hours before → full refund of £50.
farFuture := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 50,
start, farFuture, "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Should have created 1 Square refund (for the deposit record) and credited
// the balance portion via user balance.
var refundCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 2 {
t.Errorf("expected 2 refund records (1 Square + 1 balance credit), got %d", refundCount)
}
}
// =============================================================================
// ProcessPendingSquareRefunds — post-commit Square refund processing
// =============================================================================
// TestProcessPendingSquareRefunds_ProcessesPendingRecords verifies that
// ProcessPendingSquareRefunds queries for "pending" Square refund records
// without square_refund_id and marks them "completed" (no actual Square
// API call when square_payment_id is NULL in mock/dev).
func TestProcessPendingSquareRefunds_ProcessesPendingRecords(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an online_square payment
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
// Manually insert a "pending" refund record (simulating what ProcessCancellationRefundTx creates)
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
SELECT id, $1, amount, 'pending', 'client_cancelled', NOW()
FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'
`, bookingID)
if err != nil {
t.Fatalf("failed to insert pending refund: %v", err)
}
// Commit the test transaction so the refund records are persisted.
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
// Use a fresh context (no closed transaction) so db.Conn falls through to pool.
freshCtx := context.Background()
// Now call the post-commit function
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
// Verify the refund record was marked completed
var status string
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE booking_id = $1`, bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
}
// TestProcessPendingSquareRefunds_SkipsCompletedRecords verifies that
// ProcessPendingSquareRefunds does not modify already-completed refunds.
func TestProcessPendingSquareRefunds_SkipsCompletedRecords(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Create a payment first
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, amount, payment_method, payment_type, status)
VALUES ($1, 50, 'cash', 'full', 'completed') RETURNING id
`, bookingID).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Insert a "completed" refund directly (simulating non-Square refund path)
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 50, 'completed', 'cash_refund', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert completed refund: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
freshCtx := context.Background()
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
// Verify the completed refund was left untouched
var status string
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE booking_id = $1`, bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if status != "completed" {
t.Errorf("expected existing status 'completed', got %q", status)
}
}