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
@@ -38,7 +38,7 @@
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { canSaveCardsForRole, isNonceStale } from '$lib/square/square';
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import {
@@ -123,6 +123,11 @@
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let depositTokenizedAt = $state(0);
// Save intent at tokenization time: the SCA verification token is bound to
// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
// tokenize must force a fresh tokenization rather than reuse a token minted
// with the wrong intent.
let depositTokenizedForSaveCard = $state(false);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
@@ -331,17 +336,26 @@
// created so the SCA verification amount matches the exact charge.
// The verification token is amount-bound, so a changed deposit
// amount forces a fresh tokenization.
if (!depositNonce || isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)) {
if (
!depositNonce ||
depositTokenizedForSaveCard !== depositSaveCard ||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)
) {
try {
const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, {
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
});
const tokenized = await paymentCardSelection.tokenizeWithVerification(
amountCents,
{
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
},
depositSaveCard
);
depositNonce = tokenized.nonce;
depositVerificationToken = tokenized.verificationToken ?? '';
depositTokenAmount = amountCents;
depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -381,14 +395,16 @@
paymentAttempted = true;
const response = await apiFetch(`/api/bookings/${bookingId}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(body)
});
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${bookingId}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(body)
})
);
if (response.ok) {
depositPaid = true;
@@ -399,6 +415,7 @@
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial