fix: comprehensive payment system hardening (4 review passes)

CRITICAL fixes:
- C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode)
- C2: OverrideAmount validated post-substitution (prevents negative money minting)
- C3: Terminal gift-card payments store gift_card_id; refund credits user balance
- C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead)
- C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation)
- C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund)

MAJOR fixes:
- M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed
- M3: ProcessCancellationRefund returns commit error (was swallowed)
- M5: Dispute webhook handling (created + state.updated + disputes table)

MEDIUM fixes:
- ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any)
- ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens
- ME3: Webhook handlers now mutate state (payment.updated, refund.updated)

Frontend fixes:
- Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows
- CHARGE_AND_STORE intent for save-card flows (SCA compliance)
- Nonce staleness check verified across all flows

Additional fixes from adversarial re-review:
- F1: Till-sale completed dedup echoes stored amount (C4-class)
- F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class)
- F3: Square-success UPDATE checks RowsAffected (till sales)
- F4: Dispute reason truncated to 192 chars (prevents INSERT failure)
- F5: Booking-user lookup failure marks refund failed (prevents silent money loss)
- F6: Saved-card/tip rechecks wrapped in transaction (C5 residual)

Tests:
- 15 adversarial attack tests (negative override, zero override, terminal gift card,
  refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge
  amount, raw PAN, missing auth, gift card balance, concurrent refunds)
- 14 webhook state tests (dispute created/state, payment/refund updated)
- 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test
- Full suite passes with -race (25 packages, 0 failures)

25 files changed, +1532/-275 lines
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7df983052b
commit 5e3dc9b428
28 changed files with 2905 additions and 275 deletions
+45
View File
@@ -44,6 +44,51 @@ export function isNonceStale(
return nonceTokenizedFor !== amount || now - nonceTokenizedAt > NONCE_STALENESS_MS;
}
/**
* Payment failure classification mirrors the backend's `chargeFailureStatus`:
* - 503 (ambiguous): transport/network errors, Square 5xx, context
* cancellation — the pending record stays resumable, so a same-key retry
* makes the backend re-attempt/reuse it instead of double-charging.
* - 402 (definitive): card declined, expired, AVS/CVV failure — retrying
* with the same inputs can never succeed.
*/
export const PAYMENT_AMBIGUOUS_STATUS = 503;
export const PAYMENT_DEFINITIVE_STATUS = 402;
/** True when a payment submission response is an ambiguous failure (503) that
* must be retried with the SAME idempotency key so the backend resumes the
* pending record. */
export function isAmbiguousPaymentFailure(status: number): boolean {
return status === PAYMENT_AMBIGUOUS_STATUS;
}
const PAYMENT_RETRY_DELAY_MS = 1500;
const PAYMENT_MAX_RETRIES = 3;
/**
* Submits a payment and transparently retries on ambiguous (503) responses
* with the SAME request body — the body carries the idempotency key, so every
* retry reuses it and the backend re-attempts / resumes the pending record
* instead of issuing a second charge. Definitive failures (402) and every
* other status return immediately so the caller surfaces the error; the caller
* must NOT retry those on its own. Defaults to up to 3 retries with a 1.5s
* backoff (Square/backend transient failures typically clear within seconds).
*/
export async function submitPaymentWithRetry(
submit: () => Promise<Response>,
options: { maxRetries?: number; retryDelayMs?: number } = {}
): Promise<Response> {
const maxRetries = options.maxRetries ?? PAYMENT_MAX_RETRIES;
const retryDelayMs = options.retryDelayMs ?? PAYMENT_RETRY_DELAY_MS;
for (let attempt = 0; ; attempt++) {
const response = await submit();
if (response.ok || !isAmbiguousPaymentFailure(response.status) || attempt >= maxRetries) {
return response;
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
/** True when the frontend runs in local-dev mock mode: VITE_SQUARE_ENVIRONMENT === 'mock'
* AND the dev build (import.meta.env.DEV). The DEV gate makes the mock structurally
* impossible in any production bundle — even if the env var is mis-set at build time. */