Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
2875 lines
98 KiB
Go
2875 lines
98 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
)
|
|
|
|
// countingRefundClient wraps a square.SquareClient and records every
|
|
// RefundPayment call so tests can assert exactly-once charge-level refunds.
|
|
type countingRefundClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
calls []square.RefundPaymentReq
|
|
}
|
|
|
|
func (c *countingRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
c.mu.Lock()
|
|
c.calls = append(c.calls, req)
|
|
c.mu.Unlock()
|
|
return c.SquareClient.RefundPayment(ctx, req)
|
|
}
|
|
|
|
func (c *countingRefundClient) refundCalls() []square.RefundPaymentReq {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]square.RefundPaymentReq(nil), c.calls...)
|
|
}
|
|
|
|
// ambiguousRefundClient simulates a transport-level failure (no sentinel
|
|
// error) — Square may or may not have processed the refund.
|
|
type ambiguousRefundClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *ambiguousRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
return nil, fmt.Errorf("network error: connection reset by peer")
|
|
}
|
|
|
|
// ListPaymentRefunds simulates the same transport failure so reconcile paths
|
|
// fail gracefully (log + nil) instead of panicking on the nil embedded client.
|
|
func (c *ambiguousRefundClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
|
return nil, fmt.Errorf("network error: connection reset by peer")
|
|
}
|
|
|
|
// reconcileErrorClient wraps a working SquareClient (so RefundPayment
|
|
// succeeds) but simulates a transport failure on ListPaymentRefunds — the
|
|
// reconcile, not the refund call, fails. Used to lock the tri-state error
|
|
// branch: an unknown reconcile state must leave rows pending.
|
|
type reconcileErrorClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *reconcileErrorClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
|
return nil, fmt.Errorf("network error: connection reset by peer")
|
|
}
|
|
|
|
// slowRefundClient delays the Square call so the manual RefundPayment handler
|
|
// holds its advisory lock long enough that a concurrent cancellation refund
|
|
// would race it without the D1 serialization locks.
|
|
type slowRefundClient struct {
|
|
square.SquareClient
|
|
delay time.Duration
|
|
}
|
|
|
|
func (c *slowRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
time.Sleep(c.delay)
|
|
return c.SquareClient.RefundPayment(ctx, req)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CalculateRefundForCancellation - Pure function tests
|
|
// =============================================================================
|
|
|
|
func TestCalculateRefundForCancellation_FullRefund_Over72h(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) // >72h away
|
|
|
|
result := CalculateRefundForCancellation(100, 50, now, start)
|
|
|
|
if result.Tier != "full_refund_72h" {
|
|
t.Errorf("expected tier 'full_refund_72h', got %q", result.Tier)
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
if result.KeptAmount != 0 {
|
|
t.Errorf("expected kept 0, got %.2f", result.KeptAmount)
|
|
}
|
|
if result.ProtectedDeposit != 50 {
|
|
t.Errorf("expected protected deposit 50, got %.2f", result.ProtectedDeposit)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_PartialRefund_24to72h(t *testing.T) {
|
|
now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC) // ~50h before
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result := CalculateRefundForCancellation(100, 80, now, start)
|
|
|
|
if result.Tier != "partial_refund_24h_72h" {
|
|
t.Errorf("expected tier 'partial_refund_24h_72h', got %q", result.Tier)
|
|
}
|
|
// Protected deposit: min(80, 50) = 50
|
|
// Refundable: 80 - 50 = 30
|
|
if result.ProtectedDeposit != 50 {
|
|
t.Errorf("expected protected deposit 50, got %.2f", result.ProtectedDeposit)
|
|
}
|
|
if result.RefundableAmount != 30 {
|
|
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
|
|
}
|
|
if result.KeptAmount != 50 {
|
|
t.Errorf("expected kept 50, got %.2f", result.KeptAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_NoRefund_Under24h(t *testing.T) {
|
|
now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC) // 1h before
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result := CalculateRefundForCancellation(100, 100, now, start)
|
|
|
|
if result.Tier != "no_refund_under_24h" {
|
|
t.Errorf("expected tier 'no_refund_under_24h', got %q", result.Tier)
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount)
|
|
}
|
|
if result.KeptAmount != 100 {
|
|
t.Errorf("expected kept 100, got %.2f", result.KeptAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_NoShow_KeptAll(t *testing.T) {
|
|
now := time.Date(2099, 12, 31, 12, 0, 0, 0, time.UTC) // past start
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result := CalculateRefundForCancellation(100, 50, now, start)
|
|
|
|
if result.Tier != "no_refund_under_24h" {
|
|
t.Errorf("expected tier 'no_refund_under_24h', got %q", result.Tier)
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0 for no-show, got %.2f", result.RefundableAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_ProtectedDepositCappedAt50Pct(t *testing.T) {
|
|
now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// Paid 200 on a 300 total — protected deposit caps at 150 (50% of 300)
|
|
result := CalculateRefundForCancellation(300, 200, now, start)
|
|
|
|
if result.ProtectedDeposit != 150 {
|
|
t.Errorf("expected protected deposit 150 (50%% of 300), got %.2f", result.ProtectedDeposit)
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50 (200-150), got %.2f", result.RefundableAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_PaidLessThan50Pct(t *testing.T) {
|
|
now := time.Date(2099, 12, 30, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// Paid 30 on a 200 total — protected deposit = min(30, 100) = 30
|
|
result := CalculateRefundForCancellation(200, 30, now, start)
|
|
|
|
if result.ProtectedDeposit != 30 {
|
|
t.Errorf("expected protected deposit 30, got %.2f", result.ProtectedDeposit)
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0 (30-30), got %.2f", result.RefundableAmount)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_Exact72hBoundary(t *testing.T) {
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
now := start.Add(-72 * time.Hour) // exactly 72h before (not >72)
|
|
|
|
result := CalculateRefundForCancellation(100, 100, now, start)
|
|
|
|
// Exactly 72h is NOT >72 — falls into partial refund tier
|
|
if result.Tier != "partial_refund_24h_72h" {
|
|
t.Errorf("expected partial refund at exactly 72h, got %q", result.Tier)
|
|
}
|
|
}
|
|
|
|
func TestCalculateRefundForCancellation_Exact24hBoundary(t *testing.T) {
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
now := start.Add(-24 * time.Hour) // exactly 24h before
|
|
|
|
result := CalculateRefundForCancellation(100, 100, now, start)
|
|
|
|
// Exactly 24h should be >=24 — partial refund
|
|
if result.Tier != "partial_refund_24h_72h" {
|
|
t.Errorf("expected partial refund at exactly 24h, got %q", result.Tier)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefund - Integration tests
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx,
|
|
"UPDATE bookings SET deposit_required = true WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposit_required: %v", err)
|
|
}
|
|
|
|
// Add a completed payment
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
// Cancel >72h before — full refund expected
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefund(ctx, bookingID, 50, 50, start, now, "client_cancelled", &userID)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Check refund record was created
|
|
var refundCount int
|
|
tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record, got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefund_NoRefundWhenNotNeeded(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Cancel <24h before — refundable should be 0
|
|
now := time.Date(2099, 12, 31, 9, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefund(ctx, bookingID, 100, 0, start, now, "no_show", &userID)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefund(ctx, bookingID, 100, 0, start, now, "client_cancelled", &userID)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0 when nothing paid, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
var refundCount int
|
|
tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if refundCount != 0 {
|
|
t.Errorf("expected 0 refund records, got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefund — gift card refund routing
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
var giftCardID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date, last_used_at)
|
|
VALUES (100, 40, $1, false, NULL, NOW())
|
|
RETURNING id
|
|
`, userID).Scan(&giftCardID); err != nil {
|
|
t.Fatalf("failed to create gift card: %v", err)
|
|
}
|
|
|
|
var paymentID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
|
|
VALUES ($1, 'full', 'giftcard', 'completed', 60, $2, NOW(), NOW())
|
|
RETURNING id
|
|
`, bookingID, giftCardID).Scan(&paymentID); err != nil {
|
|
t.Fatalf("failed to create giftcard payment: %v", err)
|
|
}
|
|
|
|
// Booking is far in the future — full refund.
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 60,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 60 {
|
|
t.Errorf("expected refundable 60 (full refund >72h), got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
var amountRemaining float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining)
|
|
if err != nil {
|
|
t.Fatalf("failed to query gift card balance: %v", err)
|
|
}
|
|
if amountRemaining != 100 {
|
|
t.Errorf("expected gift card amount_remaining 100 (40 + 60), got %.2f", amountRemaining)
|
|
}
|
|
|
|
// Verify refund record exists (primary audit trail for cancellation refunds).
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record, got %d", refundCount)
|
|
}
|
|
|
|
var txCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query gift card transactions: %v", err)
|
|
}
|
|
if txCount != 1 {
|
|
t.Errorf("expected 1 gift card refund transaction, got %d", txCount)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create a cash payment of 30.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create cash payment: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 30,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 30 {
|
|
t.Errorf("expected refundable 30, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Verify user balance was credited.
|
|
var balance float64
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
|
|
if err != nil {
|
|
t.Fatalf("failed to query balance: %v", err)
|
|
}
|
|
if balance != 30 {
|
|
t.Errorf("expected user balance 30, got %.2f", balance)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create an online_square payment — this will be handled by Square mock.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
|
|
farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
ctx, bookingID, 100, 100,
|
|
farFuture, clock.Now(), "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 100 {
|
|
t.Errorf("expected refundable 100, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Square API refund is processed AFTER the transaction commits (see
|
|
// ProcessPendingSquareRefunds). In dev/test the payment has no
|
|
// square_payment_id, so the pending cancellation refund can never be
|
|
// issued via Square — it is marked "failed" (no API call possible) and
|
|
// surfaced for in-person arrangement. No balance credit is generated.
|
|
var status string
|
|
err = tx.QueryRow(ctx, "SELECT status FROM refunds WHERE booking_id = $1", bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected refund status 'failed' (card refund with no Square reference), got %q", status)
|
|
}
|
|
|
|
// P1-B: the pre-pass must surface an admin_notifications row for the
|
|
// affected booking (in-person arrangement needed).
|
|
var notifCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬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)
|
|
}
|
|
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
|
|
t.Errorf("expected charge-level idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
|
|
}
|
|
|
|
// Both refund records must be completed and share ONE square_refund_id —
|
|
// the old bug left one record completed with square_refund_id NULL (no
|
|
// money moved for it).
|
|
var refundCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 2 {
|
|
t.Errorf("expected 2 refund records, got %d", refundCount)
|
|
}
|
|
|
|
rows, err := db.Conn.Query(freshCtx, `
|
|
SELECT amount, status, square_refund_id FROM refunds WHERE booking_id = $1 ORDER BY amount
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund rows: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var refundIDs []string
|
|
refundCount = 0
|
|
for rows.Next() {
|
|
var amount float64
|
|
var status string
|
|
var sqRefundID *string
|
|
if err := rows.Scan(&amount, &status, &sqRefundID); err != nil {
|
|
t.Fatalf("failed to scan refund row: %v", err)
|
|
}
|
|
refundCount++
|
|
if amount != 25.00 {
|
|
t.Errorf("expected refund amount 25.00, got %.2f", amount)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected refund status 'completed', got %q", status)
|
|
}
|
|
if sqRefundID == nil || *sqRefundID == "" {
|
|
t.Error("expected square_refund_id to be set — the old bug left it NULL with money not moved")
|
|
} else {
|
|
refundIDs = append(refundIDs, *sqRefundID)
|
|
}
|
|
}
|
|
if refundCount != 2 {
|
|
t.Errorf("expected 2 refund rows, got %d", refundCount)
|
|
}
|
|
if len(refundIDs) == 2 && refundIDs[0] != refundIDs[1] {
|
|
t.Errorf("expected both records to share the SAME square_refund_id, got %q and %q", refundIDs[0], refundIDs[1])
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessPendingSquareRefunds — post-commit Square refund processing
|
|
// =============================================================================
|
|
|
|
// TestProcessPendingSquareRefunds_ProcessesPendingRecords verifies that
|
|
// ProcessPendingSquareRefunds resolves a pending cancellation card refund whose
|
|
// payment has NO square_payment_id: it can never be refunded via Square, so it
|
|
// is marked "failed" (terminal pre-pass) rather than silently completed.
|
|
func TestProcessPendingSquareRefunds_ProcessesPendingRecords(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
// Create an online_square payment
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
|
|
// Manually insert a "pending" refund record (simulating what ProcessCancellationRefundTx creates)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
SELECT id, $1, amount, 'pending', 'client_cancelled', 'cancellation', NOW()
|
|
FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
// Commit the test transaction so the refund records are persisted.
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
// Use a fresh context (no closed transaction) so db.Conn falls through to pool.
|
|
freshCtx := context.Background()
|
|
|
|
// Now call the post-commit function
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// The card refund has no Square reference — it must be marked failed.
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE booking_id = $1`, bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected refund status 'failed' (no square_payment_id), got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_SkipsCompletedRecords verifies that
|
|
// ProcessPendingSquareRefunds does not modify already-completed refunds.
|
|
func TestProcessPendingSquareRefunds_SkipsCompletedRecords(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Create a payment first
|
|
var paymentID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (booking_id, amount, payment_method, payment_type, status)
|
|
VALUES ($1, 50, 'cash', 'full', 'completed') RETURNING id
|
|
`, bookingID).Scan(&paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
// Insert a "completed" refund directly (simulating non-Square refund path)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
|
VALUES ($1, $2, 50, 'completed', 'cash_refund', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert completed refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// Verify the completed refund was left untouched
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE booking_id = $1`, bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected existing status 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_IssuesRefundWithSquareCall verifies that a
|
|
// pending cancellation refund on a card charge with a square_payment_id is
|
|
// actually issued to Square (real API call) and completed with the returned
|
|
// square_refund_id. The charge-level idempotency key is used for the call.
|
|
func TestProcessPendingSquareRefunds_IssuesRefundWithSquareCall(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Create an online_square payment with a square_payment_id so the scheduler
|
|
// calls the mock.
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_stored_key' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// Seed a pending CANCELLATION refund with a stored idempotency key.
|
|
storedKey := paymentID + "-square-2500"
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
|
|
RETURNING id
|
|
`, paymentID, bookingID, storedKey).Scan(&refundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
// Commit the test tx so the refund rows persist (scheduler reads via pool).
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// The refund must be completed with a square_refund_id — the Square call
|
|
// happened (with the stable charge-level idempotency key).
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected refund status 'completed', got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID == "" {
|
|
t.Error("expected square_refund_id to be set (Square was called)")
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund verifies that
|
|
// a pending cancellation refund on a split record sharing a square_payment_id
|
|
// with an already-completed manual refund is ISSUED (money moved) for the
|
|
// residual — the amount-blind suppression (the P0 bug) would have marked it
|
|
// completed with no money moving.
|
|
func TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Split payment records sharing one square_payment_id (deposit + balance).
|
|
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create deposit payment: %v", err)
|
|
}
|
|
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create balance payment: %v", err)
|
|
}
|
|
sameSquareID := "sqp_same_charge_pending"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", sameSquareID, depositID, balanceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// A completed MANUAL refund on the deposit record (money already moved back).
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, square_refund_id, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'completed', 'manual refund', 'ref_seeded', $3, 'manual', NOW())
|
|
`, depositID, bookingID, depositID+"-refund-2500")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert completed refund: %v", err)
|
|
}
|
|
|
|
// A pending CANCELLATION refund on the balance record sharing the charge.
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
|
|
`, balanceID, bookingID, balanceID+"-square-2500")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
// Commit the test tx so the refund rows persist (scheduler reads via pool).
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
// The residual £25 must be ISSUED — a real Square call happens (the old
|
|
// amount-blind suppression marked it completed with NO money moved).
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the residual, got %d", len(calls))
|
|
}
|
|
if calls[0].Amount != 2500 {
|
|
t.Errorf("expected Square refund of 2500 pence (the residual), got %d", calls[0].Amount)
|
|
}
|
|
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
|
|
t.Errorf("expected charge-level idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
|
|
}
|
|
|
|
// The balance pending refund must be completed WITH a square_refund_id —
|
|
// money moved, NOT left NULL.
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, balanceID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected balance refund status 'completed', got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID == "" {
|
|
t.Error("expected square_refund_id to be set (money moved) — the P0 bug left it NULL")
|
|
}
|
|
|
|
// The completed manual refund is untouched.
|
|
var manualStatus string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, depositID).Scan(&manualStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query manual refund: %v", err)
|
|
}
|
|
if manualStatus != "completed" {
|
|
t.Errorf("expected manual refund status 'completed', got %q", manualStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProcessCancellationRefundTx — transactional variant
|
|
// =============================================================================
|
|
|
|
func TestProcessCancellationRefundTx_Success(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET deposit_required = true WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set deposit_required: %v", err)
|
|
}
|
|
|
|
// Add a completed cash payment.
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 50, "cash", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
// Cancel >72h before — full refund expected.
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.RefundableAmount != 50 {
|
|
t.Errorf("expected refundable 50, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// Check refund record was created.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund record, got %d", refundCount)
|
|
}
|
|
|
|
// Check refund was recorded in the inner transaction (the record is visible
|
|
// because ProcessCancellationRefundTx writes to the same tx).
|
|
var refundAmount float64
|
|
err = innerTx.QueryRow(ctx,
|
|
"SELECT amount FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundAmount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund amount: %v", err)
|
|
}
|
|
if refundAmount != 50 {
|
|
t.Errorf("expected refund amount 50, got %.2f", refundAmount)
|
|
}
|
|
}
|
|
|
|
func TestProcessCancellationRefundTx_NoRefundNeeded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Cancel <24h before no-show — refundable should be 0.
|
|
now := time.Date(2099, 12, 31, 12, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 100, 0, start, now, "no_show", &userID, false)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result.RefundableAmount != 0 {
|
|
t.Errorf("expected refundable 0, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
// No refund records should be created.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 0 {
|
|
t.Errorf("expected 0 refund records, got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
// TestProcessCancellationRefund_DoubleCancel_FailedRow_Dedups locks the P3a fix:
|
|
// a second cancel whose prior refund row is 'failed' (money never moved at
|
|
// Square, so the residual is recomputed in full) must NOT crash on the UNIQUE
|
|
// idempotency_key. The ON CONFLICT DO NOTHING dedup skips the INSERT without
|
|
// creating a duplicate row and without touching the original 'failed' row.
|
|
func TestProcessCancellationRefund_DoubleCancel_FailedRow_Dedups(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// >72h before the appointment → full refund.
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
// Square-backed payment (not Square-less) so the row is recorded 'pending'
|
|
// with the deterministic idempotency key paymentID-square-5000.
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", "sqp_double_cancel", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
now := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
|
|
// First cancel creates the pending refund row with the deterministic key.
|
|
result, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false)
|
|
if err != nil {
|
|
t.Fatalf("first ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
if result == nil || result.RefundableAmount != 50 {
|
|
t.Fatalf("expected full refund of 50, got %+v", result)
|
|
}
|
|
|
|
var rowCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count refund rows after first cancel: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Fatalf("expected 1 refund row after first cancel, got %d", rowCount)
|
|
}
|
|
var rowKey string
|
|
err = tx.QueryRow(ctx, "SELECT idempotency_key FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund idempotency_key: %v", err)
|
|
}
|
|
if rowKey != paymentID+"-square-5000" {
|
|
t.Fatalf("expected idempotency_key %q, got %q", paymentID+"-square-5000", rowKey)
|
|
}
|
|
|
|
// Simulate the sweep failing all 3 attempts: the row flips to 'failed'.
|
|
// 'failed' is deliberately excluded from the residual sum (money never
|
|
// moved at Square), so a second cancel recomputes the FULL residual and
|
|
// attempts to INSERT the same idempotency key again.
|
|
_, err = tx.Exec(ctx, "UPDATE refunds SET status = 'failed' WHERE payment_id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to flip refund row to failed: %v", err)
|
|
}
|
|
|
|
// Second cancel: same payment, same amount, same key. The ON CONFLICT
|
|
// (idempotency_key) DO NOTHING must dedup — no error, no duplicate row,
|
|
// and the original 'failed' row untouched.
|
|
if _, err := ProcessCancellationRefundTx(ctx, innerTx, bookingID, 50, 50, start, now, "client_cancelled", &userID, false); err != nil {
|
|
t.Fatalf("second ProcessCancellationRefundTx failed: %v", err)
|
|
}
|
|
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count refund rows after second cancel: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Errorf("expected exactly 1 refund row after dedup, got %d", rowCount)
|
|
}
|
|
var rowStatus string
|
|
err = tx.QueryRow(ctx, "SELECT status FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if rowStatus != "failed" {
|
|
t.Errorf("expected original row still 'failed' (untouched), got %q", rowStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CreateRefundRecord — PaymentService method
|
|
// =============================================================================
|
|
|
|
func TestCreateRefundRecord_Success(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: paymentID,
|
|
BookingID: bookingID,
|
|
Amount: 25.00,
|
|
Status: "completed",
|
|
Reason: "partial refund",
|
|
CreatedBy: &userID,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRefundRecord failed: %v", err)
|
|
}
|
|
if refundID == "" {
|
|
t.Fatal("expected non-empty refund ID")
|
|
}
|
|
|
|
// Verify the refund record exists in the DB.
|
|
var storedAmount float64
|
|
var storedStatus string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT amount, status FROM refunds WHERE id = $1", refundID).Scan(&storedAmount, &storedStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if storedAmount != 25.00 {
|
|
t.Errorf("expected amount 25.00, got %.2f", storedAmount)
|
|
}
|
|
if storedStatus != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", storedStatus)
|
|
}
|
|
}
|
|
|
|
func TestCreateRefundRecord_WithSquareRefundID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
squareRefundID := "sqr_test_refund_123"
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: paymentID,
|
|
BookingID: bookingID,
|
|
Amount: 100.00,
|
|
SquareRefundID: &squareRefundID,
|
|
Status: "completed",
|
|
Reason: "full square refund",
|
|
CreatedBy: &userID,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRefundRecord failed: %v", err)
|
|
}
|
|
if refundID == "" {
|
|
t.Fatal("expected non-empty refund ID")
|
|
}
|
|
|
|
// Verify the square_refund_id was stored.
|
|
var storedSquareRefundID *string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT square_refund_id FROM refunds WHERE id = $1", refundID).Scan(&storedSquareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if storedSquareRefundID == nil || *storedSquareRefundID != squareRefundID {
|
|
t.Errorf("expected square_refund_id %q, got %v", squareRefundID, storedSquareRefundID)
|
|
}
|
|
}
|
|
|
|
func TestCreateRefundRecord_NilCreatedBy(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
// Need a user for the booking.
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30.00, "cash", "partial", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
refundID, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: paymentID,
|
|
BookingID: bookingID,
|
|
Amount: 30.00,
|
|
Status: "completed",
|
|
Reason: "refund without actor",
|
|
CreatedBy: nil,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRefundRecord failed: %v", err)
|
|
}
|
|
if refundID == "" {
|
|
t.Fatal("expected non-empty refund ID")
|
|
}
|
|
|
|
// Verify created_by is NULL.
|
|
var storedCreatedBy *string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT created_by FROM refunds WHERE id = $1", refundID).Scan(&storedCreatedBy)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if storedCreatedBy != nil {
|
|
t.Errorf("expected created_by NULL, got %q", *storedCreatedBy)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Charge-level aggregation & sweep tests
|
|
// =============================================================================
|
|
|
|
// TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund verifies the
|
|
// P0 fix: two pending refund rows sharing one square_payment_id produce ONE
|
|
// Square refund of the aggregate amount, and both rows complete with the SAME
|
|
// square_refund_id (never one row completed with money unmoved).
|
|
func TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Split payment records sharing one square_payment_id.
|
|
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create deposit payment: %v", err)
|
|
}
|
|
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create balance payment: %v", err)
|
|
}
|
|
sameSquareID := "sqp_aggregate_split"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", sameSquareID, depositID, balanceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
// Two pending cancellation refunds (deposit £25 + balance £25).
|
|
for _, pid := range []string{depositID, balanceID} {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
|
|
`, pid, bookingID, pid+"-square-2500")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the charge, got %d", len(calls))
|
|
}
|
|
if calls[0].Amount != 5000 {
|
|
t.Errorf("expected aggregated amount 5000 pence (25+25), got %d", calls[0].Amount)
|
|
}
|
|
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
|
|
t.Errorf("expected idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
|
|
}
|
|
|
|
// Both rows completed with the same square_refund_id, amounts preserved.
|
|
rows, err := db.Conn.Query(freshCtx, `
|
|
SELECT amount, status, square_refund_id FROM refunds WHERE booking_id = $1 ORDER BY amount
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var sqIDs []string
|
|
count := 0
|
|
for rows.Next() {
|
|
var amount float64
|
|
var status string
|
|
var sqID *string
|
|
if err := rows.Scan(&amount, &status, &sqID); err != nil {
|
|
t.Fatalf("failed to scan refund row: %v", err)
|
|
}
|
|
count++
|
|
if amount != 25.00 {
|
|
t.Errorf("expected amount 25.00 preserved, got %.2f", amount)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", status)
|
|
}
|
|
if sqID == nil || *sqID == "" {
|
|
t.Error("expected square_refund_id set (money moved)")
|
|
} else {
|
|
sqIDs = append(sqIDs, *sqID)
|
|
}
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 refund rows, got %d", count)
|
|
}
|
|
if len(sqIDs) == 2 && sqIDs[0] != sqIDs[1] {
|
|
t.Errorf("expected both rows to share the SAME square_refund_id, got %q and %q", sqIDs[0], sqIDs[1])
|
|
}
|
|
}
|
|
|
|
// TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed verifies
|
|
// the sweep's terminal pre-pass: a pending cancellation card refund with NO
|
|
// square_payment_id can never be refunded via Square — it is marked failed.
|
|
func TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// online_square payment with square_payment_id NULL (fixture default).
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 50, 'pending', 'client_cancelled', 'cancellation', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected refund status 'failed', got %q", status)
|
|
}
|
|
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square calls (no square_payment_id), got %d", len(calls))
|
|
}
|
|
|
|
// P1-B: the terminal pre-pass must surface an admin_notifications row for
|
|
// the affected booking (in-person arrangement needed).
|
|
var notifCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬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)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var status string
|
|
var attempts int
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected status untouched ('pending'), got %q", status)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected refund_attempts untouched (3), got %d", attempts)
|
|
}
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square calls for an attempts=3 row, got %d", len(calls))
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed verifies the
|
|
// definitive-decline path: each run increments refund_attempts, and the third
|
|
// run marks the rows failed.
|
|
func TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_declined' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
mock.FailRefundCode = "REFUND_DECLINED"
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
|
|
// Run 1 and 2: attempts increment, row stays pending.
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
var status string
|
|
var attempts int
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected refund_attempts 2 after two declined runs, got %d", attempts)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected status 'pending' before the cap is hit, got %q", status)
|
|
}
|
|
|
|
// Run 3: cap hit → failed.
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected status 'failed' after 3 declined runs, got %q", status)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected refund_attempts 3 after three declined runs, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError
|
|
// verifies the tri-state reconcile under the ambiguous path (plain transport
|
|
// error): the row is retried up to the 3-attempt cap, and on the run that hits
|
|
// the cap the reconcile against Square ALSO fails (same transport failure) — an
|
|
// unknown state. The row MUST stay 'pending' (NOT 'failed', which would let the
|
|
// over-refund guard exclude money that may have moved) and NO admin_notification
|
|
// is inserted; the next sweep retries the reconcile.
|
|
func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_ambiguous' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &ambiguousRefundClient{}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
for i := 0; i < 3; i++ {
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
}
|
|
|
|
var status string
|
|
var attempts int
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected status 'pending' after 3 ambiguous runs with a failing reconcile, got %q", status)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected refund_attempts 3 after three ambiguous runs, got %d", attempts)
|
|
}
|
|
|
|
// The terminal failure must NOT fire: the reconcile returned a network
|
|
// error (unknown state), so the row stays pending and no admin
|
|
// notification is inserted.
|
|
var notifCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if notifCount != 0 {
|
|
t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount)
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual
|
|
// verifies the per-record prior-refund subtraction: after a £30 manual refund
|
|
// on the deposit record, a £70 cancellation refund issues the residual (£20
|
|
// deposit + £50 balance) as ONE aggregate £70 Square refund.
|
|
func TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to confirm booking: %v", err)
|
|
}
|
|
|
|
sameSquareID := "sqp_residual"
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
|
|
depositID, err := svc.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "deposit",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: 50.00,
|
|
SquarePaymentID: &sameSquareID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create deposit record: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "balance",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: 50.00,
|
|
SquarePaymentID: &sameSquareID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create balance record: %v", err)
|
|
}
|
|
|
|
// £30 manually refunded on the deposit record (completed, origin='manual').
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, square_refund_id, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 30, 'completed', 'manual refund', 'ref_manual_30', $3, 'manual', NOW())
|
|
`, depositID, bookingID, depositID+"-refund-3000")
|
|
if err != nil {
|
|
t.Fatalf("failed to insert manual refund: %v", err)
|
|
}
|
|
|
|
// Commit the setup: ProcessCancellationRefund acquires pg_advisory_xact_lock
|
|
// on the card payments, and in the test env those locks would be held by the
|
|
// outer per-test transaction (savepoints don't release xact locks) — which
|
|
// would deadlock the post-commit sweep's session locks. Running at pool level
|
|
// mirrors production, where the cancellation tx commits and releases the locks.
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
freshCtx := context.Background()
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
// Cancel >72h before with net paid £70 (after the £30 manual refund).
|
|
// Refundable = £70 → the loop must only issue the residual.
|
|
farFuture := time.Date(2099, 12, 28, 8, 0, 0, 0, time.UTC)
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
result, err := ProcessCancellationRefund(
|
|
freshCtx, bookingID, 100, 70,
|
|
start, farFuture, "client_cancelled", &userID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ProcessCancellationRefund failed: %v", err)
|
|
}
|
|
if result.RefundableAmount != 70 {
|
|
t.Errorf("expected refundable 70, got %.2f", result.RefundableAmount)
|
|
}
|
|
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the residual, got %d", len(calls))
|
|
}
|
|
if calls[0].Amount != 7000 {
|
|
t.Errorf("expected aggregated residual of 7000 pence (£70), got %d", calls[0].Amount)
|
|
}
|
|
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
|
|
t.Errorf("expected idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
|
|
}
|
|
|
|
// Two pending rows created for the residual: deposit £20 + balance £50.
|
|
rows, err := db.Conn.Query(freshCtx, `
|
|
SELECT amount, status, origin FROM refunds
|
|
WHERE booking_id = $1 AND origin = 'cancellation' ORDER BY amount
|
|
`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query cancellation refunds: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var amounts []float64
|
|
count := 0
|
|
for rows.Next() {
|
|
var amount float64
|
|
var status, origin string
|
|
if err := rows.Scan(&amount, &status, &origin); err != nil {
|
|
t.Fatalf("failed to scan refund row: %v", err)
|
|
}
|
|
count++
|
|
amounts = append(amounts, amount)
|
|
if status != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", status)
|
|
}
|
|
}
|
|
if count != 2 {
|
|
t.Fatalf("expected 2 cancellation refund rows, got %d", count)
|
|
}
|
|
if amounts[0] != 20.00 || amounts[1] != 50.00 {
|
|
t.Errorf("expected residual amounts £20 + £50, got %.2f + %.2f", amounts[0], amounts[1])
|
|
}
|
|
|
|
// The manual refund row is untouched.
|
|
var manualStatus string
|
|
var manualAmount float64
|
|
err = db.Conn.QueryRow(freshCtx, "SELECT status, amount FROM refunds WHERE idempotency_key = $1", depositID+"-refund-3000").Scan(&manualStatus, &manualAmount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query manual refund: %v", err)
|
|
}
|
|
if manualStatus != "completed" {
|
|
t.Errorf("expected manual refund status 'completed', got %q", manualStatus)
|
|
}
|
|
if manualAmount != 30.00 {
|
|
t.Errorf("expected manual refund amount 30.00, got %.2f", manualAmount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// D1 — cancellation loop serializes against a concurrent manual refund
|
|
// =============================================================================
|
|
|
|
// TestCancellationRefund_SerializesAgainstManualRefund proves the D1 fix: the
|
|
// cancellation refund loop acquires the same per-payment advisory locks as the
|
|
// manual RefundPayment handler, so the two can never BOTH read zero prior
|
|
// refunds and both refund the same payment. Without the locks, a manual refund
|
|
// whose guard read happens before the cancellation tx commits over-refunds the
|
|
// payment by the full amount. The manual handler's Square call is slowed so the
|
|
// window is wide enough that the racy interleaving would occur without the fix.
|
|
func TestCancellationRefund_SerializesAgainstManualRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_serialize' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
// Commit the setup so both goroutines operate at pool level — the advisory
|
|
// locks only serialize across independent connections, and a per-test tx
|
|
// would route one side's reads through a single shared connection.
|
|
innerTx := db.TxFromContext(ctx)
|
|
if innerTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := innerTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit setup tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
slow := &slowRefundClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
|
SquareClient = slow
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pool := context.Background()
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
req := RefundRequest{Amount: 10000, Reason: "customer request"}
|
|
|
|
var wg sync.WaitGroup
|
|
startBoth := make(chan struct{})
|
|
var manualCode int
|
|
var manualErr, cancelErr error
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-startBoth
|
|
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, pool)
|
|
manualCode = rec.Code
|
|
}()
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-startBoth
|
|
cancelTx, err := db.Conn.Pool().Begin(pool)
|
|
if err != nil {
|
|
cancelErr = err
|
|
return
|
|
}
|
|
defer func() {
|
|
// Rollback is a no-op after a successful commit.
|
|
_ = cancelTx.Rollback(pool)
|
|
}()
|
|
if _, err := ProcessCancellationRefundTx(pool, cancelTx, bookingID, 100, 100, start, clock.Now(), "client_cancelled", &userID, false); err != nil {
|
|
cancelErr = err
|
|
return
|
|
}
|
|
cancelErr = cancelTx.Commit(pool)
|
|
}()
|
|
close(startBoth)
|
|
wg.Wait()
|
|
|
|
if manualErr != nil {
|
|
t.Fatalf("manual refund goroutine failed: %v", manualErr)
|
|
}
|
|
if cancelErr != nil {
|
|
t.Fatalf("cancellation goroutine failed: %v", cancelErr)
|
|
}
|
|
|
|
// Manual outcome: 200 (manual won, cancellation created no row) or 400
|
|
// (cancellation's committed pending row blocked the manual over-refund).
|
|
if manualCode != http.StatusOK && manualCode != http.StatusBadRequest {
|
|
t.Errorf("expected manual refund 200 (won) or 400 (blocked by cancellation), got %d", manualCode)
|
|
}
|
|
|
|
// Money-correctness invariant: at most ONE refund row resolves the payment
|
|
// and the total never exceeds the £100 payment.
|
|
var refundCount int
|
|
err = db.Conn.QueryRow(pool,
|
|
`SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status IN ('completed','pending')`, paymentID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount > 1 {
|
|
t.Errorf("over-refund: %d refund rows for a single £100 payment", refundCount)
|
|
}
|
|
var refundedPence int64
|
|
err = db.Conn.QueryRow(pool,
|
|
`SELECT COALESCE(SUM(ROUND(amount * 100)), 0)::bigint FROM refunds WHERE payment_id = $1 AND status IN ('completed','pending')`, paymentID).Scan(&refundedPence)
|
|
if err != nil {
|
|
t.Fatalf("failed to sum refunds: %v", err)
|
|
}
|
|
if refundedPence > 10000 {
|
|
t.Errorf("over-refund: %d pence refunded on a 10000-pence payment", refundedPence)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// D2 — the sweep retries stale MANUAL pending refunds with their own key
|
|
// =============================================================================
|
|
|
|
// TestSweepPendingSquareRefunds_RetriesStaleManualRefund verifies the D2 fix:
|
|
// a manual refund left 'pending' by the handler's ambiguous-error path is
|
|
// retried by the sweep with the row's OWN stored idempotency key (never the
|
|
// -square-agg key), and resolves to completed.
|
|
func TestSweepPendingSquareRefunds_RetriesStaleManualRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_manual_retry' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
storedKey := paymentID + "-refund-5000"
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', NOW())
|
|
RETURNING id
|
|
`, paymentID, bookingID, storedKey).Scan(&refundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert stale manual pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
// The sweep processes the whole shared test database — clear any pending
|
|
// rows left by earlier sequential tests (e.g. the D1 concurrency test when
|
|
// the cancellation won) so the call count below is deterministic.
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
|
|
t.Fatalf("failed to clean leftover pending refunds: %v", err)
|
|
}
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call for the stale manual refund, got %d", len(calls))
|
|
}
|
|
if calls[0].IdempotencyKey != storedKey {
|
|
t.Errorf("expected the row's OWN stored idempotency key %q, got %q", storedKey, calls[0].IdempotencyKey)
|
|
}
|
|
if calls[0].Amount != 5000 {
|
|
t.Errorf("expected refund of 5000 pence, got %d", calls[0].Amount)
|
|
}
|
|
if calls[0].PaymentID != "sqp_manual_retry" {
|
|
t.Errorf("expected Square payment id sqp_manual_retry, got %q", calls[0].PaymentID)
|
|
}
|
|
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected refund status 'completed', got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID == "" {
|
|
t.Error("expected square_refund_id to be set")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// D3 — the 23h age guard reconciles against Square before marking failed
|
|
// =============================================================================
|
|
|
|
// TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund verifies
|
|
// the D3 fix: an aged pending refund (>23h) whose money actually moved at
|
|
// Square (pre-seeded COMPLETED refund via MockClient) is resolved to completed
|
|
// instead of failed — no new Square call is issued, and the square_refund_id
|
|
// matches the refund Square already recorded.
|
|
func TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_reconcile' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
|
|
RETURNING id
|
|
`, paymentID, bookingID).Scan(&refundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert aged pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
// Pre-seed the COMPLETED refund Square recorded for this charge — the exact
|
|
// amount, same payment. This simulates the money already having moved.
|
|
seeded, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
|
|
PaymentID: "sqp_age_reconcile",
|
|
Amount: 2500,
|
|
IdempotencyKey: "seed-age-guard",
|
|
Reason: "client_cancelled",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to seed completed Square refund: %v", err)
|
|
}
|
|
counting := &countingRefundClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
var status string
|
|
var squareRefundID *string
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected aged refund resolved to 'completed' (money moved at Square), got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID != seeded.ID {
|
|
t.Errorf("expected square_refund_id %q (the refund Square recorded), got %v", seeded.ID, squareRefundID)
|
|
}
|
|
|
|
// The reconcile must NOT have re-issued a new Square refund.
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO new Square refund call during reconcile, got %d", len(calls))
|
|
}
|
|
}
|
|
|
|
// TestProcessPendingSquareRefunds_AgeGuard_ReconcileError_LeavesPending locks
|
|
// the tri-state reconcile error branch: an aged pending refund (>23h) whose
|
|
// reconcile against Square FAILS (network error) must stay 'pending' — NOT
|
|
// 'failed', which would let the over-refund guard exclude money that may have
|
|
// moved at Square. The charge-level refund must NOT be re-issued either.
|
|
func TestProcessPendingSquareRefunds_AgeGuard_ReconcileError_LeavesPending(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create card payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_age_reconcile_error' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'client_cancelled', 'cancellation', NOW() - INTERVAL '25 hours')
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert aged pending refund: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
if pgxTx == nil {
|
|
t.Fatal("no transaction in context")
|
|
}
|
|
if err := pgxTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit test tx: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
recErr := &reconcileErrorClient{SquareClient: square.NewDevClient()}
|
|
counting := &countingRefundClient{SquareClient: recErr}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled")
|
|
|
|
var status string
|
|
var attempts int
|
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &attempts)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "pending" {
|
|
t.Errorf("expected aged refund left 'pending' (reconcile error = unknown state), got %q", status)
|
|
}
|
|
if attempts != 0 {
|
|
t.Errorf("expected refund_attempts untouched (0) when the reconcile fails before any refund call, got %d", attempts)
|
|
}
|
|
|
|
// The reconcile error must NOT have fired a charge-level Square refund.
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Errorf("expected NO Square refund call when the reconcile fails, got %d", len(calls))
|
|
}
|
|
|
|
// No admin notification — the row was not marked failed.
|
|
var notifCount int
|
|
err = db.Conn.QueryRow(freshCtx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|