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