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
+52 -1
View File
@@ -30,9 +30,14 @@ type MockClient struct {
payments map[string]*PaymentResult
paymentByKey map[string]*PaymentResult
refunds map[string]*RefundResult
refundByKey map[string]*RefundResult
completed map[string]*PaymentResult
HoldCheckouts bool
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
// FailRefundCode simulates a specific Square refund rejection code. Empty
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
// RefundPayment returns the sentinel-wrapped error for that code.
FailRefundCode string
}
type devProdClient struct{}
@@ -58,6 +63,9 @@ func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]Ca
func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
return deleteCardOnFileHTTP(ctx, cardID)
}
func (d *devProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTP(ctx, paymentID, beginTime)
}
func NewClient() SquareClient {
return NewDevClient()
@@ -76,6 +84,7 @@ func NewDevClient() SquareClient {
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
completed: make(map[string]*PaymentResult),
}
}
@@ -287,7 +296,15 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
if m.ShouldFail {
return nil, fmt.Errorf("mock: refund declined (simulated failure)")
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
}
if m.FailRefundCode != "" {
switch m.FailRefundCode {
case "PAYMENT_ALREADY_REFUNDED":
return nil, fmt.Errorf("%w: payment already fully refunded (simulated)", ErrRefundAlreadyProcessed)
default:
return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode)
}
}
log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount)
mockSleep(1 * time.Second)
@@ -295,6 +312,17 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
m.mu.Lock()
defer m.mu.Unlock()
// Real Square dedups on idempotency key: a retry with the same key returns
// the original refund rather than issuing a second refund. The mock mirrors
// this so dev/testing behaves like production (and the pending-refund
// resume path can rely on it).
if req.IdempotencyKey != "" {
if existing, ok := m.refundByKey[req.IdempotencyKey]; ok {
log.Printf("[SQUARE-MOCK] RefundPayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil
}
}
now := clock.Now().UTC()
refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano())
@@ -326,6 +354,9 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
CreatedAt: now.Format(time.RFC3339),
}
m.refunds[refundID] = result
if req.IdempotencyKey != "" {
m.refundByKey[req.IdempotencyKey] = result
}
log.Printf("[SQUARE-MOCK] Refund completed: id=%s, payment=%s, amount=%d", refundID, req.PaymentID, amount)
return result, nil
}
@@ -404,6 +435,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
return fmt.Errorf("card not found: %s", cardID)
}
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
m.mu.Lock()
defer m.mu.Unlock()
out := []RefundResult{}
for _, r := range m.refunds {
if r.PaymentID != paymentID {
continue
}
createdAt, err := time.Parse(time.RFC3339, r.CreatedAt)
if err == nil && createdAt.Before(beginTime) {
continue
}
out = append(out, *r)
}
return out, nil
}
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
func isTokenLike(s string) bool {