Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed: MONEY: - CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) — a rejected auto-refund no longer re-replays the expired key every sweep run (which minted a stacking unauthorized charge each time); FAILED-webhook demotion respects the cap; never re-replay a key whose B1 refund failed - HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible campaign discount rows immediately (capped) instead of skipping with no discount recorded — no more promised-discount-not-recorded overcharge - MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed new-card+save_card charges (re-issue guard now covers req.SaveCard) - LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining now matches the authoritative tip-excluded balance) SECURITY: - MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap on critical_payment_log + refresh_token_reuse rows) - MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer clears LastMintAt on gate-verify; cleared on terminal charge success) - MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked state instead of a fresh 5-guess budget per request - MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs instead of sleeping unboundedly; login bcrypt concurrency semaphore added - LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check; email-verification per-user attempt counter DUP/MOD: - formatCurrency single source (frontend format.ts, 7 files consolidated); SquareRefundStatusToLocal single source (errors.go, all sites); admin audit-log helper dedup; SCA retry model unified (proactive on all 6 surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from backend; generateUUID at all card-form sites; magic numbers named (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card terminal charges now audited; DAV_SKIP_INIT documented in manuals Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet tags, frontend tests+build, env-docs 42/42.
4789 lines
183 KiB
Go
4789 lines
183 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(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
|
|
}
|
|
|
|
// No balance credit should have been created (Square payment method uses
|
|
// post-commit refund processing, not balance credits).
|
|
var balance float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
// No row = no balance credit — this is the expected outcome.
|
|
// The refund was processed as a direct record, not a balance credit.
|
|
t.Logf("no balance row (expected): %v", err)
|
|
} else if balance > 0 {
|
|
t.Errorf("expected no balance credit for Square payment, got %.2f", balance)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefund — non-money payment methods (discount, on_the_house)
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a discount payment (no real money exchanged).
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 20, "discount", "partial", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create discount payment: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 20,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 20 {
|
|
t.Errorf("expected refundable 20 (full refund >72h), got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Discount payments should NOT create a balance credit.
|
|
var balance float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
balance = 0
|
|
}
|
|
if balance != 0 {
|
|
t.Errorf("expected no balance credit for discount payment, got %.2f", balance)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create an on_the_house payment (no real money exchanged).
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "on_the_house", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create on_the_house payment: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 100,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 100 {
|
|
t.Errorf("expected refundable 100 (full refund >72h), got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// on_the_house payments should NOT create a balance credit.
|
|
var balance float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
balance = 0
|
|
}
|
|
if balance != 0 {
|
|
t.Errorf("expected no balance credit for on_the_house payment, got %.2f", balance)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefund — missing user_id edge case
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a cash payment.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create cash payment: %v", err)
|
|
}
|
|
|
|
// Set user_id to NULL on the booking to simulate a purged guest account.
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET user_id = NULL WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to nullify booking user_id: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 50,
|
|
farFuture, clock.Now(), "client_cancelled", nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Refund record should still be created even without user_id.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record (user_id-less), got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefund — guest users must NOT get balance credits
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
// Create a user and promote them to guest role.
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a gift card payment.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "giftcard", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create giftcard payment: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 50,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Guest must NOT have a balance credit.
|
|
var balance float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
// No row means balance is 0 — this is the expected outcome.
|
|
balance = 0
|
|
}
|
|
if balance != 0 {
|
|
t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance)
|
|
}
|
|
|
|
// Refund record should still exist.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record for guest, got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a cash payment.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create cash payment: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 30,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 30 {
|
|
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Guest must NOT have a balance credit.
|
|
var balance float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
balance = 0
|
|
}
|
|
if balance != 0 {
|
|
t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Refund with split payments — verify ONE Square refund per charge
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_SplitPayment_SingleSquareRefund(t *testing.T) {
|
|
// When a single Square charge is split into 2 DB payment records (deposit + balance)
|
|
// sharing the same square_payment_id, the refund loop must produce exactly ONE
|
|
// Square refund covering the whole charge — never one per record, and never
|
|
// one call plus a silent "completed" with no money moved.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
sameSquareID := "sqp_split_dedup_test"
|
|
now := clock.Now()
|
|
|
|
// Create 2 payment records sharing the same square_payment_id — simulating a
|
|
// split charge where one Square payment was recorded as deposit + balance.
|
|
svc := NewPaymentService()
|
|
|
|
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "deposit",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: 25.00,
|
|
SquarePaymentID: &sameSquareID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create deposit record: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "balance",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: 25.00,
|
|
SquarePaymentID: &sameSquareID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create balance record: %v", err)
|
|
}
|
|
|
|
// Commit the setup: ProcessCancellationRefund acquires pg_advisory_xact_lock
|
|
// on the card payments, and in the test env those locks would be held by the
|
|
// outer per-test transaction (savepoints don't release xact locks) — which
|
|
// would deadlock the post-commit sweep's session locks. Running at pool level
|
|
// mirrors production, where the cancellation tx commits and releases the locks.
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
freshCtx := context.Background()
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
// Cancel 72+ hours before → full refund of £50.
|
|
farFuture := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefund(
|
|
freshCtx, bookingID, 100, 50,
|
|
start, farFuture, "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Exactly ONE Square call for the charge — the aggregated amount, keyed
|
|
// by the stable charge-level idempotency key.
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the charge, got %d", len(calls))
|
|
}
|
|
if calls[0].Amount != 5000 {
|
|
t.Errorf("expected aggregated Square refund of 5000 pence, got %d", calls[0].Amount)
|
|
}
|
|
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
|
|
|
|
// Both refund records must be completed and share ONE square_refund_id —
|
|
// the old bug left one record completed with square_refund_id NULL (no
|
|
// money moved for it).
|
|
var refundCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 2 {
|
|
t.Errorf("expected 2 refund records, got %d", refundCount)
|
|
}
|
|
|
|
rows, err := db.Conn.Query(freshCtx, `
|
|
SELECT amount, status, square_refund_id FROM refunds WHERE booking_id = $1 ORDER BY amount
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund rows: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var refundIDs []string
|
|
refundCount = 0
|
|
for rows.Next() {
|
|
var amount float64
|
|
var status string
|
|
var sqRefundID *string
|
|
if err := rows.Scan(&amount, &status, &sqRefundID); err != nil {
|
|
t.Fatalf("failed to scan refund row: %v", err)
|
|
}
|
|
refundCount++
|
|
if amount != 25.00 {
|
|
t.Errorf("expected refund amount 25.00, got %.2f", amount)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected refund status 'completed', got %q", status)
|
|
}
|
|
if sqRefundID == nil || *sqRefundID == "" {
|
|
t.Error("expected square_refund_id to be set — the old bug left it NULL with money not moved")
|
|
} else {
|
|
refundIDs = append(refundIDs, *sqRefundID)
|
|
}
|
|
}
|
|
if refundCount != 2 {
|
|
t.Errorf("expected 2 refund rows, got %d", refundCount)
|
|
}
|
|
if len(refundIDs) == 2 && refundIDs[0] != refundIDs[1] {
|
|
t.Errorf("expected both records to share the SAME square_refund_id, got %q and %q", refundIDs[0], refundIDs[1])
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessPendingSquareRefunds — post-commit Square refund processing
|
|
// =============================================================================
|
|
|
|
// TestProcessPendingSquareRefunds_ProcessesPendingRecords verifies that
|
|
// ProcessPendingSquareRefunds resolves a pending cancellation card refund whose
|
|
// payment has NO square_payment_id: it can never be refunded via Square, so it
|
|
// is marked "failed" (terminal pre-pass) rather than silently completed.
|
|
func TestProcessPendingSquareRefunds_ProcessesPendingRecords(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create an online_square payment
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
|
|
// Manually insert a "pending" refund record (simulating what ProcessCancellationRefundTx creates)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
SELECT id, $1, amount, 'pending', 'client_cancelled', 'cancellation', NOW()
|
|
FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
// Commit the test transaction so the refund records are persisted.
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// Use a fresh context (no closed transaction) so db.Conn falls through to pool.
|
|
freshCtx := context.Background()
|
|
|
|
// Now call the post-commit function
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// The card refund has no Square reference — it must be marked failed.
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE booking_id = $1`, bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected refund status 'failed' (no square_payment_id), got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_SkipsCompletedRecords verifies that
|
|
// ProcessPendingSquareRefunds does not modify already-completed refunds.
|
|
func TestProcessPendingSquareRefunds_SkipsCompletedRecords(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Create a payment first
|
|
var paymentID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, amount, payment_method, payment_type, status)
|
|
VALUES ($1, 50, 'cash', 'full', 'completed') RETURNING id
|
|
`, bookingID).Scan(&paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
// Insert a "completed" refund directly (simulating non-Square refund path)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
|
VALUES ($1, $2, 50, 'completed', 'cash_refund', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert completed refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// Verify the completed refund was left untouched
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE booking_id = $1`, bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected existing status 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_IssuesRefundWithSquareCall verifies that a
|
|
// pending cancellation refund on a card charge with a square_payment_id is
|
|
// actually issued to Square (real API call) and completed with the returned
|
|
// square_refund_id. The charge-level idempotency key is used for the call.
|
|
func TestProcessPendingSquareRefunds_IssuesRefundWithSquareCall(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Create an online_square payment with a square_payment_id so the scheduler
|
|
// calls the mock.
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_stored_key' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// Seed a pending CANCELLATION refund with a stored idempotency key.
|
|
storedKey := paymentID + "-square-2500"
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
|
|
RETURNING id
|
|
`, paymentID, bookingID, storedKey).Scan(&refundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
// Commit the test tx so the refund rows persist (scheduler reads via pool).
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// The refund must be completed with a square_refund_id — the Square call
|
|
// happened (with the stable charge-level idempotency key).
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected refund status 'completed', got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID == "" {
|
|
t.Error("expected square_refund_id to be set (Square was called)")
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund verifies that
|
|
// a pending cancellation refund on a split record sharing a square_payment_id
|
|
// with an already-completed manual refund is ISSUED (money moved) for the
|
|
// residual — the amount-blind suppression (the P0 bug) would have marked it
|
|
// completed with no money moving.
|
|
func TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Split payment records sharing one square_payment_id (deposit + balance).
|
|
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create deposit payment: %v", err)
|
|
}
|
|
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create balance payment: %v", err)
|
|
}
|
|
sameSquareID := "sqp_same_charge_pending"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", sameSquareID, depositID, balanceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// A completed MANUAL refund on the deposit record (money already moved back).
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, square_refund_id, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'completed', 'manual refund', 'ref_seeded', $3, 'manual', NOW())
|
|
`, depositID, bookingID, depositID+"-refund-2500")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert completed refund: %v", err)
|
|
}
|
|
|
|
// A pending CANCELLATION refund on the balance record sharing the charge.
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
|
|
`, balanceID, bookingID, balanceID+"-square-2500")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
// Commit the test tx so the refund rows persist (scheduler reads via pool).
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// The residual £25 must be ISSUED — a real Square call happens (the old
|
|
// amount-blind suppression marked it completed with NO money moved).
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the residual, got %d", len(calls))
|
|
}
|
|
if calls[0].Amount != 2500 {
|
|
t.Errorf("expected Square refund of 2500 pence (the residual), got %d", calls[0].Amount)
|
|
}
|
|
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
|
|
|
|
// The balance pending refund must be completed WITH a square_refund_id —
|
|
// money moved, NOT left NULL.
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, balanceID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected balance refund status 'completed', got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID == "" {
|
|
t.Error("expected square_refund_id to be set (money moved) — the P0 bug left it NULL")
|
|
}
|
|
|
|
// The completed manual refund is untouched.
|
|
var manualStatus string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, depositID).Scan(&manualStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query manual refund: %v", err)
|
|
}
|
|
if manualStatus != "completed" {
|
|
t.Errorf("expected manual refund status 'completed', got %q", manualStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefundTx — transactional variant
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefundTx_Success(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET deposit_required = true WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposit_required: %v", err)
|
|
}
|
|
|
|
// Add a completed cash payment.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
// Cancel >72h before — full refund expected.
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Check refund record was created.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record, got %d", refundCount)
|
|
}
|
|
|
|
// Check refund was recorded in the inner transaction (the record is visible
|
|
// because ProcessCancellationRefundTx writes to the same tx).
|
|
var refundAmount float64
|
|
err = innerTx.QueryRow(ctx,
|
|
"SELECT amount FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundAmount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund amount: %v", err)
|
|
}
|
|
if refundAmount != 50 {
|
|
t.Errorf("expected refund amount 50, got %.2f", refundAmount)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefundTx_NoRefundNeeded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Cancel <24h before no-show — refundable should be 0.
|
|
now := time.Date(2099, 12, 31, 12, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 100, 0, start, now, "no_show", &userID, false)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// No refund records should be created.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 0 {
|
|
t.Errorf("expected 0 refund records, got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
// TestProcessCancellationRefund_DoubleCancel_FailedRow_Dedups locks the P3a fix:
|
|
// a second cancel whose prior refund row is 'failed' (money never moved at
|
|
// Square, so the residual is recomputed in full) must NOT crash on the UNIQUE
|
|
// idempotency_key. The ON CONFLICT DO NOTHING dedup skips the INSERT without
|
|
// creating a duplicate row and without touching the original 'failed' row.
|
|
func TestProcessCancellationRefund_DoubleCancel_FailedRow_Dedups(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// >72h before the appointment → full refund.
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
// Square-backed payment (not Square-less) so the row is recorded 'pending'
|
|
// with the deterministic idempotency key paymentID-square-5000.
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", "sqp_double_cancel", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// First cancel creates the pending refund row with the deterministic key.
|
|
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false)
|
|
if err != nil {
|
|
t.Fatalf("first ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
if result == nil || result.RefundableAmount != 50 {
|
|
t.Fatalf("expected full refund of 50, got %+v", result)
|
|
}
|
|
|
|
var rowCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count refund rows after first cancel: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Fatalf("expected 1 refund row after first cancel, got %d", rowCount)
|
|
}
|
|
var rowKey string
|
|
err = tx.QueryRow(ctx, "SELECT idempotency_key FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund idempotency_key: %v", err)
|
|
}
|
|
if rowKey != paymentID+"-square-5000" {
|
|
t.Fatalf("expected idempotency_key %q, got %q", paymentID+"-square-5000", rowKey)
|
|
}
|
|
|
|
// Simulate the sweep failing all 3 attempts: the row flips to 'failed'.
|
|
// 'failed' is deliberately excluded from the residual sum (money never
|
|
// moved at Square), so a second cancel recomputes the FULL residual and
|
|
// attempts to INSERT the same idempotency key again.
|
|
_, err = tx.Exec(ctx, "UPDATE refunds SET status = 'failed' WHERE payment_id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to flip refund row to failed: %v", err)
|
|
}
|
|
|
|
// Second cancel: same payment, same amount, same key. The ON CONFLICT
|
|
// (idempotency_key) DO NOTHING must dedup — no error, no duplicate row,
|
|
// and the original 'failed' row untouched.
|
|
if _, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false); err != nil {
|
|
t.Fatalf("second ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count refund rows after second cancel: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Errorf("expected exactly 1 refund row after dedup, got %d", rowCount)
|
|
}
|
|
var rowStatus string
|
|
err = tx.QueryRow(ctx, "SELECT status FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if rowStatus != "failed" {
|
|
t.Errorf("expected original row still 'failed' (untouched), got %q", rowStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CreateRefundRecord — PaymentService method
|
|
// =============================================================================
|
|
|
|
func TestCreateRefundRecord_Success(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: paymentID,
|
|
BookingID: bookingID,
|
|
Amount: 25.00,
|
|
Status: "completed",
|
|
Reason: "partial refund",
|
|
CreatedBy: &userID,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRefundRecord failed: %v", err)
|
|
}
|
|
if refundID == "" {
|
|
t.Fatal("expected non-empty refund ID")
|
|
}
|
|
|
|
// Verify the refund record exists in the DB.
|
|
var storedAmount float64
|
|
var storedStatus string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT amount, status FROM refunds WHERE id = $1", refundID).Scan(&storedAmount, &storedStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if storedAmount != 25.00 {
|
|
t.Errorf("expected amount 25.00, got %.2f", storedAmount)
|
|
}
|
|
if storedStatus != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", storedStatus)
|
|
}
|
|
}
|
|
|
|
func TestCreateRefundRecord_WithSquareRefundID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
squareRefundID := "sqr_test_refund_123"
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: paymentID,
|
|
BookingID: bookingID,
|
|
Amount: 100.00,
|
|
SquareRefundID: &squareRefundID,
|
|
Status: "completed",
|
|
Reason: "full square refund",
|
|
CreatedBy: &userID,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRefundRecord failed: %v", err)
|
|
}
|
|
if refundID == "" {
|
|
t.Fatal("expected non-empty refund ID")
|
|
}
|
|
|
|
// Verify the square_refund_id was stored.
|
|
var storedSquareRefundID *string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT square_refund_id FROM refunds WHERE id = $1", refundID).Scan(&storedSquareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if storedSquareRefundID == nil || *storedSquareRefundID != squareRefundID {
|
|
t.Errorf("expected square_refund_id %q, got %v", squareRefundID, storedSquareRefundID)
|
|
}
|
|
}
|
|
|
|
func TestCreateRefundRecord_NilCreatedBy(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Need a user for the booking.
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30.00, "cash", "partial", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: paymentID,
|
|
BookingID: bookingID,
|
|
Amount: 30.00,
|
|
Status: "completed",
|
|
Reason: "refund without actor",
|
|
CreatedBy: nil,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRefundRecord failed: %v", err)
|
|
}
|
|
if refundID == "" {
|
|
t.Fatal("expected non-empty refund ID")
|
|
}
|
|
|
|
// Verify created_by is NULL.
|
|
var storedCreatedBy *string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT created_by FROM refunds WHERE id = $1", refundID).Scan(&storedCreatedBy)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if storedCreatedBy != nil {
|
|
t.Errorf("expected created_by NULL, got %q", *storedCreatedBy)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Charge-level aggregation & sweep tests
|
|
// =============================================================================
|
|
|
|
// TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund verifies the
|
|
// P0 fix: two pending refund rows sharing one square_payment_id produce ONE
|
|
// Square refund of the aggregate amount, and both rows complete with the SAME
|
|
// square_refund_id (never one row completed with money unmoved).
|
|
func TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Split payment records sharing one square_payment_id.
|
|
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create deposit payment: %v", err)
|
|
}
|
|
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create balance payment: %v", err)
|
|
}
|
|
sameSquareID := "sqp_aggregate_split"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", sameSquareID, depositID, balanceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// Two pending cancellation refunds (deposit £25 + balance £25).
|
|
for _, pid := range []string{depositID, balanceID} {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
|
|
`, pid, bookingID, pid+"-square-2500")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the charge, got %d", len(calls))
|
|
}
|
|
if calls[0].Amount != 5000 {
|
|
t.Errorf("expected aggregated amount 5000 pence (25+25), got %d", calls[0].Amount)
|
|
}
|
|
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
|
|
|
|
// Both rows completed with the same square_refund_id, amounts preserved.
|
|
rows, err := db.Conn.Query(freshCtx, `
|
|
SELECT amount, status, square_refund_id FROM refunds WHERE booking_id = $1 ORDER BY amount
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var sqIDs []string
|
|
count := 0
|
|
for rows.Next() {
|
|
var amount float64
|
|
var status string
|
|
var sqID *string
|
|
if err := rows.Scan(&amount, &status, &sqID); err != nil {
|
|
t.Fatalf("failed to scan refund row: %v", err)
|
|
}
|
|
count++
|
|
if amount != 25.00 {
|
|
t.Errorf("expected amount 25.00 preserved, got %.2f", amount)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", status)
|
|
}
|
|
if sqID == nil || *sqID == "" {
|
|
t.Error("expected square_refund_id set (money moved)")
|
|
} else {
|
|
sqIDs = append(sqIDs, *sqID)
|
|
}
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 refund rows, got %d", count)
|
|
}
|
|
if len(sqIDs) == 2 && sqIDs[0] != sqIDs[1] {
|
|
t.Errorf("expected both rows to share the SAME square_refund_id, got %q and %q", sqIDs[0], sqIDs[1])
|
|
}
|
|
}
|
|
|
|
// TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed verifies
|
|
// the sweep's terminal pre-pass: a pending cancellation card refund with NO
|
|
// square_payment_id can never be refunded via Square — it is marked failed.
|
|
func TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// online_square payment with square_payment_id NULL (fixture default).
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 50, 'pending', 'client_cancelled', 'cancellation', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// The committed rows live in the SHARED test pool — clean them up or
|
|
// parallel tests that count whole tables see them (test isolation).
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected refund status 'failed', got %q", status)
|
|
}
|
|
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square calls (no square_payment_id), got %d", len(calls))
|
|
}
|
|
|
|
// P1-B: the terminal pre-pass must surface an admin_notifications row for
|
|
// the affected booking (in-person arrangement needed).
|
|
var notifCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed verifies that a
|
|
// pending refund row already at the 3-attempt cap is never processed by the
|
|
// sweep: it is filtered out (refund_attempts < 3), no Square call happens, and
|
|
// its status is left untouched.
|
|
func TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_attempts_exhausted' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// Pending cancellation refund already at the 3-attempt cap.
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, refund_attempts, created_at)
|
|
VALUES ($1, $2, 50, 'pending', 'client_cancelled', 'cancellation', 3, NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// The committed rows live in the SHARED test pool — clean them up or
|
|
// parallel tests that count whole tables see them (test isolation).
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
var attempts int
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected status untouched ('pending'), got %q", status)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected refund_attempts untouched (3), got %d", attempts)
|
|
}
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square calls for an attempts=3 row, got %d", len(calls))
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed verifies the
|
|
// definitive-decline path: each run increments refund_attempts, and the third
|
|
// run marks the rows failed.
|
|
func TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_declined' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
mock.FailRefundCode = "REFUND_DECLINED"
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
|
|
// Run 1 and 2: attempts increment, row stays pending.
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
var status string
|
|
var attempts int
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected refund_attempts 2 after two declined runs, got %d", attempts)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected status 'pending' before the cap is hit, got %q", status)
|
|
}
|
|
|
|
// Run 3: cap hit → failed.
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected status 'failed' after 3 declined runs, got %q", status)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected refund_attempts 3 after three declined runs, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError
|
|
// verifies the tri-state reconcile under the ambiguous path (plain transport
|
|
// error): the row is retried up to the 3-attempt cap, and on the run that hits
|
|
// the cap the reconcile against Square ALSO fails (same transport failure) — an
|
|
// unknown 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(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount != 0 {
|
|
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
|
|
}
|
|
// 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(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount != 0 {
|
|
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed locks the
|
|
// tri-state reconcile nil+nil branch: an aged pending refund (>23h) with NO
|
|
// exact COMPLETED refund at Square (genuine no-match — money provably did not
|
|
// move) is marked 'failed' and surfaced via admin_notification.
|
|
func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_no_match' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert aged pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// The committed rows live in the SHARED test pool — clean them up or
|
|
// parallel tests that count whole tables see them (test isolation).
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
origClient := SquareClient
|
|
// The MockClient records no refunds for this charge → reconcile returns a
|
|
// genuine no-match (nil, nil).
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
counting := &countingRefundClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected aged refund marked 'failed' (Square shows no COMPLETED refund), got %q", status)
|
|
}
|
|
|
|
// The reconcile showed no match — the charge-level refund must NOT fire.
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square refund call on a genuine no-match reconcile, got %d", len(calls))
|
|
}
|
|
|
|
// The terminal failure must surface an admin_notifications row.
|
|
var notifCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// F1 — manual refunds with a stored square_refund_id are reconciled, not dropped
|
|
// =============================================================================
|
|
|
|
// TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled locks the
|
|
// F1 fix: a manual refund the RefundPayment handler left 'pending' WITH a
|
|
// stored square_refund_id (Square's synchronous-PENDING response) is no longer
|
|
// filtered out of the sweep — it is reconciled against Square instead. When
|
|
// Square reports the exact COMPLETED refund, the row resolves to 'completed'
|
|
// and NO new refund is issued (re-issuing would risk a second refund).
|
|
func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
chargeID := "sqp_manual_reconcile_completed"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, square_refund_id, created_at)
|
|
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_completed', NOW() - INTERVAL '5 minutes')
|
|
RETURNING id
|
|
`, paymentID, bookingID, paymentID+"-refund-5000").Scan(&refundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending manual refund with square_refund_id: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// The committed rows live in the SHARED test pool — clean them up or
|
|
// parallel tests that count whole tables see them (test isolation).
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
// Pre-seed the COMPLETED refund Square recorded for this charge — the exact
|
|
// amount, same payment. Simulates the handler's PENDING refund that has
|
|
// since completed at Square.
|
|
seeded, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
|
|
PaymentID: chargeID,
|
|
Amount: 5000,
|
|
IdempotencyKey: "seed-manual-reconcile-completed",
|
|
Reason: "customer request",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square refund: %v", err)
|
|
}
|
|
counting := &countingRefundClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
// The sweep processes the whole shared test database — clear pending rows
|
|
// left by earlier sequential tests so the call count is deterministic.
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
|
|
t.Fatalf("failed to clean leftover pending refunds: %v", err)
|
|
}
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected manual refund with square_refund_id resolved to 'completed' via Square reconcile, got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID != seeded.ID {
|
|
t.Errorf("expected square_refund_id %q (the refund Square recorded), got %v", seeded.ID, squareRefundID)
|
|
}
|
|
|
|
// The reconcile must NOT have re-issued a new Square refund.
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO new Square refund call for a reconcilable row, got %d", len(calls))
|
|
}
|
|
}
|
|
|
|
// TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed locks
|
|
// the F1 no-match branch: a manual refund row WITH a stored square_refund_id
|
|
// whose Square reconcile finds no exact COMPLETED refund (Square never
|
|
// recorded it) is marked 'failed' and surfaced via admin_notification — it no
|
|
// longer blocks the over-refund guard forever.
|
|
func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
chargeID := "sqp_manual_reconcile_nomatch"
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, square_refund_id, created_at)
|
|
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_nomatch', NOW() - INTERVAL '5 minutes')
|
|
RETURNING id
|
|
`, paymentID, bookingID, paymentID+"-refund-5001").Scan(&refundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending manual refund with square_refund_id: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// The committed rows live in the SHARED test pool — clean them up or
|
|
// parallel tests that count whole tables see them (test isolation).
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
origClient := SquareClient
|
|
// The MockClient has no refund for this charge → reconcile is a genuine
|
|
// no-match (nil, nil).
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
counting := &countingRefundClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
|
|
t.Fatalf("failed to clean leftover pending refunds: %v", err)
|
|
}
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected manual refund with no COMPLETED refund at Square marked 'failed', got %q", status)
|
|
}
|
|
|
|
// The no-match reconcile must NOT re-issue a new Square refund.
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square refund call on a no-match reconcile, got %d", len(calls))
|
|
}
|
|
|
|
// The terminal failure must surface an admin_notifications row.
|
|
var notifCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Bug fix — 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(¬ifCount); 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(¬ifCount); 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(¬ifCount); 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(¬ifCount); 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)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MEDIUM 3 — B1 re-poll age escalation (webhook-optional strand fix)
|
|
// =============================================================================
|
|
|
|
// b1RePollStatusClient reports every refund Square holds for a payment as a
|
|
// SINGLE synthetic refund with a fixed id and status, so the B1 re-poll
|
|
// escalation branches are deterministic regardless of what the underlying mock
|
|
// stored.
|
|
type b1RePollStatusClient struct {
|
|
square.SquareClient
|
|
refundID string
|
|
status string
|
|
}
|
|
|
|
func (c *b1RePollStatusClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
|
return []square.RefundResult{{
|
|
ID: c.refundID,
|
|
Status: c.status,
|
|
PaymentID: paymentID,
|
|
}}, nil
|
|
}
|
|
|
|
// seedB1RefundAndPendingParent seeds a payments-table B1 sweep auto-refund row
|
|
// (status 'pending', square_refund_id set, the deterministic sweepdup key) on a
|
|
// still-pending parent payment, aged refundAgeHours back from now, and returns
|
|
// the parent payment id, the refund id and the booking id.
|
|
func seedB1RefundAndPendingParent(t *testing.T, ctx context.Context, tx db.Querier, userID, bookingID string, amount float64, squareRefundID, refundKey string, refundAgeHours int) (paymentID, refundID string) {
|
|
t.Helper()
|
|
pid, err := fixtures.CreateTestPayment(tx, bookingID, amount, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create pending parent payment: %v", err)
|
|
}
|
|
var rid string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
|
|
VALUES ($1, $2, $3, $4, 'pending', 'manual', $5, $6, $7, NOW() - ($8 || ' hours')::interval)
|
|
RETURNING id
|
|
`, pid, bookingID, amount, squareRefundID, sweepDuplicateRefundReason, refundKey, userID, refundAgeHours).Scan(&rid)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert B1 refund row: %v", err)
|
|
}
|
|
return pid, rid
|
|
}
|
|
|
|
// TestSweepPendingB1Refunds_FailedPastAge_Terminal locks the MEDIUM 3 fix: a B1
|
|
// sweep auto-refund Square reports FAILED once it has been pending longer than
|
|
// stalePendingB1RefundAge is terminal — the webhook FAILED-refund reconciliation
|
|
// is OPTIONAL (README), so with it unconfigured the sweep itself must fail the
|
|
// refund, resolve the parent payment and raise the CRITICAL admin notification
|
|
// instead of re-polling forever.
|
|
func TestSweepPendingB1Refunds_FailedPastAge_Terminal(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)
|
|
}
|
|
|
|
const squareRefundID = "ref_b1_failed_terminal"
|
|
const refundKey = "sweepdup-pay_dup_terminal"
|
|
paymentID, refundID := seedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey, 49)
|
|
|
|
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 setup tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = 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
|
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "FAILED"}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var refundStatus string
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if refundStatus != "failed" {
|
|
t.Errorf("expected the stale FAILED B1 refund marked 'failed', got %q", refundStatus)
|
|
}
|
|
|
|
// The parent payment must be resolved to failed — no longer stranded.
|
|
var paymentStatus string
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
|
t.Fatalf("failed to query parent payment: %v", err)
|
|
}
|
|
if paymentStatus != "failed" {
|
|
t.Errorf("expected the parent payment resolved to 'failed', got %q", paymentStatus)
|
|
}
|
|
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the terminal FAILED B1 refund, got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepPendingB1Refunds_FailedYoung_StaysPending locks the pre-threshold
|
|
// behaviour: a B1 auto-refund Square reports FAILED while still younger than
|
|
// stalePendingB1RefundAge stays PENDING (the webhook FAILED-refund
|
|
// reconciliation owns it, and the age escalation has not yet applied) — the
|
|
// parent is NOT failed on a terminal Square status before the threshold.
|
|
func TestSweepPendingB1Refunds_FailedYoung_StaysPending(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)
|
|
}
|
|
|
|
const squareRefundID = "ref_b1_failed_young"
|
|
const refundKey = "sweepdup-pay_dup_young"
|
|
paymentID, refundID := seedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey, 2)
|
|
|
|
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 setup tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = 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
|
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "FAILED"}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var refundStatus string
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if refundStatus != "pending" {
|
|
t.Errorf("expected the young FAILED B1 refund to stay 'pending' (webhook owns it until the age escalation), got %q", refundStatus)
|
|
}
|
|
var paymentStatus string
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
|
t.Fatalf("failed to query parent payment: %v", err)
|
|
}
|
|
if paymentStatus != "pending" {
|
|
t.Errorf("expected the parent payment untouched while the refund is young, got %q", paymentStatus)
|
|
}
|
|
}
|
|
|
|
// TestSweepPendingB1Refunds_PendingPastAge_EscalatedStopsRepoll locks the MEDIUM 3
|
|
// age cap: a B1 auto-refund still PENDING past stalePendingB1RefundAge is
|
|
// escalated (a deduped CRITICAL admin notification fires) and no longer
|
|
// re-polled — a second run with Square now reporting COMPLETED must NOT resolve
|
|
// the parent, proving the escalation stopped the re-poll. The refund stays
|
|
// PENDING (never marked failed on a non-terminal state; never completed without
|
|
// a COMPLETED Square refund).
|
|
func TestSweepPendingB1Refunds_PendingPastAge_EscalatedStopsRepoll(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)
|
|
}
|
|
|
|
const squareRefundID = "ref_b1_pending_esc"
|
|
const refundKey = "sweepdup-pay_dup_pending_esc"
|
|
paymentID, refundID := seedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey, 49)
|
|
|
|
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 setup tx: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = 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
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
// Run 1: Square still reports PENDING past the age threshold → escalation.
|
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "PENDING"}
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds run 1 failed: %v", err)
|
|
}
|
|
|
|
var refundStatus, paymentStatus string
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if refundStatus != "pending" {
|
|
t.Errorf("expected the escalated refund to stay 'pending' (never failed on a non-terminal state), got %q", refundStatus)
|
|
}
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
|
t.Fatalf("failed to query parent payment: %v", err)
|
|
}
|
|
if paymentStatus != "pending" {
|
|
t.Errorf("expected the parent payment to stay pending after escalation, got %q", paymentStatus)
|
|
}
|
|
var notifCount int
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
|
t.Fatalf("failed to count admin notifications: %v", err)
|
|
}
|
|
if notifCount < 1 {
|
|
t.Errorf("expected a critical-payment admin notification for the escalated refund, got %d", notifCount)
|
|
}
|
|
|
|
// Run 2: Square would now report COMPLETED — but the row was escalated, so
|
|
// it is no longer re-polled and the parent must stay pending (a re-poll here
|
|
// would have resolved it). This proves the cap stopped the re-poll.
|
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "COMPLETED"}
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds run 2 failed: %v", err)
|
|
}
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
|
t.Fatalf("failed to re-query refund: %v", err)
|
|
}
|
|
if refundStatus != "pending" {
|
|
t.Errorf("expected the escalated refund to NOT be re-polled (status stays 'pending'), got %q", refundStatus)
|
|
}
|
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
|
t.Fatalf("failed to re-query parent payment: %v", err)
|
|
}
|
|
if paymentStatus != "pending" {
|
|
t.Errorf("expected the parent payment untouched after the escalated row was skipped, got %q", paymentStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// DRIFT-REAL — synchronous-PENDING manual refund must stay pending
|
|
// =============================================================================
|
|
|
|
// TestSweepManualRefund_SyncPendingResponse_LeavesPending locks the DRIFT-REAL
|
|
// semantics for a synchronous PENDING refund response: Square accepts the
|
|
// refund but leaves it PENDING (money in flight, e.g. an async card network) —
|
|
// a NON-terminal state. The refund row must be left 'pending' — never
|
|
// 'completed' (a later Square failure would permanently block the amount in
|
|
// the over-refund guard) — and square_refund_id must NOT be written (only
|
|
// terminal states record the Square reference; a later sweep run re-attempts
|
|
// the same deterministic key, which Square dedups, or re-discovers the refund).
|
|
// Mirrors the processChargeGroup / RefundPayment handler status handling.
|
|
func TestSweepManualRefund_SyncPendingResponse_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, 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_manual_pending_sync' WHERE id = $1", paymentID); 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)
|
|
}
|
|
|
|
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
|
|
// pendingRefundClient forces the synchronous RefundPayment response to
|
|
// PENDING (defined in sweep_test.go).
|
|
SquareClient = &pendingRefundClient{SquareClient: square.NewDevClient()}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
// The sweep processes the whole shared test database — clear any pending
|
|
// rows left by earlier sequential tests so the re-issue below is the only
|
|
// one acting on this payment.
|
|
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 != "pending" {
|
|
t.Errorf("expected a synchronous-PENDING Square refund to leave the row 'pending', got %q", status)
|
|
}
|
|
if squareRefundID != nil && *squareRefundID != "" {
|
|
t.Error("expected square_refund_id NOT to be written for a non-terminal PENDING response (only terminal states record the Square reference)")
|
|
}
|
|
}
|