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:
2026-08-22 00:34:49 +01:00
parent 54f6bf3c1a
commit ae8735ba2f
33 changed files with 4129 additions and 1868 deletions
+158
View File
@@ -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