//go:build test && dev package payments import ( "context" "fmt" "net/http" "strings" "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...) } // assertAggKey asserts the charge-level aggregated refund key carries the // "-square-agg-" segment and stays within Square's 45-char idempotency-key // limit. The prefix is the verbatim chargeID for short square_payment_ids and a // hashed fixed-width form for long ones (see chargeAggKey), so only the shared // segment + length bound are asserted here. func assertAggKey(t *testing.T, got, chargeID string) { t.Helper() if !strings.Contains(got, "-square-agg-") { t.Errorf("expected charge-level idempotency key containing \"-square-agg-\", got %q", got) } if len(got) > 45 { t.Errorf("expected idempotency key within Square's 45-char limit, got %d chars: %q", len(got), got) } } // 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) } } // TestProcessCancellationRefund_ExpiredGiftCard_Retained verifies the // refunds.go expiry guard: when the payment was made with an EXPIRED gift card // (expiry_date in the past), the cancellation refund must NOT credit the card — // the money is retained by the salon. Regression for the previously-dead // `expiry_date IS NOT NULL AND expiry_date < NOW()` check, which never fired // because no write path populated expiry_date. func TestProcessCancellationRefund_ExpiredGiftCard_Retained(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) } // Expired card: balance 40, expiry_date 1 day in the past, last used 25mo ago. 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, NOW() - INTERVAL '1 day', NOW() - INTERVAL '25 months') RETURNING id `, userID).Scan(&giftCardID); err != nil { t.Fatalf("failed to create expired gift card: %v", err) } // Payment made WITH the expired card (this can happen for legacy cards that // were still spendable before the expiry_date write paths were wired up). _, err = tx.Exec(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()) `, bookingID, giftCardID) if err != nil { t.Fatalf("failed to create giftcard payment: %v", err) } farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) _, err = ProcessCancellationRefund( ctx, bookingID, 100, 60, farFuture, clock.Now(), "client_cancelled", &userID, ) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } // The expired-card guard must retain the money: balance unchanged at 40 // (NOT credited +60), and no refund-to-card transaction recorded. var amountRemaining float64 if err := tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining); err != nil { t.Fatalf("failed to query gift card balance: %v", err) } if amountRemaining != 40 { t.Errorf("expired card must NOT be credited: expected amount_remaining 40, got %.2f", amountRemaining) } var txCount int if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount); err != nil { t.Fatalf("failed to query gift card transactions: %v", err) } if txCount != 0 { t.Errorf("expected NO refund-to-expired-card 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(¬ifCount) 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) } assertAggKey(t, calls[0].IdempotencyKey, sameSquareID) // 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) } assertAggKey(t, calls[0].IdempotencyKey, sameSquareID) // 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) } assertAggKey(t, calls[0].IdempotencyKey, sameSquareID) // 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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) 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(¬ifCount) 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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) 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(¬ifCount) 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) } assertAggKey(t, calls[0].IdempotencyKey, sameSquareID) // 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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) 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") } } // ============================================================================= // D2b — legacy pending refund rows with NULL idempotency_key resume with a key // ============================================================================= // TestResumePendingRefund_LegacyNullKey_DerivesFreshKey verifies that resuming // a pending refund whose row has a NULL idempotency_key (a legacy row created // before keyed refunds) re-issues the Square refund with a NON-EMPTY derived // key. Square's RefundPayment REQUIRES a non-empty idempotency key — re-issuing // with "" would return a 400 INVALID_REQUEST_ERROR that classifies as ambiguous // and leaves the refund pending forever. func TestResumePendingRefund_LegacyNullKey_DerivesFreshKey(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) } 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_legacy_null_key' WHERE id = $1", paymentID) if err != nil { t.Fatalf("failed to set square_payment_id: %v", err) } // Seed a pending refund row with NO idempotency_key column value → NULL // (legacy row). The (payment_id, amount) pending fallback finds it. var refundID string err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at) VALUES ($1, $2, 50, 'pending', 'customer request', 'manual', NOW()) RETURNING id `, paymentID, bookingID).Scan(&refundID) if err != nil { t.Fatalf("failed to insert legacy pending refund with NULL idempotency_key: %v", err) } origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() // A NEW client-supplied key makes the exact-key dedup miss, so the pending // (payment_id, amount) fallback resumes the legacy NULL-key row. adminToken := jwt.GenerateTestToken(adminID, "admin") req := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "fresh-client-key-legacy"} rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) if rec.Code != http.StatusOK { t.Fatalf("expected 200 on resume, got %d: %s", rec.Code, rec.Body.String()) } calls := counting.refundCalls() if len(calls) != 1 { t.Fatalf("expected exactly 1 Square refund call for the resumed legacy refund, got %d", len(calls)) } if calls[0].IdempotencyKey == "" { t.Fatal("expected a NON-EMPTY idempotency key for the resumed legacy refund (Square RefundPayment requires one)") } if calls[0].IdempotencyKey == req.IdempotencyKey { t.Errorf("expected a derived key, not the client's fresh key, got %q", calls[0].IdempotencyKey) } // Derived shape: -refund--<12 hex chars>. prefix := paymentID + "-refund-5000-" if !strings.HasPrefix(calls[0].IdempotencyKey, prefix) { t.Errorf("expected derived key with prefix %q, got %q", prefix, calls[0].IdempotencyKey) } if len(calls[0].IdempotencyKey) > 45 { t.Errorf("expected derived key within Square's 45-char limit, got %d chars: %q", len(calls[0].IdempotencyKey), calls[0].IdempotencyKey) } // The row must resolve to completed (the mock returns COMPLETED). var status string err = db.Conn.QueryRow(ctx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status) if err != nil { t.Fatalf("failed to query refund status: %v", err) } if status != "completed" { t.Errorf("expected resumed refund status 'completed', got %q", status) } } // ============================================================================= // 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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) 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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) 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(¬ifCount) 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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) 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(¬ifCount) 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) } } // ============================================================================= // F1 — manual refunds with a stored square_refund_id are reconciled, not dropped // ============================================================================= // TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled locks the // F1 fix: a manual refund the RefundPayment handler left 'pending' WITH a // stored square_refund_id (Square's synchronous-PENDING response) is no longer // filtered out of the sweep — it is reconciled against Square instead. When // Square reports the exact COMPLETED refund, the row resolves to 'completed' // and NO new refund is issued (re-issuing would risk a second refund). func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled(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) } chargeID := "sqp_manual_reconcile_completed" if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); 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, idempotency_key, origin, square_refund_id, created_at) VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_completed', NOW() - INTERVAL '5 minutes') RETURNING id `, paymentID, bookingID, paymentID+"-refund-5000").Scan(&refundID) if err != nil { t.Fatalf("failed to insert pending manual refund with square_refund_id: %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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // Pre-seed the COMPLETED refund Square recorded for this charge — the exact // amount, same payment. Simulates the handler's PENDING refund that has // since completed at Square. seeded, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{ PaymentID: chargeID, Amount: 5000, IdempotencyKey: "seed-manual-reconcile-completed", Reason: "customer request", }) 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() // The sweep processes the whole shared test database — clear pending rows // left by earlier sequential tests so the call count 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) } 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 manual refund with square_refund_id resolved to 'completed' via Square reconcile, 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 for a reconcilable row, got %d", len(calls)) } } // TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed locks // the F1 no-match branch: a manual refund row WITH a stored square_refund_id // whose Square reconcile finds no exact COMPLETED refund (Square never // recorded it) is marked 'failed' and surfaced via admin_notification — it no // longer blocks the over-refund guard forever. func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_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, 50.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create card payment: %v", err) } chargeID := "sqp_manual_reconcile_nomatch" if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); 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, idempotency_key, origin, square_refund_id, created_at) VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_nomatch', NOW() - INTERVAL '5 minutes') RETURNING id `, paymentID, bookingID, paymentID+"-refund-5001").Scan(&refundID) if err != nil { t.Fatalf("failed to insert pending manual refund with square_refund_id: %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) } // The committed rows live in the SHARED test pool — clean them up or // parallel tests that count whole tables see them (test isolation). t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) origClient := SquareClient // The MockClient has no refund for this charge → reconcile is a genuine // no-match (nil, nil). mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() freshCtx := context.Background() 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) } var status string err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status) if err != nil { t.Fatalf("failed to query refund: %v", err) } if status != "failed" { t.Errorf("expected manual refund with no COMPLETED refund at Square marked 'failed', got %q", status) } // The no-match reconcile must NOT re-issue a new Square refund. if calls := counting.refundCalls(); len(calls) != 0 { t.Errorf("expected NO Square refund call on a 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(¬ifCount) 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) } } // ============================================================================= // Bug fix — set-stable charge-level idempotency key (refunds.go processChargeGroup) // ============================================================================= // TestChargeAggKey_AlwaysFitsAndDeterministic locks the charge-level idempotency // key builder: the key must stay within Square's 45-char limit for ANY // square_payment_id length (the verbatim form only fits chargeIDs ≤21 chars; // longer ones fall back to a hashed fixed-width prefix — see chargeAggKey), be // deterministic for a given (charge, set), and differ across charges/sets. func TestChargeAggKey_AlwaysFitsAndDeterministic(t *testing.T) { ids := []string{"id1", "id2"} longCharge := "sqp_real_square_payment_id_that_is_quite_long" // Short chargeID → verbatim form. shortKey := chargeAggKey("sqp_short", ids) if !strings.HasPrefix(shortKey, "sqp_short-square-agg-") { t.Errorf("expected verbatim prefix for a short chargeID, got %q", shortKey) } if len(shortKey) > 45 { t.Errorf("expected short-charge key within 45 chars, got %d: %q", len(shortKey), shortKey) } // Long chargeID → still ≤45, never the verbatim form. longKey := chargeAggKey(longCharge, ids) if strings.HasPrefix(longKey, longCharge+"-square-agg-") { t.Errorf("expected long chargeID NOT embedded verbatim, got %q", longKey) } if len(longKey) > 45 { t.Errorf("expected long-charge key within 45 chars, got %d: %q", len(longKey), longKey) } // Same (charge, set) → same key; different set → different key; different // charge → different key (no cross-charge dedup collision). if chargeAggKey("sqp_short", ids) != shortKey { t.Error("expected the same (charge, set) to produce the same key") } if chargeAggKey("sqp_short", []string{"id1"}) == shortKey { t.Error("expected a changed set to produce a different key") } if chargeAggKey(longCharge, []string{"id1"}) == longKey { t.Error("expected a changed set to produce a different key (long charge)") } if chargeAggKey("sqp_other", ids) == shortKey { t.Error("expected a different charge to produce a different key") } if chargeAggKey(longCharge+"x", ids) == longKey { t.Error("expected a different long charge to produce a different key") } } // TestProcessChargeGroup_ChangedPendingSet_NewKey verifies the idempotency-key // bug fix: the charge-level aggregated refund key is derived from the SET of // pending row ids. A SAME-set retry (the CRITICAL-log path where the post-refund // DB UPDATE failed, leaving the rows 'pending') reuses the SAME key → Square's // dedup returns the ORIGINAL refund. When a NEW cancellation refund row joins // the group, the set changes → a NEW key → Square issues a fresh refund covering // the new total, so the new row is NEVER marked 'completed' against the old // smaller refund (the under-refund / lost-money bookkeeping bug). func TestProcessChargeGroup_ChangedPendingSet_NewKey(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) } chargeID := "sqp_changed_set" if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", chargeID, depositID, balanceID); err != nil { t.Fatalf("failed to set square_payment_id: %v", err) } insertRefund := func(pid string, amount float64, key string) string { t.Helper() var id string if err := tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at) VALUES ($1, $2, $3, 'pending', 'client_cancelled', $4, 'cancellation', NOW()) RETURNING id `, pid, bookingID, amount, key).Scan(&id); err != nil { t.Fatalf("failed to insert pending refund: %v", err) } return id } row1 := insertRefund(depositID, 25, depositID+"-square-2500") row2 := insertRefund(balanceID, 25, balanceID+"-square-2500") 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() t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() runGroup := func() string { t.Helper() if _, err := processChargeGroup(freshCtx, chargeID, fetchPendingChargeRows(freshCtx, chargeID), "client_cancelled"); err != nil { t.Fatalf("processChargeGroup failed: %v", err) } calls := counting.refundCalls() return calls[len(calls)-1].IdempotencyKey } // Run 1: the two-row set → key K1 covering £50. key1 := runGroup() assertAggKey(t, key1, chargeID) calls := counting.refundCalls() if len(calls) != 1 || calls[0].Amount != 5000 { t.Fatalf("expected exactly 1 Square refund of 5000 pence, got %d call(s) (amount %d)", len(calls), calls[0].Amount) } var oldRefundID string if err := db.Conn.QueryRow(freshCtx, `SELECT square_refund_id FROM refunds WHERE id = $1`, row1).Scan(&oldRefundID); err != nil { t.Fatalf("failed to read refund id: %v", err) } // SAME-set retry (the CRITICAL-log path: Square committed but the DB UPDATE // failed, rows still 'pending'): reset to pending and re-run → the SAME key // K1, and Square's dedup returns the ORIGINAL refund (no second refund). _, err = db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', square_refund_id = NULL WHERE id = ANY($1)`, []string{row1, row2}) if err != nil { t.Fatalf("failed to reset refund rows pending: %v", err) } keyRetry := runGroup() if keyRetry != key1 { t.Errorf("expected SAME-set retry to reuse key %q, got %q", key1, keyRetry) } if n := mock.RefundKeyCount(); n != 1 { t.Errorf("expected Square to have issued exactly 1 refund after the same-set retry (dedup), got %d", n) } // A NEW cancellation refund row joins the group (e.g. an admin re-cancels // the residual after run 1's DB UPDATE failed) → the set changes. var row3 string err = db.Conn.QueryRow(freshCtx, ` 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 `, balanceID, bookingID, balanceID+"-square-2500-2").Scan(&row3) if err != nil { t.Fatalf("failed to insert third pending refund: %v", err) } // Reset the original two rows to pending again (they completed in run 2) so // the group is once more the FULL set {row1, row2, row3}. _, err = db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', square_refund_id = NULL WHERE id = ANY($1)`, []string{row1, row2}) if err != nil { t.Fatalf("failed to reset refund rows pending: %v", err) } // Run 3: the three-row set → key K2 ≠ K1 → a NEW Square refund for the full // £75 — never deduped against the old £50 refund. key2 := runGroup() if key2 == key1 { t.Errorf("expected CHANGED set to yield a DIFFERENT key, got the same %q", key2) } calls = counting.refundCalls() if len(calls) != 3 { t.Fatalf("expected 3 total Square refund calls, got %d", len(calls)) } if calls[2].Amount != 7500 { t.Errorf("expected the changed-set refund of 7500 pence (£25+£25+£25), got %d", calls[2].Amount) } if n := mock.RefundKeyCount(); n != 2 { t.Errorf("expected Square to have issued exactly 2 distinct refunds, got %d", n) } // All three rows resolve to completed; the new row is completed against the // NEW refund, never the old smaller one. var newRefundID string var newStatus string if err := db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, row3).Scan(&newStatus, &newRefundID); err != nil { t.Fatalf("failed to query third refund: %v", err) } if newStatus != "completed" { t.Errorf("expected third refund status 'completed', got %q", newStatus) } if newRefundID == "" || newRefundID == oldRefundID { t.Errorf("expected third refund completed against the NEW refund (not the old %q), got %q", oldRefundID, newRefundID) } var completedCount int if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE id = ANY($1) AND status = 'completed'`, []string{row1, row2, row3}).Scan(&completedCount); err != nil { t.Fatalf("failed to count completed refunds: %v", err) } if completedCount != 3 { t.Errorf("expected all 3 refund rows completed, got %d", completedCount) } } // ============================================================================= // Bug fix — NULL-key manual refund rows are keyed ONCE before re-issue // ============================================================================= // TestSweepManualRetry_NullKey_SingleSquareRefund verifies the idempotency-key // bug fix for the manual-refund sweep loop: a manual refund row with a NULL // idempotency_key (legacy) gets a fallback key PERSISTED to the row before the // Square call, so a retry after the CRITICAL-log path (Square committed, DB // UPDATE to 'completed' failed, row still 'pending') reuses the SAME key and // Square issues exactly ONE refund (the mock dedups same-key retries). The old // code generated a fresh random suffix per resume → a second Square refund. func TestSweepManualRetry_NullKey_SingleSquareRefund(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) } if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_null_key_manual' WHERE id = $1", paymentID); err != nil { t.Fatalf("failed to set square_payment_id: %v", err) } // A legacy pending manual refund row with NO idempotency_key column value → // NULL (the root source of the bug: the next sweep's refundResumeKey would // generate a fresh random suffix per resume). var refundID string err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at) VALUES ($1, $2, 50, 'pending', 'customer request', 'manual', NOW()) RETURNING id `, paymentID, bookingID).Scan(&refundID) if err != nil { t.Fatalf("failed to insert legacy NULL-key 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) } freshCtx := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() runSweep := func() { t.Helper() n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}) if err != nil { t.Fatalf("processManualPaymentGroup failed: %v", err) } if n != 1 { t.Fatalf("expected 1 manual refund processed, got %d", n) } } // Run 1: ensureRefundKey generates a fallback key, PERSISTS it to the row, // and Square issues refund R1 under that key. runSweep() calls := counting.refundCalls() if len(calls) != 1 { t.Fatalf("expected exactly 1 Square refund call, got %d", len(calls)) } key1 := calls[0].IdempotencyKey if key1 == "" { t.Fatal("expected a NON-EMPTY idempotency key (Square rejects empty keys with 400 INVALID_REQUEST_ERROR)") } if len(key1) > 45 { t.Errorf("expected key within Square's 45-char limit, got %d chars: %q", len(key1), key1) } var storedKey string if err := db.Conn.QueryRow(freshCtx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&storedKey); err != nil { t.Fatalf("failed to read stored key: %v", err) } if storedKey != key1 { t.Errorf("expected the generated key %q persisted to the row, got %q", key1, storedKey) } // Simulate the CRITICAL-log path: the Square refund committed but the DB // UPDATE to 'completed' failed → row back to 'pending', square_refund_id NULL. if _, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', square_refund_id = NULL WHERE id = $1`, refundID); err != nil { t.Fatalf("failed to reset refund row pending: %v", err) } // Run 2: the retry reuses the PERSISTED key → Square dedups → the SAME // single refund is returned; NO second refund is ever issued. runSweep() calls = counting.refundCalls() if len(calls) != 2 { t.Fatalf("expected 2 Square refund calls across both runs, got %d", len(calls)) } if calls[1].IdempotencyKey != key1 { t.Errorf("expected the retry to reuse the persisted key %q, got %q", key1, calls[1].IdempotencyKey) } if n := mock.RefundKeyCount(); n != 1 { t.Errorf("expected exactly ONE distinct Square refund issued (dedup), got %d", n) } var keyAfter string if err := db.Conn.QueryRow(freshCtx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&keyAfter); err != nil { t.Fatalf("failed to read stored key: %v", err) } if keyAfter != key1 { t.Errorf("expected the row's key unchanged across both runs (%q), got %q", key1, keyAfter) } var status string if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status); err != nil { t.Fatalf("failed to read refund status: %v", err) } if status != "completed" { t.Errorf("expected refund status 'completed', got %q", status) } }