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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user