Migrate all test files from resetTestData(t) to testutils.SetupTestDB(t) for isolated per-package test databases. - Add new feature tests: name history assertions, referral discount preview, time blockers, email validation, GDPR export, loyalty manual redemption - Update existing tests to use batch queries and SetupTestDB - Remove test_helpers.go resetTestData infrastructure - Add comprehensive user profile tests (442 new lines) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
941 lines
32 KiB
Go
941 lines
32 KiB
Go
//go:build test && dev
|
|
// +build test,dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
)
|
|
|
|
// =============================================================================
|
|
// CalculateRefundForCancellation - Pure function tests
|
|
// =============================================================================
|
|
|
|
func TestCalculateRefundForCancellation_FullRefund_Over72h(t *testing.T) {
|
|
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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(),
|
|
"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
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
// 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(context.Background(), 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
|
|
db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
// 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(context.Background(), 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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
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(context.Background(), 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
|
|
db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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 := db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
t.Cleanup(func() {
|
|
db.DB.Exec(context.Background(), "DELETE FROM gift_card_transactions WHERE gift_card_id = $1", giftCardID)
|
|
db.DB.Exec(context.Background(), "DELETE FROM gift_cards WHERE id = $1", giftCardID)
|
|
})
|
|
|
|
var paymentID string
|
|
if err := db.DB.QueryRow(context.Background(), `
|
|
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)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
// Booking is far in the future — full refund.
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 60,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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.
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 30, "cash", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create cash payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 30,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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.
|
|
var paymentID string
|
|
paymentID, err = fixtures.CreateTestPayment(db.DB, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 100,
|
|
farFuture, time.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)
|
|
}
|
|
|
|
// In dev/test the payment has no square_payment_id, so Square cannot process
|
|
// the refund and the amount falls through to a balance credit. In production
|
|
// with a real square_payment_id the Square API would handle the refund instead.
|
|
var balance float64
|
|
err = db.DB.QueryRow(context.Background(),
|
|
"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 <= 0 {
|
|
t.Errorf("expected a balance credit (Square refund unavailable in mock), got %.2f", balance)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefund — non-money payment methods (discount, on_the_house)
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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).
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 20, "discount", "partial", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create discount payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 20,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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).
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 100, "on_the_house", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create on_the_house payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 100,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a cash payment.
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "cash", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create cash payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
// Set user_id to NULL on the booking to simulate a purged guest account.
|
|
_, err = db.DB.Exec(context.Background(), "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(
|
|
context.Background(), bookingID, 100, 50,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
// Create a user and promote them to guest role.
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
_, err = db.DB.Exec(context.Background(), "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(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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.
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "giftcard", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create giftcard payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 50,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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 = db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
_, err = db.DB.Exec(context.Background(), "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(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a cash payment.
|
|
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 30, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create cash payment: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
context.Background(), bookingID, 100, 30,
|
|
farFuture, time.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 = db.DB.QueryRow(context.Background(),
|
|
"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.
|
|
testutils.SetupTestDB(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
|
|
|
|
serviceID, err := fixtures.CreateTestService(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
|
|
|
|
_, err = db.DB.Exec(context.Background(), "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 := time.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()
|
|
|
|
pid1, err := svc.CreatePaymentRecord(context.Background(), 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)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, pid1) })
|
|
|
|
pid2, err := svc.CreatePaymentRecord(context.Background(), 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)
|
|
}
|
|
t.Cleanup(func() { fixtures.DeletePayment(db.DB, pid2) })
|
|
|
|
// 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(
|
|
context.Background(), 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 = db.DB.QueryRow(context.Background(),
|
|
"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)
|
|
}
|
|
|
|
}
|