Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
@@ -4,6 +4,7 @@ package square
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -299,6 +300,54 @@ func TestRefundPayment_ShouldFail(t *testing.T) {
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_PaymentAlreadyRefunded(t *testing.T) {
|
||||
// PAYMENT_ALREADY_REFUNDED means the money already moved at Square, so the
|
||||
// mock must return ErrRefundAlreadyProcessed (never ErrRefundDeclined) and
|
||||
// must not store a refund — the caller resolves the record to 'completed'.
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
|
||||
|
||||
ctx := context.Background()
|
||||
req := RefundPaymentReq{
|
||||
PaymentID: "pay_mock_already_refunded",
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-key-already",
|
||||
Reason: "already refunded",
|
||||
}
|
||||
|
||||
result, err := client.RefundPayment(ctx, req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "expected ErrRefundAlreadyProcessed, got: %v", err)
|
||||
assert.False(t, errors.Is(err, ErrRefundDeclined), "already-processed refund must not be classified as declined: %v", err)
|
||||
|
||||
client.mu.RLock()
|
||||
defer client.mu.RUnlock()
|
||||
assert.Len(t, client.refunds, 0, "no refund must be stored when the payment is already refunded")
|
||||
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when the payment is already refunded")
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
|
||||
// Any other code configured via FailRefundCode preserves the prior
|
||||
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.FailRefundCode = "REFUND_DECLINED"
|
||||
|
||||
ctx := context.Background()
|
||||
req := RefundPaymentReq{
|
||||
PaymentID: "pay_mock_refund_declined",
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-key-declined",
|
||||
Reason: "declined",
|
||||
}
|
||||
|
||||
result, err := client.RefundPayment(ctx, req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.True(t, errors.Is(err, ErrRefundDeclined), "expected ErrRefundDeclined, got: %v", err)
|
||||
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed), "declined refund must not be classified as already processed: %v", err)
|
||||
}
|
||||
|
||||
func TestDevClient_ConcurrentPayments(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
|
||||
@@ -402,6 +451,46 @@ func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) {
|
||||
assert.Equal(t, first.ID, byKey.ID)
|
||||
}
|
||||
|
||||
func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) {
|
||||
// Real Square dedups on idempotency key: a same-key retry returns the
|
||||
// original refund. The mock must mirror this or the pending-refund resume
|
||||
// path can't be exercised (and a retry could double-refund the customer).
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-refund-dedup",
|
||||
ReferenceID: "booking-refund-dedup",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-dedup-key-1",
|
||||
Reason: "customer request",
|
||||
}
|
||||
|
||||
first, err := client.RefundPayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, first.ID)
|
||||
|
||||
second, err := client.RefundPayment(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.ID, second.ID, "same-key retry must return the original refund, not a new one")
|
||||
|
||||
// Only one refund stored in the mock's refunds map (deduped).
|
||||
client.mu.RLock()
|
||||
defer client.mu.RUnlock()
|
||||
assert.Len(t, client.refunds, 1, "same-key retry must not store a second refund")
|
||||
byKey := client.refundByKey["refund-dedup-key-1"]
|
||||
assert.NotNil(t, byKey)
|
||||
assert.Equal(t, first.ID, byKey.ID)
|
||||
}
|
||||
|
||||
func TestDevClient_CreatePayment_AutocompleteFalse(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
@@ -519,6 +608,75 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_FiltersByPaymentAndTime(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
begin := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
client.mu.Lock()
|
||||
client.refunds["ref_1"] = &RefundResult{
|
||||
ID: "ref_1", Status: "COMPLETED", Amount: 5000, PaymentID: "pay_a",
|
||||
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(2 * 24 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
client.refunds["ref_2"] = &RefundResult{
|
||||
ID: "ref_2", Status: "COMPLETED", Amount: 2500, PaymentID: "pay_b",
|
||||
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(3 * 24 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
client.refunds["ref_3"] = &RefundResult{
|
||||
ID: "ref_3", Status: "COMPLETED", Amount: 1000, PaymentID: "pay_a",
|
||||
LocationID: "L_MOCK", Reason: "customer request", CreatedAt: begin.Add(-1 * 24 * time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
client.mu.Unlock()
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, "pay_a", begin)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1, "only the pay_a refund created after beginTime must be returned")
|
||||
assert.Equal(t, "ref_1", results[0].ID)
|
||||
assert.Equal(t, int64(5000), results[0].Amount)
|
||||
assert.Equal(t, "COMPLETED", results[0].Status)
|
||||
assert.Equal(t, "pay_a", results[0].PaymentID)
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_AfterRefundPayment(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 10000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: "payment-for-list-refunds",
|
||||
ReferenceID: "booking-list-refunds",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||
PaymentID: paymentResult.ID,
|
||||
Amount: 5000,
|
||||
IdempotencyKey: "refund-for-list",
|
||||
Reason: "customer request",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-24*time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1, "the refund stored by RefundPayment must be listed")
|
||||
assert.Equal(t, refundResult.ID, results[0].ID)
|
||||
assert.Equal(t, int64(5000), results[0].Amount)
|
||||
assert.Equal(t, "COMPLETED", results[0].Status)
|
||||
assert.Equal(t, paymentResult.ID, results[0].PaymentID)
|
||||
}
|
||||
|
||||
func TestDevClient_ListPaymentRefunds_Empty(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
ctx := context.Background()
|
||||
|
||||
results, err := client.ListPaymentRefunds(ctx, "pay_unknown", time.Now().Add(-24*time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, results, "must return an empty slice, not nil")
|
||||
assert.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestDetectCardInfo_Variants(t *testing.T) {
|
||||
tests := []struct {
|
||||
sourceID string
|
||||
|
||||
Reference in New Issue
Block a user