Files
Crussell/backend/handlers/payments/refunds_test.go
T
popertots ae8735ba2f Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
  per-payment advisory locks taken before the prior-refunds read
  (pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
  charge (stable charge-level -square-agg key); atomic group UPDATE
  keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
  refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
  refund_attempts cap; sweep retries stale manual pending refunds with
  each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
  terminal failed transition: tri-state result leaves rows pending on
  reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
  resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
  (excluding failed); ErrRefundDeclined distinguishes definitive vs
  ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
  with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
  (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
  DO NOTHING without consuming refundRemaining

Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
  in request bodies; gate new-card entry behind CardEntryUnavailable
  notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
  and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording

Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
  (single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
  goroutines), reconcile error vs no-match branches, stale manual retry,
  forgive-fees real refund row + reason, double-cancel dedup, mock refund
  key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
2026-08-22 00:34:49 +01:00

2875 lines
98 KiB
Go

//go:build test && dev
package payments
import (
"context"
"fmt"
"net/http"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
)
// countingRefundClient wraps a square.SquareClient and records every
// RefundPayment call so tests can assert exactly-once charge-level refunds.
type countingRefundClient struct {
square.SquareClient
mu sync.Mutex
calls []square.RefundPaymentReq
}
func (c *countingRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
c.mu.Lock()
c.calls = append(c.calls, req)
c.mu.Unlock()
return c.SquareClient.RefundPayment(ctx, req)
}
func (c *countingRefundClient) refundCalls() []square.RefundPaymentReq {
c.mu.Lock()
defer c.mu.Unlock()
return append([]square.RefundPaymentReq(nil), c.calls...)
}
// ambiguousRefundClient simulates a transport-level failure (no sentinel
// error) — Square may or may not have processed the refund.
type ambiguousRefundClient struct {
square.SquareClient
}
func (c *ambiguousRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
// ListPaymentRefunds simulates the same transport failure so reconcile paths
// fail gracefully (log + nil) instead of panicking on the nil embedded client.
func (c *ambiguousRefundClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
// reconcileErrorClient wraps a working SquareClient (so RefundPayment
// succeeds) but simulates a transport failure on ListPaymentRefunds — the
// reconcile, not the refund call, fails. Used to lock the tri-state error
// branch: an unknown reconcile state must leave rows pending.
type reconcileErrorClient struct {
square.SquareClient
}
func (c *reconcileErrorClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
// slowRefundClient delays the Square call so the manual RefundPayment handler
// holds its advisory lock long enough that a concurrent cancellation refund
// would race it without the D1 serialization locks.
type slowRefundClient struct {
square.SquareClient
delay time.Duration
}
func (c *slowRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
time.Sleep(c.delay)
return c.SquareClient.RefundPayment(ctx, req)
}
// =============================================================================
// 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 cancellation refund can never be
// issued via Square — it is marked "failed" (no API call possible) and
// surfaced for in-person arrangement. No balance credit is generated.
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 != "failed" {
t.Errorf("expected refund status 'failed' (card refund with no Square reference), got %q", status)
}
// P1-B: the pre-pass must surface an admin_notifications row for the
// affected booking (in-person arrangement needed).
var notifCount int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
// 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 ONE Square refund per charge
// =============================================================================
func TestProcessCancellationRefund_SplitPayment_SingleSquareRefund(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 produce exactly ONE
// Square refund covering the whole charge — never one per record, and never
// one call plus a silent "completed" with no money moved.
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)
}
// Commit the setup: ProcessCancellationRefund acquires pg_advisory_xact_lock
// on the card payments, and in the test env those locks would be held by the
// outer per-test transaction (savepoints don't release xact locks) — which
// would deadlock the post-commit sweep's session locks. Running at pool level
// mirrors production, where the cancellation tx commits and releases the locks.
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()
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
// 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(
freshCtx, 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)
}
// Exactly ONE Square call for the charge — the aggregated amount, keyed
// by the stable charge-level idempotency key.
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call for the charge, got %d", len(calls))
}
if calls[0].Amount != 5000 {
t.Errorf("expected aggregated Square refund of 5000 pence, got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected charge-level idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
// Both refund records must be completed and share ONE square_refund_id —
// the old bug left one record completed with square_refund_id NULL (no
// money moved for it).
var refundCount int
err = db.Conn.QueryRow(freshCtx,
"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, got %d", refundCount)
}
rows, err := db.Conn.Query(freshCtx, `
SELECT amount, status, square_refund_id FROM refunds WHERE booking_id = $1 ORDER BY amount
`, bookingID)
if err != nil {
t.Fatalf("failed to query refund rows: %v", err)
}
defer rows.Close()
var refundIDs []string
refundCount = 0
for rows.Next() {
var amount float64
var status string
var sqRefundID *string
if err := rows.Scan(&amount, &status, &sqRefundID); err != nil {
t.Fatalf("failed to scan refund row: %v", err)
}
refundCount++
if amount != 25.00 {
t.Errorf("expected refund amount 25.00, got %.2f", amount)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
if sqRefundID == nil || *sqRefundID == "" {
t.Error("expected square_refund_id to be set — the old bug left it NULL with money not moved")
} else {
refundIDs = append(refundIDs, *sqRefundID)
}
}
if refundCount != 2 {
t.Errorf("expected 2 refund rows, got %d", refundCount)
}
if len(refundIDs) == 2 && refundIDs[0] != refundIDs[1] {
t.Errorf("expected both records to share the SAME square_refund_id, got %q and %q", refundIDs[0], refundIDs[1])
}
}
// =============================================================================
// ProcessPendingSquareRefunds — post-commit Square refund processing
// =============================================================================
// TestProcessPendingSquareRefunds_ProcessesPendingRecords verifies that
// ProcessPendingSquareRefunds resolves a pending cancellation card refund whose
// payment has NO square_payment_id: it can never be refunded via Square, so it
// is marked "failed" (terminal pre-pass) rather than silently completed.
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, origin, created_at)
SELECT id, $1, amount, 'pending', 'client_cancelled', 'cancellation', 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")
// The card refund has no Square reference — it must be marked failed.
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 != "failed" {
t.Errorf("expected refund status 'failed' (no square_payment_id), 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)
}
}
// TestProcessPendingSquareRefunds_IssuesRefundWithSquareCall verifies that a
// pending cancellation refund on a card charge with a square_payment_id is
// actually issued to Square (real API call) and completed with the returned
// square_refund_id. The charge-level idempotency key is used for the call.
func TestProcessPendingSquareRefunds_IssuesRefundWithSquareCall(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 an online_square payment with a square_payment_id so the scheduler
// calls the mock.
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_stored_key' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Seed a pending CANCELLATION refund with a stored idempotency key.
storedKey := paymentID + "-square-2500"
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
RETURNING id
`, paymentID, bookingID, storedKey).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending refund: %v", err)
}
// Commit the test tx so the refund rows persist (scheduler reads via pool).
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")
// The refund must be completed with a square_refund_id — the Square call
// happened (with the stable charge-level idempotency key).
var status string
var squareRefundID *string
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
if squareRefundID == nil || *squareRefundID == "" {
t.Error("expected square_refund_id to be set (Square was called)")
}
}
// TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund verifies that
// a pending cancellation refund on a split record sharing a square_payment_id
// with an already-completed manual refund is ISSUED (money moved) for the
// residual — the amount-blind suppression (the P0 bug) would have marked it
// completed with no money moving.
func TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund(t *testing.T) {
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)
}
// Split payment records sharing one square_payment_id (deposit + balance).
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create deposit payment: %v", err)
}
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
if err != nil {
t.Fatalf("failed to create balance payment: %v", err)
}
sameSquareID := "sqp_same_charge_pending"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", sameSquareID, depositID, balanceID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// A completed MANUAL refund on the deposit record (money already moved back).
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, square_refund_id, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'completed', 'manual refund', 'ref_seeded', $3, 'manual', NOW())
`, depositID, bookingID, depositID+"-refund-2500")
if err != nil {
t.Fatalf("failed to insert completed refund: %v", err)
}
// A pending CANCELLATION refund on the balance record sharing the charge.
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
`, balanceID, bookingID, balanceID+"-square-2500")
if err != nil {
t.Fatalf("failed to insert pending refund: %v", err)
}
// Commit the test tx so the refund rows persist (scheduler reads via pool).
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)
}
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
// The residual £25 must be ISSUED — a real Square call happens (the old
// amount-blind suppression marked it completed with NO money moved).
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call for the residual, got %d", len(calls))
}
if calls[0].Amount != 2500 {
t.Errorf("expected Square refund of 2500 pence (the residual), got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected charge-level idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
// The balance pending refund must be completed WITH a square_refund_id —
// money moved, NOT left NULL.
var status string
var squareRefundID *string
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, balanceID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected balance refund status 'completed', got %q", status)
}
if squareRefundID == nil || *squareRefundID == "" {
t.Error("expected square_refund_id to be set (money moved) — the P0 bug left it NULL")
}
// The completed manual refund is untouched.
var manualStatus string
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, depositID).Scan(&manualStatus)
if err != nil {
t.Fatalf("failed to query manual refund: %v", err)
}
if manualStatus != "completed" {
t.Errorf("expected manual refund status 'completed', got %q", manualStatus)
}
}
// =============================================================================
// ProcessCancellationRefundTx — transactional variant
// =============================================================================
func TestProcessCancellationRefundTx_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
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 cash payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "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 := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false)
if err != nil {
t.Fatalf("ProcessCancellationRefundTx 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
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)
}
// Check refund was recorded in the inner transaction (the record is visible
// because ProcessCancellationRefundTx writes to the same tx).
var refundAmount float64
err = innerTx.QueryRow(ctx,
"SELECT amount FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundAmount)
if err != nil {
t.Fatalf("failed to query refund amount: %v", err)
}
if refundAmount != 50 {
t.Errorf("expected refund amount 50, got %.2f", refundAmount)
}
}
func TestProcessCancellationRefundTx_NoRefundNeeded(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
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 no-show — refundable should be 0.
now := time.Date(2099, 12, 31, 12, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 100, 0, start, now, "no_show", &userID, false)
if err != nil {
t.Fatalf("ProcessCancellationRefundTx 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)
}
// No refund records should be created.
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 != 0 {
t.Errorf("expected 0 refund records, got %d", refundCount)
}
}
// TestProcessCancellationRefund_DoubleCancel_FailedRow_Dedups locks the P3a fix:
// a second cancel whose prior refund row is 'failed' (money never moved at
// Square, so the residual is recomputed in full) must NOT crash on the UNIQUE
// idempotency_key. The ON CONFLICT DO NOTHING dedup skips the INSERT without
// creating a duplicate row and without touching the original 'failed' row.
func TestProcessCancellationRefund_DoubleCancel_FailedRow_Dedups(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
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)
}
// >72h before the appointment → full refund.
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Square-backed payment (not Square-less) so the row is recorded 'pending'
// with the deterministic idempotency key paymentID-square-5000.
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", "sqp_double_cancel", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
// First cancel creates the pending refund row with the deterministic key.
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false)
if err != nil {
t.Fatalf("first ProcessCancellationRefundTx failed: %v", err)
}
if result == nil || result.RefundableAmount != 50 {
t.Fatalf("expected full refund of 50, got %+v", result)
}
var rowCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count refund rows after first cancel: %v", err)
}
if rowCount != 1 {
t.Fatalf("expected 1 refund row after first cancel, got %d", rowCount)
}
var rowKey string
err = tx.QueryRow(ctx, "SELECT idempotency_key FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowKey)
if err != nil {
t.Fatalf("failed to query refund idempotency_key: %v", err)
}
if rowKey != paymentID+"-square-5000" {
t.Fatalf("expected idempotency_key %q, got %q", paymentID+"-square-5000", rowKey)
}
// Simulate the sweep failing all 3 attempts: the row flips to 'failed'.
// 'failed' is deliberately excluded from the residual sum (money never
// moved at Square), so a second cancel recomputes the FULL residual and
// attempts to INSERT the same idempotency key again.
_, err = tx.Exec(ctx, "UPDATE refunds SET status = 'failed' WHERE payment_id = $1", paymentID)
if err != nil {
t.Fatalf("failed to flip refund row to failed: %v", err)
}
// Second cancel: same payment, same amount, same key. The ON CONFLICT
// (idempotency_key) DO NOTHING must dedup — no error, no duplicate row,
// and the original 'failed' row untouched.
if _, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false); err != nil {
t.Fatalf("second ProcessCancellationRefundTx failed: %v", err)
}
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count refund rows after second cancel: %v", err)
}
if rowCount != 1 {
t.Errorf("expected exactly 1 refund row after dedup, got %d", rowCount)
}
var rowStatus string
err = tx.QueryRow(ctx, "SELECT status FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowStatus)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if rowStatus != "failed" {
t.Errorf("expected original row still 'failed' (untouched), got %q", rowStatus)
}
}
// =============================================================================
// CreateRefundRecord — PaymentService method
// =============================================================================
func TestCreateRefundRecord_Success(t *testing.T) {
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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
svc := NewPaymentService()
now := clock.Now()
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
PaymentID: paymentID,
BookingID: bookingID,
Amount: 25.00,
Status: "completed",
Reason: "partial refund",
CreatedBy: &userID,
CreatedAt: now,
})
if err != nil {
t.Fatalf("CreateRefundRecord failed: %v", err)
}
if refundID == "" {
t.Fatal("expected non-empty refund ID")
}
// Verify the refund record exists in the DB.
var storedAmount float64
var storedStatus string
err = tx.QueryRow(ctx,
"SELECT amount, status FROM refunds WHERE id = $1", refundID).Scan(&storedAmount, &storedStatus)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if storedAmount != 25.00 {
t.Errorf("expected amount 25.00, got %.2f", storedAmount)
}
if storedStatus != "completed" {
t.Errorf("expected status 'completed', got %q", storedStatus)
}
}
func TestCreateRefundRecord_WithSquareRefundID(t *testing.T) {
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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squareRefundID := "sqr_test_refund_123"
svc := NewPaymentService()
now := clock.Now()
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
PaymentID: paymentID,
BookingID: bookingID,
Amount: 100.00,
SquareRefundID: &squareRefundID,
Status: "completed",
Reason: "full square refund",
CreatedBy: &userID,
CreatedAt: now,
})
if err != nil {
t.Fatalf("CreateRefundRecord failed: %v", err)
}
if refundID == "" {
t.Fatal("expected non-empty refund ID")
}
// Verify the square_refund_id was stored.
var storedSquareRefundID *string
err = tx.QueryRow(ctx,
"SELECT square_refund_id FROM refunds WHERE id = $1", refundID).Scan(&storedSquareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if storedSquareRefundID == nil || *storedSquareRefundID != squareRefundID {
t.Errorf("expected square_refund_id %q, got %v", squareRefundID, storedSquareRefundID)
}
}
func TestCreateRefundRecord_NilCreatedBy(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Need a user for the booking.
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30.00, "cash", "partial", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
svc := NewPaymentService()
now := clock.Now()
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
PaymentID: paymentID,
BookingID: bookingID,
Amount: 30.00,
Status: "completed",
Reason: "refund without actor",
CreatedBy: nil,
CreatedAt: now,
})
if err != nil {
t.Fatalf("CreateRefundRecord failed: %v", err)
}
if refundID == "" {
t.Fatal("expected non-empty refund ID")
}
// Verify created_by is NULL.
var storedCreatedBy *string
err = tx.QueryRow(ctx,
"SELECT created_by FROM refunds WHERE id = $1", refundID).Scan(&storedCreatedBy)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if storedCreatedBy != nil {
t.Errorf("expected created_by NULL, got %q", *storedCreatedBy)
}
}
// =============================================================================
// Charge-level aggregation & sweep tests
// =============================================================================
// TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund verifies the
// P0 fix: two pending refund rows sharing one square_payment_id produce ONE
// Square refund of the aggregate amount, and both rows complete with the SAME
// square_refund_id (never one row completed with money unmoved).
func TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund(t *testing.T) {
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)
}
// Split payment records sharing one square_payment_id.
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create deposit payment: %v", err)
}
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
if err != nil {
t.Fatalf("failed to create balance payment: %v", err)
}
sameSquareID := "sqp_aggregate_split"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", sameSquareID, depositID, balanceID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Two pending cancellation refunds (deposit £25 + balance £25).
for _, pid := range []string{depositID, balanceID} {
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
`, pid, bookingID, pid+"-square-2500")
if err != nil {
t.Fatalf("failed to insert pending 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)
}
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call for the charge, got %d", len(calls))
}
if calls[0].Amount != 5000 {
t.Errorf("expected aggregated amount 5000 pence (25+25), got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
// Both rows completed with the same square_refund_id, amounts preserved.
rows, err := db.Conn.Query(freshCtx, `
SELECT amount, status, square_refund_id FROM refunds WHERE booking_id = $1 ORDER BY amount
`, bookingID)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
defer rows.Close()
var sqIDs []string
count := 0
for rows.Next() {
var amount float64
var status string
var sqID *string
if err := rows.Scan(&amount, &status, &sqID); err != nil {
t.Fatalf("failed to scan refund row: %v", err)
}
count++
if amount != 25.00 {
t.Errorf("expected amount 25.00 preserved, got %.2f", amount)
}
if status != "completed" {
t.Errorf("expected status 'completed', got %q", status)
}
if sqID == nil || *sqID == "" {
t.Error("expected square_refund_id set (money moved)")
} else {
sqIDs = append(sqIDs, *sqID)
}
}
if count != 2 {
t.Errorf("expected 2 refund rows, got %d", count)
}
if len(sqIDs) == 2 && sqIDs[0] != sqIDs[1] {
t.Errorf("expected both rows to share the SAME square_refund_id, got %q and %q", sqIDs[0], sqIDs[1])
}
}
// TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed verifies
// the sweep's terminal pre-pass: a pending cancellation card refund with NO
// square_payment_id can never be refunded via Square — it is marked failed.
func TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed(t *testing.T) {
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)
}
// online_square payment with square_payment_id NULL (fixture default).
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 50, 'pending', 'client_cancelled', 'cancellation', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert pending 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)
}
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
}
var status string
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if status != "failed" {
t.Errorf("expected refund status 'failed', got %q", status)
}
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO Square calls (no square_payment_id), got %d", len(calls))
}
// P1-B: the terminal pre-pass must surface an admin_notifications row for
// the affected booking (in-person arrangement needed).
var notifCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
// TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed verifies that a
// pending refund row already at the 3-attempt cap is never processed by the
// sweep: it is filtered out (refund_attempts < 3), no Square call happens, and
// its status is left untouched.
func TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_attempts_exhausted' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Pending cancellation refund already at the 3-attempt cap.
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'client_cancelled', 'cancellation', 3, NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert pending 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)
}
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
}
var status string
var attempts int
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "pending" {
t.Errorf("expected status untouched ('pending'), got %q", status)
}
if attempts != 3 {
t.Errorf("expected refund_attempts untouched (3), got %d", attempts)
}
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO Square calls for an attempts=3 row, got %d", len(calls))
}
}
// TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed verifies the
// definitive-decline path: each run increments refund_attempts, and the third
// run marks the rows failed.
func TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_declined' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert pending 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)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailRefundCode = "REFUND_DECLINED"
SquareClient = mock
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
// Run 1 and 2: attempts increment, row stays pending.
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
var status string
var attempts int
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if attempts != 2 {
t.Errorf("expected refund_attempts 2 after two declined runs, got %d", attempts)
}
if status != "pending" {
t.Errorf("expected status 'pending' before the cap is hit, got %q", status)
}
// Run 3: cap hit → failed.
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "failed" {
t.Errorf("expected status 'failed' after 3 declined runs, got %q", status)
}
if attempts != 3 {
t.Errorf("expected refund_attempts 3 after three declined runs, got %d", attempts)
}
}
// TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError
// verifies the tri-state reconcile under the ambiguous path (plain transport
// error): the row is retried up to the 3-attempt cap, and on the run that hits
// the cap the reconcile against Square ALSO fails (same transport failure) — an
// unknown state. The row MUST stay 'pending' (NOT 'failed', which would let the
// over-refund guard exclude money that may have moved) and NO admin_notification
// is inserted; the next sweep retries the reconcile.
func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_ambiguous' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert pending 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)
}
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < 3; i++ {
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
}
var status string
var attempts int
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "pending" {
t.Errorf("expected status 'pending' after 3 ambiguous runs with a failing reconcile, got %q", status)
}
if attempts != 3 {
t.Errorf("expected refund_attempts 3 after three ambiguous runs, got %d", attempts)
}
// The terminal failure must NOT fire: the reconcile returned a network
// error (unknown state), so the row stays pending and no admin
// notification is inserted.
var notifCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
}
}
// TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual
// verifies the per-record prior-refund subtraction: after a £30 manual refund
// on the deposit record, a £70 cancellation refund issues the residual (£20
// deposit + £50 balance) as ONE aggregate £70 Square refund.
func TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual(t *testing.T) {
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_residual"
svc := NewPaymentService()
now := clock.Now()
depositID, err := svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "online_square",
Status: "completed",
Amount: 50.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: 50.00,
SquarePaymentID: &sameSquareID,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create balance record: %v", err)
}
// £30 manually refunded on the deposit record (completed, origin='manual').
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, square_refund_id, idempotency_key, origin, created_at)
VALUES ($1, $2, 30, 'completed', 'manual refund', 'ref_manual_30', $3, 'manual', NOW())
`, depositID, bookingID, depositID+"-refund-3000")
if err != nil {
t.Fatalf("failed to insert manual refund: %v", err)
}
// Commit the setup: ProcessCancellationRefund acquires pg_advisory_xact_lock
// on the card payments, and in the test env those locks would be held by the
// outer per-test transaction (savepoints don't release xact locks) — which
// would deadlock the post-commit sweep's session locks. Running at pool level
// mirrors production, where the cancellation tx commits and releases the locks.
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()
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
// Cancel >72h before with net paid £70 (after the £30 manual refund).
// Refundable = £70 → the loop must only issue the residual.
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(
freshCtx, bookingID, 100, 70,
start, farFuture, "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 70 {
t.Errorf("expected refundable 70, got %.2f", result.RefundableAmount)
}
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call for the residual, got %d", len(calls))
}
if calls[0].Amount != 7000 {
t.Errorf("expected aggregated residual of 7000 pence (£70), got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
// Two pending rows created for the residual: deposit £20 + balance £50.
rows, err := db.Conn.Query(freshCtx, `
SELECT amount, status, origin FROM refunds
WHERE booking_id = $1 AND origin = 'cancellation' ORDER BY amount
`, bookingID)
if err != nil {
t.Fatalf("failed to query cancellation refunds: %v", err)
}
defer rows.Close()
var amounts []float64
count := 0
for rows.Next() {
var amount float64
var status, origin string
if err := rows.Scan(&amount, &status, &origin); err != nil {
t.Fatalf("failed to scan refund row: %v", err)
}
count++
amounts = append(amounts, amount)
if status != "completed" {
t.Errorf("expected status 'completed', got %q", status)
}
}
if count != 2 {
t.Fatalf("expected 2 cancellation refund rows, got %d", count)
}
if amounts[0] != 20.00 || amounts[1] != 50.00 {
t.Errorf("expected residual amounts £20 + £50, got %.2f + %.2f", amounts[0], amounts[1])
}
// The manual refund row is untouched.
var manualStatus string
var manualAmount float64
err = db.Conn.QueryRow(freshCtx, "SELECT status, amount FROM refunds WHERE idempotency_key = $1", depositID+"-refund-3000").Scan(&manualStatus, &manualAmount)
if err != nil {
t.Fatalf("failed to query manual refund: %v", err)
}
if manualStatus != "completed" {
t.Errorf("expected manual refund status 'completed', got %q", manualStatus)
}
if manualAmount != 30.00 {
t.Errorf("expected manual refund amount 30.00, got %.2f", manualAmount)
}
}
// =============================================================================
// D1 — cancellation loop serializes against a concurrent manual refund
// =============================================================================
// TestCancellationRefund_SerializesAgainstManualRefund proves the D1 fix: the
// cancellation refund loop acquires the same per-payment advisory locks as the
// manual RefundPayment handler, so the two can never BOTH read zero prior
// refunds and both refund the same payment. Without the locks, a manual refund
// whose guard read happens before the cancellation tx commits over-refunds the
// payment by the full amount. The manual handler's Square call is slowed so the
// window is wide enough that the racy interleaving would occur without the fix.
func TestCancellationRefund_SerializesAgainstManualRefund(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_serialize' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Commit the setup so both goroutines operate at pool level — the advisory
// locks only serialize across independent connections, and a per-test tx
// would route one side's reads through a single shared connection.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
origClient := SquareClient
slow := &slowRefundClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
adminToken := jwt.GenerateTestToken(adminID, "admin")
req := RefundRequest{Amount: 10000, Reason: "customer request"}
var wg sync.WaitGroup
startBoth := make(chan struct{})
var manualCode int
var manualErr, cancelErr error
wg.Add(1)
go func() {
defer wg.Done()
<-startBoth
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, pool)
manualCode = rec.Code
}()
wg.Add(1)
go func() {
defer wg.Done()
<-startBoth
cancelTx, err := db.Conn.Pool().Begin(pool)
if err != nil {
cancelErr = err
return
}
defer func() {
// Rollback is a no-op after a successful commit.
_ = cancelTx.Rollback(pool)
}()
if _, err := ProcessCancellationRefundTx(pool, cancelTx, bookingID, 100, 100, start, clock.Now(), "client_cancelled", &userID, false); err != nil {
cancelErr = err
return
}
cancelErr = cancelTx.Commit(pool)
}()
close(startBoth)
wg.Wait()
if manualErr != nil {
t.Fatalf("manual refund goroutine failed: %v", manualErr)
}
if cancelErr != nil {
t.Fatalf("cancellation goroutine failed: %v", cancelErr)
}
// Manual outcome: 200 (manual won, cancellation created no row) or 400
// (cancellation's committed pending row blocked the manual over-refund).
if manualCode != http.StatusOK && manualCode != http.StatusBadRequest {
t.Errorf("expected manual refund 200 (won) or 400 (blocked by cancellation), got %d", manualCode)
}
// Money-correctness invariant: at most ONE refund row resolves the payment
// and the total never exceeds the £100 payment.
var refundCount int
err = db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status IN ('completed','pending')`, paymentID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to count refunds: %v", err)
}
if refundCount > 1 {
t.Errorf("over-refund: %d refund rows for a single £100 payment", refundCount)
}
var refundedPence int64
err = db.Conn.QueryRow(pool,
`SELECT COALESCE(SUM(ROUND(amount * 100)), 0)::bigint FROM refunds WHERE payment_id = $1 AND status IN ('completed','pending')`, paymentID).Scan(&refundedPence)
if err != nil {
t.Fatalf("failed to sum refunds: %v", err)
}
if refundedPence > 10000 {
t.Errorf("over-refund: %d pence refunded on a 10000-pence payment", refundedPence)
}
}
// =============================================================================
// D2 — the sweep retries stale MANUAL pending refunds with their own key
// =============================================================================
// TestSweepPendingSquareRefunds_RetriesStaleManualRefund verifies the D2 fix:
// a manual refund left 'pending' by the handler's ambiguous-error path is
// retried by the sweep with the row's OWN stored idempotency key (never the
// -square-agg key), and resolves to completed.
func TestSweepPendingSquareRefunds_RetriesStaleManualRefund(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_manual_retry' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
storedKey := paymentID + "-refund-5000"
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', NOW())
RETURNING id
`, paymentID, bookingID, storedKey).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert stale manual pending 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)
}
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
// The sweep processes the whole shared test database — clear any pending
// rows left by earlier sequential tests (e.g. the D1 concurrency test when
// the cancellation won) so the call count below is deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
t.Fatalf("failed to clean leftover pending refunds: %v", err)
}
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
}
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call for the stale manual refund, got %d", len(calls))
}
if calls[0].IdempotencyKey != storedKey {
t.Errorf("expected the row's OWN stored idempotency key %q, got %q", storedKey, calls[0].IdempotencyKey)
}
if calls[0].Amount != 5000 {
t.Errorf("expected refund of 5000 pence, got %d", calls[0].Amount)
}
if calls[0].PaymentID != "sqp_manual_retry" {
t.Errorf("expected Square payment id sqp_manual_retry, got %q", calls[0].PaymentID)
}
var status string
var squareRefundID *string
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
if squareRefundID == nil || *squareRefundID == "" {
t.Error("expected square_refund_id to be set")
}
}
// =============================================================================
// D3 — the 23h age guard reconciles against Square before marking failed
// =============================================================================
// TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund verifies
// the D3 fix: an aged pending refund (>23h) whose money actually moved at
// Square (pre-seeded COMPLETED refund via MockClient) is resolved to completed
// instead of failed — no new Square call is issued, and the square_refund_id
// matches the refund Square already recorded.
func TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_reconcile' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
RETURNING id
`, paymentID, bookingID).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert aged pending 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)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Pre-seed the COMPLETED refund Square recorded for this charge — the exact
// amount, same payment. This simulates the money already having moved.
seeded, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: "sqp_age_reconcile",
Amount: 2500,
IdempotencyKey: "seed-age-guard",
Reason: "client_cancelled",
})
if err != nil {
t.Fatalf("failed to seed completed Square refund: %v", err)
}
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
var status string
var squareRefundID *string
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected aged refund resolved to 'completed' (money moved at Square), got %q", status)
}
if squareRefundID == nil || *squareRefundID != seeded.ID {
t.Errorf("expected square_refund_id %q (the refund Square recorded), got %v", seeded.ID, squareRefundID)
}
// The reconcile must NOT have re-issued a new Square refund.
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO new Square refund call during reconcile, got %d", len(calls))
}
}
// TestProcessPendingSquareRefunds_AgeGuard_ReconcileError_LeavesPending locks
// the tri-state reconcile error branch: an aged pending refund (>23h) whose
// reconcile against Square FAILS (network error) must stay 'pending' — NOT
// 'failed', which would let the over-refund guard exclude money that may have
// moved at Square. The charge-level refund must NOT be re-issued either.
func TestProcessPendingSquareRefunds_AgeGuard_ReconcileError_LeavesPending(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_reconcile_error' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert aged pending 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)
}
origClient := SquareClient
recErr := &reconcileErrorClient{SquareClient: square.NewDevClient()}
counting := &countingRefundClient{SquareClient: recErr}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
var status string
var attempts int
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "pending" {
t.Errorf("expected aged refund left 'pending' (reconcile error = unknown state), got %q", status)
}
if attempts != 0 {
t.Errorf("expected refund_attempts untouched (0) when the reconcile fails before any refund call, got %d", attempts)
}
// The reconcile error must NOT have fired a charge-level Square refund.
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO Square refund call when the reconcile fails, got %d", len(calls))
}
// No admin notification — the row was not marked failed.
var notifCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
}
}
// TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed locks the
// tri-state reconcile nil+nil branch: an aged pending refund (>23h) with NO
// exact COMPLETED refund at Square (genuine no-match — money provably did not
// move) is marked 'failed' and surfaced via admin_notification.
func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T) {
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)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_no_match' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert aged pending 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)
}
origClient := SquareClient
// The MockClient records no refunds for this charge → reconcile returns a
// genuine no-match (nil, nil).
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
var status string
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "failed" {
t.Errorf("expected aged refund marked 'failed' (Square shows no COMPLETED refund), got %q", status)
}
// The reconcile showed no match — the charge-level refund must NOT fire.
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO Square refund call on a genuine no-match reconcile, got %d", len(calls))
}
// The terminal failure must surface an admin_notifications row.
var notifCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}