Files
Crussell/backend/handlers/payments/refunds_test.go
T
popertots 6d82535780 fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
2026-08-22 00:34:50 +01:00

4404 lines
166 KiB
Go

//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)
}
// C4 money-safety: the refund record must be 'failed' — never 'completed'.
// The old bug fell through the switch and recorded a completed refund even
// though the UPDATE gift_cards credit was skipped (no money moved).
var refundStatus string
if err := tx.QueryRow(ctx,
"SELECT status FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundStatus); err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if refundStatus != "failed" {
t.Errorf("expected expired-card refund record status 'failed' (no money credited), got %q", refundStatus)
}
}
func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a cash payment of 30.
_, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 30,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 30 {
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
}
// Verify user balance was credited.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query balance: %v", err)
}
if balance != 30 {
t.Errorf("expected user balance 30, got %.2f", balance)
}
}
func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an online_square payment — this will be handled by Square mock.
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 100,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 100 {
t.Errorf("expected refundable 100, got %.2f", result.RefundableAmount)
}
// Square API refund is processed AFTER the transaction commits (see
// ProcessPendingSquareRefunds). In dev/test the payment has no
// square_payment_id, so the pending cancellation refund can never be
// issued via Square — it is marked "failed" (no API call possible) and
// surfaced for in-person arrangement. No balance credit is generated.
var status string
err = tx.QueryRow(ctx, "SELECT status FROM refunds WHERE booking_id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if status != "failed" {
t.Errorf("expected refund status 'failed' (card refund with no Square reference), got %q", status)
}
// P1-B: the pre-pass must surface an admin_notifications row for the
// affected booking (in-person arrangement needed).
var notifCount int
err = tx.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
// No balance credit should have been created (Square payment method uses
// post-commit refund processing, not balance credits).
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
// No row = no balance credit — this is the expected outcome.
// The refund was processed as a direct record, not a balance credit.
t.Logf("no balance row (expected): %v", err)
} else if balance > 0 {
t.Errorf("expected no balance credit for Square payment, got %.2f", balance)
}
}
// =============================================================================
// ProcessCancellationRefund — non-money payment methods (discount, on_the_house)
// =============================================================================
func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a discount payment (no real money exchanged).
_, err = fixtures.CreateTestPayment(tx, bookingID, 20, "discount", "partial", "completed")
if err != nil {
t.Fatalf("failed to create discount payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 20,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 20 {
t.Errorf("expected refundable 20 (full refund >72h), got %.2f", result.RefundableAmount)
}
// Discount payments should NOT create a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
balance = 0
}
if balance != 0 {
t.Errorf("expected no balance credit for discount payment, got %.2f", balance)
}
}
func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create an on_the_house payment (no real money exchanged).
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "on_the_house", "full", "completed")
if err != nil {
t.Fatalf("failed to create on_the_house payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 100,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 100 {
t.Errorf("expected refundable 100 (full refund >72h), got %.2f", result.RefundableAmount)
}
// on_the_house payments should NOT create a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
balance = 0
}
if balance != 0 {
t.Errorf("expected no balance credit for on_the_house payment, got %.2f", balance)
}
}
// =============================================================================
// ProcessCancellationRefund — missing user_id edge case
// =============================================================================
func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a cash payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
// Set user_id to NULL on the booking to simulate a purged guest account.
_, err = tx.Exec(ctx, "UPDATE bookings SET user_id = NULL WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to nullify booking user_id: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 50,
farFuture, clock.Now(), "client_cancelled", nil,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Refund record should still be created even without user_id.
var refundCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund record (user_id-less), got %d", refundCount)
}
}
// =============================================================================
// ProcessCancellationRefund — guest users must NOT get balance credits
// =============================================================================
func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create a user and promote them to guest role.
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set guest role: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a gift card payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "giftcard", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create giftcard payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 50,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Guest must NOT have a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
// No row means balance is 0 — this is the expected outcome.
balance = 0
}
if balance != 0 {
t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance)
}
// Refund record should still exist.
var refundCount int
err = tx.QueryRow(ctx,
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund record for guest, got %d", refundCount)
}
}
func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set guest role: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
// Create a cash payment.
_, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
ctx, bookingID, 100, 30,
farFuture, clock.Now(), "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 30 {
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
}
// Guest must NOT have a balance credit.
var balance float64
err = tx.QueryRow(ctx,
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
balance = 0
}
if balance != 0 {
t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance)
}
}
// =============================================================================
// Refund with split payments — verify ONE Square refund per charge
// =============================================================================
func TestProcessCancellationRefund_SplitPayment_SingleSquareRefund(t *testing.T) {
// When a single Square charge is split into 2 DB payment records (deposit + balance)
// sharing the same square_payment_id, the refund loop must produce exactly ONE
// Square refund covering the whole charge — never one per record, and never
// one call plus a silent "completed" with no money moved.
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
sameSquareID := "sqp_split_dedup_test"
now := clock.Now()
// Create 2 payment records sharing the same square_payment_id — simulating a
// split charge where one Square payment was recorded as deposit + balance.
svc := NewPaymentService()
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "online_square",
Status: "completed",
Amount: 25.00,
SquarePaymentID: &sameSquareID,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create deposit record: %v", err)
}
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "balance",
PaymentMethod: "online_square",
Status: "completed",
Amount: 25.00,
SquarePaymentID: &sameSquareID,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create balance record: %v", err)
}
// Commit the setup: ProcessCancellationRefund acquires pg_advisory_xact_lock
// on the card payments, and in the test env those locks would be held by the
// outer per-test transaction (savepoints don't release xact locks) — which
// would deadlock the post-commit sweep's session locks. Running at pool level
// mirrors production, where the cancellation tx commits and releases the locks.
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
freshCtx := context.Background()
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
// Cancel 72+ hours before → full refund of £50.
farFuture := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
result, err := ProcessCancellationRefund(
freshCtx, bookingID, 100, 50,
start, farFuture, "client_cancelled", &userID,
)
if err != nil {
t.Fatalf("ProcessCancellationRefund failed: %v", err)
}
if result.RefundableAmount != 50 {
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
}
// Exactly ONE Square call for the charge — the aggregated amount, keyed
// by the stable charge-level idempotency key.
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call for the charge, got %d", len(calls))
}
if calls[0].Amount != 5000 {
t.Errorf("expected aggregated Square refund of 5000 pence, got %d", calls[0].Amount)
}
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(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
// TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed verifies that a
// pending refund row already at the 3-attempt cap is never processed by the
// sweep: it is filtered out (refund_attempts < 3), no Square call happens, and
// its status is left untouched.
func TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_attempts_exhausted' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// Pending cancellation refund already at the 3-attempt cap.
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'client_cancelled', 'cancellation', 3, NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert pending refund: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
// 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 money 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. Since the A5c fix the capped rows are re-armed under the cap on a
// reconcile error (mirroring resolveManualRefundAtCap) so the next sweep
// re-picks them — the row ends the third run at maxManualRefundAttempts-1, not
// stranded at the cap — and the consecutive-failure counter is still below
// maxConsecutiveReconcileFailures, so no critical_payment_log notification
// fires yet.
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)
}
// A5c: the cap-time reconcile failure re-arms the capped row under the cap
// (instead of stranding it at the cap where the sweep would never re-pick
// it), so the third run ends at maxManualRefundAttempts-1, not at the cap.
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after three ambiguous runs with a failing reconcile, got %d", maxManualRefundAttempts-1, attempts)
}
// The terminal failure must NOT fire: the reconcile returned a network
// error (unknown state), so the row stays pending and no admin
// notification is inserted.
var notifCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
}
// The consecutive-reconcile-failure counter (A5b/A5c) is still below
// maxConsecutiveReconcileFailures after this single cap-time re-arm, so no
// critical_payment_log notification fires either.
var critCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount)
if err != nil {
t.Fatalf("failed to query critical_payment_log notifications: %v", err)
}
if critCount != 0 {
t.Errorf("expected NO critical_payment_log notification after one reconcile-failure re-arm, got %d", critCount)
}
}
// 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)
}
}
// TestRefundPayment_ReasonTooLong_Rejected verifies the N4 fix: a refund
// reason longer than Square's 192-char cap is rejected with 400 before any
// refund row is created — a longer reason would 400 at Square and be
// misclassified as a definitive decline, misleading the admin.
func TestRefundPayment_ReasonTooLong_Rejected(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 payment: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
req := RefundRequest{Amount: 1000, Reason: strings.Repeat("x", 193)}
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for a 193-char reason, got %d: %s", rec.Code, rec.Body.String())
}
var refundCount int
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount); err != nil {
t.Fatalf("failed to count refunds: %v", err)
}
if refundCount != 0 {
t.Errorf("expected 0 refund rows, got %d", refundCount)
}
}
// =============================================================================
// 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: <paymentID>-refund-<pence>-<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(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
}
}
// TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed locks the
// tri-state reconcile nil+nil branch: an aged pending refund (>23h) with NO
// exact COMPLETED refund at Square (genuine no-match — money provably did not
// move) is marked 'failed' and surfaced via admin_notification.
func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_no_match' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to insert aged pending refund: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
// 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(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
// =============================================================================
// 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(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
// =============================================================================
// Bug fix — charge-stable idempotency key (refunds.go processChargeGroup, C6)
// =============================================================================
// 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 ≤34 chars;
// longer ones fall back to a hashed fixed-width prefix — see chargeAggKey), be
// deterministic for a given charge, be stable regardless of the pending row set
// (C6 — the key must NOT change when new rows join the group), and differ
// across charges.
func TestChargeAggKey_AlwaysFitsAndDeterministic(t *testing.T) {
longCharge := "sqp_real_square_payment_id_that_is_quite_long"
// Short chargeID → verbatim form.
shortKey := chargeAggKey("sqp_short")
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)
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 → same key (deterministic AND set-stable: the pending row set
// is never part of the key, so a new row joining the group cannot change it
// — C6); different charge → different key (no cross-charge dedup collision).
if chargeAggKey("sqp_short") != shortKey {
t.Error("expected the same charge to produce the same key")
}
if chargeAggKey(longCharge) != longKey {
t.Error("expected the same long charge to produce the same key")
}
if chargeAggKey("sqp_other") == shortKey {
t.Error("expected a different charge to produce a different key")
}
if chargeAggKey(longCharge+"x") == longKey {
t.Error("expected a different long charge to produce a different key")
}
}
// TestProcessChargeGroup_ChangedPendingSet_NoDoubleRefund verifies the C6
// idempotency-key fix: the charge-level aggregated refund key is derived from
// the charge ID ONLY, never 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
// but the key STAYS THE SAME → Square's dedup STILL returns the ORIGINAL refund
// instead of issuing a second refund on top of money that already moved (the
// C6 double-refund bug).
func TestProcessChargeGroup_ChangedPendingSet_NoDoubleRefund(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 → the SAME charge-stable key K1. Square's dedup
// returns the ORIGINAL £50 refund — a fresh £75 refund would have
// double-refunded the customer on top of money that already moved (C6, the
// old set-derived-key bug). The new row resolves against the original
// refund; no second Square refund is ever issued.
key2 := runGroup()
if key2 != key1 {
t.Errorf("expected CHANGED set to reuse the charge-stable key %q, got %q", key1, key2)
}
calls = counting.refundCalls()
if len(calls) != 3 {
t.Fatalf("expected 3 total Square refund calls (all dedup hits), got %d", len(calls))
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected Square to have issued exactly 1 distinct refund (dedup), got %d", n)
}
// All three rows resolve to completed against the ORIGINAL refund.
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 != oldRefundID {
t.Errorf("expected third refund completed against the ORIGINAL refund %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)
}
}
// =============================================================================
// H2 — REFUND_AMOUNT_INVALID reconciliation (refunds.go)
// =============================================================================
// TestSweepManualRefund_RefundAmountInvalid_AlreadyRefunded_Completed covers
// the H2 reconciliation on the manual-sweep path: when the sweep's re-issue
// attempt hits REFUND_AMOUNT_INVALID on a payment Square HAS already refunded,
// the pending row resolves to 'completed' (never 'failed', no admin
// notification). The mock's RefundPayment reconciles internally: the payment is
// in its ledger with a recorded refund, so the over-refund attempt is answered
// ErrRefundAlreadyProcessed and the handler marks the row completed.
func TestSweepManualRefund_RefundAmountInvalid_AlreadyRefunded_Completed(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 payment: %v", err)
}
// Create the charge through the mock so the sweep's refund attempt hits a
// payment KNOWN to its ledger (the mock only reconciles over-refunds for
// payments it holds).
mock := square.NewDevClient().(*square.MockClient)
charge, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:h2-already-refunded",
IdempotencyKey: "h2-already-refunded-charge",
})
if err != nil {
t.Fatalf("failed to seed mock payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", charge.ID, 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, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-refund-5000").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending manual 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)
}
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)
})
// Record a COMPLETED refund at Square BEFORE the sweep so the payment is
// already refunded; the sweep's re-issue attempt then over-refunds.
if _, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: charge.ID,
Amount: 5000,
IdempotencyKey: "seed-h2-already-refunded",
Reason: "customer request",
}); err != nil {
t.Fatalf("failed to seed Square refund: %v", err)
}
origClient := SquareClient
SquareClient = mock
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
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)
}
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 query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund 'completed' after REFUND_AMOUNT_INVALID on an already-refunded payment, got %q", status)
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected exactly 1 distinct Square refund (the pre-seeded one; the sweep must not re-issue), got %d", n)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification for an already-refunded payment resolved to completed, got %d", notifCount)
}
}
// TestSweepManualRefund_RefundAmountInvalid_NotRefunded_DeclinePath covers the
// other H2 branch on the manual-sweep path: REFUND_AMOUNT_INVALID where the
// payment was NOT refunded is a genuine decline, not an already-refunded
// outcome — the row falls through to the decline path (refund_attempts
// incremented, still pending, no completion).
func TestSweepManualRefund_RefundAmountInvalid_NotRefunded_DeclinePath(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 payment: %v", err)
}
chargeID := "sqp_h2_not_refunded"
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, created_at)
VALUES ($1, $2, 25, 'pending', 'customer request', $3, 'manual', NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-refund-2500").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending manual 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)
}
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)
mock.FailRefundCode = "REFUND_AMOUNT_INVALID"
SquareClient = mock
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
if err != nil {
t.Fatalf("processManualPaymentGroup failed: %v", err)
}
if n != 0 {
t.Fatalf("expected 0 refunds processed on the decline path, got %d", n)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "pending" {
t.Errorf("expected status 'pending' (REFUND_AMOUNT_INVALID on a NOT-refunded payment is a decline, not a completion), got %q", status)
}
if attempts != 1 {
t.Errorf("expected refund_attempts 1 after the decline path, got %d", attempts)
}
if n := mock.RefundKeyCount(); n != 0 {
t.Errorf("expected NO Square refund recorded on the decline path, got %d", n)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification while the row is still pending, got %d", notifCount)
}
}
// TestProcessPendingSquareRefunds_RefundAmountInvalid_AlreadyRefunded_Completed
// covers the H2 reconciliation on the aggregated (charge-group) path: the
// cancellation refund row for an already-refunded charge resolves to
// 'completed' — never 'failed', no admin notification — so the amount unblocks
// the over-refund guard without double-refunding.
func TestProcessPendingSquareRefunds_RefundAmountInvalid_AlreadyRefunded_Completed(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 payment: %v", err)
}
mock := square.NewDevClient().(*square.MockClient)
charge, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 2500,
Currency: "GBP",
SourceID: "cnon:h2-agg-refunded",
IdempotencyKey: "h2-agg-refunded-charge",
})
if err != nil {
t.Fatalf("failed to seed mock payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", charge.ID, 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, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-square-2500").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending cancellation 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)
}
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)
})
// The charge is already fully refunded at Square before the sweep.
if _, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: charge.ID,
Amount: 2500,
IdempotencyKey: "seed-h2-agg-refunded",
Reason: "client_cancelled",
}); err != nil {
t.Fatalf("failed to seed Square refund: %v", err)
}
origClient := SquareClient
SquareClient = mock
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)
}
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
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 query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund 'completed' after REFUND_AMOUNT_INVALID on an already-refunded charge, got %q", status)
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected exactly 1 distinct Square refund (the pre-seeded one), got %d", n)
}
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification for an already-refunded charge resolved to completed, got %d", notifCount)
}
}
// =============================================================================
// resolveManualRefundAtCap re-arm (refunds.go)
// =============================================================================
// TestSweepManualRefund_ReconcileError_AtCap_ReArmed covers the re-arm fix: when
// a manual refund reaches the attempt cap but the cap-time reconcile against
// Square FAILS (unknown money state), the row must NOT be stranded at the cap
// (the sweep only re-picks rows with refund_attempts < maxManualRefundAttempts).
// resolveManualRefundAtCap re-arms the row to maxManualRefundAttempts-1 so the
// next sweep re-picks it — still pending, still under the cap.
func TestSweepManualRefund_ReconcileError_AtCap_ReArmed(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 payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_h2_rearm' WHERE id = $1", 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, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', $4, NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-h2-rearm-refund", maxManualRefundAttempts-1).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert manual refund at the attempt cap minus one: %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)
}
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)
})
// RefundPayment AND the reconcile (ListPaymentRefunds) both fail with
// plain transport errors → every run ends in an UNKNOWN money state.
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < 2; i++ {
if _, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}); err != nil {
t.Fatalf("processManualPaymentGroup run %d failed: %v", i+1, err)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after run %d: %v", i+1, err)
}
if status != "pending" {
t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after run %d (not stranded at the cap %d), got %d",
maxManualRefundAttempts-1, i+1, maxManualRefundAttempts, attempts)
}
}
// The re-arm must not have fired the terminal failure notification.
var notifCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected NO admin_notification (unknown money state must never be marked failed), got %d", notifCount)
}
}
// TestSweepManualRefund_ReconcileError_AtCap_NotifiesAfterNReArms locks the
// A5b fix: a manual refund whose cap-time reconcile keeps FAILING is re-armed
// under the cap on every sweep run (so the row is never stranded) BUT after
// maxConsecutiveReconcileFailures consecutive failures a deduped
// 'critical_payment_log' admin notification surfaces the hard-failing reconcile
// — the audit requirement that an admin is notified after repeated reconcile
// failures, never silently forever. The row stays 'pending' (an unknown money
// state is never marked failed) and keeps being re-armed, so the notification
// (deduped) is the durable admin-visible signal.
func TestSweepManualRefund_ReconcileError_AtCap_NotifiesAfterNReArms(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 payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_a5b_notify' WHERE id = $1", 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, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', $4, NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-a5b-notify-refund", maxManualRefundAttempts-1).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert manual refund at the attempt cap minus one: %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)
}
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)
resetReconcileFailureCount(refundID)
})
// RefundPayment AND the reconcile (ListPaymentRefunds) both fail with
// plain transport errors → every run ends in an UNKNOWN money state.
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < maxConsecutiveReconcileFailures; i++ {
if _, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}); err != nil {
t.Fatalf("processManualPaymentGroup run %d failed: %v", i+1, err)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after run %d: %v", i+1, err)
}
if status != "pending" {
t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after run %d, got %d",
maxManualRefundAttempts-1, i+1, attempts)
}
}
// After N consecutive failures the admin MUST have been notified via a
// deduped 'critical_payment_log' notification — the A5b requirement.
var critCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount); err != nil {
t.Fatalf("failed to query critical_payment_log notifications: %v", err)
}
if critCount < 1 {
t.Errorf("expected at least 1 critical_payment_log admin notification after %d consecutive reconcile failures, got %d",
maxConsecutiveReconcileFailures, critCount)
}
// The row must still be pending (never failed on an unknown money state)
// and no 'refund_failed' notification may have fired.
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after notification: %v", err)
}
if status != "pending" {
t.Errorf("expected status 'pending' (unknown state is never marked failed), got %q", status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts %d (still re-armed for the next sweep), got %d", maxManualRefundAttempts-1, attempts)
}
var failedCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&failedCount); err != nil {
t.Fatalf("failed to query refund_failed notifications: %v", err)
}
if failedCount != 0 {
t.Errorf("expected NO 'refund_failed' notification (row was never marked failed), got %d", failedCount)
}
}
// TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies locks the A5c
// fix on the cancellation (charge-group) path: when the cap-time reconcile
// fails the capped refund rows are re-armed under the cap (mirroring
// resolveManualRefundAtCap) so the sweep re-picks them instead of stranding
// them pending at the cap forever, and after maxConsecutiveReconcileFailures
// consecutive failures a deduped 'critical_payment_log' admin notification
// surfaces the hard-failing reconcile.
func TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies(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 payment: %v", err)
}
chargeID := "sqp_a5c_notify"
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, refund_attempts, created_at)
VALUES ($1, $2, 50, 'pending', 'client_cancelled', $3, 'cancellation', $4, NOW())
RETURNING id
`, paymentID, bookingID, paymentID+"-a5c-square-5000", maxManualRefundAttempts-1).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert cancellation refund at the attempt cap minus one: %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)
}
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)
resetReconcileFailureCount(refundID)
})
origClient := SquareClient
SquareClient = &ambiguousRefundClient{}
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
for i := 0; i < maxConsecutiveReconcileFailures; i++ {
if _, err := processChargeGroup(freshCtx, chargeID, fetchPendingChargeRows(freshCtx, chargeID), "client_cancelled"); err != nil {
t.Fatalf("processChargeGroup run %d failed: %v", i+1, err)
}
var status string
var attempts int
if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil {
t.Fatalf("failed to query refund after run %d: %v", i+1, err)
}
if status != "pending" {
t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status)
}
if attempts != maxManualRefundAttempts-1 {
t.Errorf("expected refund_attempts re-armed to %d after run %d (A5c: not stranded at the cap), got %d",
maxManualRefundAttempts-1, i+1, attempts)
}
}
var critCount int
if err := db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount); err != nil {
t.Fatalf("failed to query critical_payment_log notifications: %v", err)
}
if critCount < 1 {
t.Errorf("expected at least 1 critical_payment_log admin notification after %d consecutive reconcile failures, got %d",
maxConsecutiveReconcileFailures, critCount)
}
}