//go:build test && dev // +build test,dev package payments import ( "testing" "time" "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, 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 = 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, 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 = 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, 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 = 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 <= 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) { 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, 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 = 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, 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 = 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, 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 = 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, 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 = 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, 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 = 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 := 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() _, 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) } }