fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub

Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 4b28e93710
commit e9b0f0f2a7
50 changed files with 4223 additions and 413 deletions
@@ -8,6 +8,8 @@
import { apiFetch } from '$lib/utils/api';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { resolve } from '$app/paths';
type CartItem = {
id: string;
@@ -96,9 +98,19 @@
})
);
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
// customer's saved card online. Cash, card machine, and online (new-card
// nonce) payments are unaffected.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
// The saved-card option is hidden outright unless a customer is selected
// AND has at least one currently-valid card on file.
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
// AND has at least one currently-valid card on file AND 2FA gating is not
// active.
const showSavedCardOption = $derived(
selectedCustomer !== null && validCards.length > 0 && !twoFactorBlocksSavedCards
);
const availablePaymentMethods = $derived(
PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption)
@@ -249,6 +261,10 @@
);
return;
}
if (paymentMethod === 'saved_card' && twoFactorBlocksSavedCards) {
toast.error('Two-factor authentication is required to use online card payments');
return;
}
if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) {
toast.error('Select a customer and a saved card before charging');
return;
@@ -614,6 +630,13 @@
</div>
</div>
{#if twoFactorBlocksSavedCards}
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
</div>
{/if}
{#if paymentMethod === 'online_square'}
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
{#if isSquareConfigured()}
@@ -139,6 +139,13 @@
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
// and saving new cards for reuse. The new-card (nonce) path has its own
// SCA via Square tokenizeWithVerification.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
const depositCardFormValid = $derived(paymentCardSelectionValid);
// VAT registration status from public business info (via shared store)
@@ -309,6 +316,12 @@
isProcessingPayment = true;
paymentAttempted = false;
try {
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
// but not enabled — clear any stale selection so the new-card
// (nonce) path is used instead.
if (twoFactorBlocksSavedCards && selectedPaymentMethod) {
selectedPaymentMethod = '';
}
await submitAndProceed();
if (!confirmedBooking) {
toast.error('Booking was not created. Please try again.');
@@ -327,7 +340,7 @@
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedPaymentMethod) {
if (selectedPaymentMethod && !twoFactorBlocksSavedCards) {
// saved card — nothing to tokenize
} else if (paymentCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
@@ -5,6 +5,8 @@
import type { SquareVerificationContact } from './SquareCardInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { isSquareConfigured } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { resolve } from '$app/paths';
export interface SelectableCard {
id: string;
@@ -39,17 +41,42 @@
// would collide on the same checkbox id. Pure SPA, so no SSR concern.
const consentId = `save-card-consent-${crypto.randomUUID()}`;
// PSD2 SCA stand-in: when 2FA is required but not yet enabled, saved-card
// selection and save-for-later are blocked. The new-card (nonce) path has
// its own SCA via Square tokenizeWithVerification, so only the saved-card
// list and the save toggle are gated here.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
// Auto-select the default saved card when cards first load. Guarded by
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
// NOT immediately overridden back to the default card — which would
// silently charge the wrong card on submit.
// silently charge the wrong card on submit. Also skipped while 2FA gating
// is active so a saved card is never selected by default.
$effect(() => {
if (cards.length > 0 && !selectedCardId && !showNewCardForm) {
if (
cards.length > 0 &&
!selectedCardId &&
!showNewCardForm &&
!twoFactorBlocksSavedCards
) {
const defaultCard = cards.find((c) => c.is_default) ?? cards[0];
selectedCardId = defaultCard.id;
}
});
// While 2FA gating is active, keep the shared component self-consistent:
// never allow a saved card to stay selected or the save-card checkbox to
// remain checked (the parents' submit paths also guard, this is belt-and-
// braces for pre-selected state from a previous session).
$effect(() => {
if (twoFactorBlocksSavedCards && (selectedCardId !== '' || saveCard)) {
selectedCardId = '';
saveCard = false;
}
});
// When no saved cards exist the new-card form shows by default (no toggle).
const newCardMode = $derived(showNewCardForm || cards.length === 0);
@@ -93,32 +120,41 @@
{#if cards.length > 0}
<div class="space-y-2">
{#each cards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
{#if twoFactorBlocksSavedCards}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
</p>
</div>
{:else}
{#each cards as card (card.id)}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = card.id;
showNewCardForm = false;
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
<div class="text-sm">
<span class="font-mono">**** {card.last_4}</span>
<span class="ml-2 text-xs text-gray-400"
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</div>
</div>
{#if selectedCardId === card.id && !showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
{#if selectedCardId === card.id && !showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
{/if}
<button
type="button"
@@ -143,6 +179,13 @@
{/if}
</button>
</div>
{:else if twoFactorBlocksSavedCards}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
</p>
</div>
{/if}
{#if newCardMode}
@@ -153,7 +196,7 @@
<CardEntryUnavailable />
{/if}
{#if canSaveCards && squareCardReady}
{#if canSaveCards && squareCardReady && !twoFactorBlocksSavedCards}
<label
class="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-600"
for={consentId}
@@ -7,7 +7,9 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import { submitPaymentWithRetry } from '$lib/square/square';
import { submitPaymentWithRetry } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { resolve } from '$app/paths';
const LOYALTY_DISCOUNT_RATE = 0.1;
@@ -54,6 +56,13 @@ import { submitPaymentWithRetry } from '$lib/square/square';
// reactive flag is checked synchronously at the start of every handler.
let isProcessingPaymentSync = false;
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
// customer's saved card online (the admin's own 2FA status gates it). The
// card-machine and new-card paths have their own SCA.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
@@ -661,6 +670,10 @@ import { submitPaymentWithRetry } from '$lib/square/square';
async function handleSavedCardPayment() {
if (isProcessingPaymentSync) return;
if (twoFactorBlocksSavedCards) {
toast.error('Two-factor authentication is required to use online card payments');
return;
}
if (!selectedSavedCardId) {
toast.error('Please select a saved card');
return;
@@ -744,6 +757,13 @@ import { submitPaymentWithRetry } from '$lib/square/square';
}
$effect(() => {
// PSD2 SCA stand-in: if 2FA gating becomes active mid-modal, bail out
// of the saved-card screen back to method selection.
if (selectedMethod === 'savedcard' && twoFactorBlocksSavedCards) {
selectedMethod = null;
status = 'idle';
return;
}
if (selectedMethod === 'cash') {
cashAmount = totalDue.toFixed(2);
extraAsTip = false;
@@ -952,7 +972,7 @@ import { submitPaymentWithRetry } from '$lib/square/square';
</svg>
Cash
</button>
{#if savedCardList.length > 0}
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
<button
type="button"
disabled={nothingToCharge}
@@ -1008,8 +1028,17 @@ import { submitPaymentWithRetry } from '$lib/square/square';
</button>
</div>
{#if twoFactorBlocksSavedCards && savedCardList.length > 0}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
</p>
</div>
{/if}
<div class="flex flex-wrap gap-3 sm:hidden">
{#if savedCardList.length > 0}
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
<button
type="button"
disabled={nothingToCharge}
@@ -85,6 +85,13 @@
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
// and saving new cards for reuse. The new-card (nonce) path has its own
// SCA via Square tokenizeWithVerification.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
const isCardValid = $derived(cardSelectionValid);
let selectedTip = $state<number | null>(null);
@@ -180,7 +187,7 @@
async function loadSavedCards() {
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
if (savedCards.length > 0 && !selectedCardId && !twoFactorBlocksSavedCards) {
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
return;
@@ -188,7 +195,7 @@
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
if (savedCards.length > 0 && !selectedCardId && !twoFactorBlocksSavedCards) {
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
} catch {
@@ -206,9 +213,16 @@
return;
}
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
// but not enabled — clear any stale selection so the new-card (nonce)
// path is used instead.
if (twoFactorBlocksSavedCards && selectedCardId) {
selectedCardId = '';
}
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
if (selectedCardId && !twoFactorBlocksSavedCards) {
// saved card — nothing to tokenize
} else if (cardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
@@ -36,6 +36,13 @@
// the checkbox inside CardSelection; defaults to false (opt-in).
let saveCard = $state(false);
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
// and saving new cards for reuse. The new-card (nonce) path has its own
// SCA via Square tokenizeWithVerification.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
@@ -371,7 +378,9 @@
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
// but not enabled — fall back to the new-card (nonce) path.
if (selectedCardId && !twoFactorBlocksSavedCards) {
cardId = selectedCardId;
} else if (cardSelection) {
// New-card mode: tokenize once per attempt WITH SCA verification, then
+9 -2
View File
@@ -25,6 +25,9 @@ export interface User {
profilePicUrl?: string;
previousFirstName?: string;
previousLastName?: string;
twoFactorEnabled?: boolean;
twoFactorRequired?: boolean;
twoFactorMethod?: string;
}
class AuthStore {
@@ -81,7 +84,9 @@ class AuthStore {
role: decoded.role,
email: '',
firstName: '',
lastName: ''
lastName: '',
twoFactorEnabled: false,
twoFactorRequired: false
};
// Refresh first so any JTI invalidation from rotation
@@ -126,7 +131,9 @@ class AuthStore {
role: decoded.role,
email: '',
firstName: '',
lastName: ''
lastName: '',
twoFactorEnabled: false,
twoFactorRequired: false
};
this.fetchUserProfile();
}
+1 -1
View File
@@ -95,7 +95,7 @@ export interface Payment {
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
+173
View File
@@ -520,6 +520,89 @@
}
}
// =============== Two-Factor Authentication State ===============
let twoFAMethod = $state<'email' | 'sms'>('email');
let twoFACode = $state('');
let twoFASetupPending = $state(false);
let twoFADevCode = $state('');
let twoFASettingUp = $state(false);
let twoFAVerifying = $state(false);
let twoFADisabling = $state(false);
async function startTwoFASetup() {
twoFASettingUp = true;
try {
const res = await apiFetch('/api/user/2fa/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: twoFAMethod })
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
// Dev-only: unenforced environments return the code so the loose
// fake flow is usable without reading backend logs.
twoFADevCode = data.code ?? '';
twoFASetupPending = true;
twoFACode = '';
toast.success(data.message ?? 'Verification code sent');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to start two-factor setup');
}
} catch {
toast.error('Network error');
} finally {
twoFASettingUp = false;
}
}
async function verifyTwoFASetup() {
twoFAVerifying = true;
try {
const res = await apiFetch('/api/user/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFACode })
});
if (res.ok) {
twoFASetupPending = false;
twoFACode = '';
twoFADevCode = '';
await authStore.refreshProfile();
toast.success('Two-factor authentication enabled');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Invalid verification code');
}
} catch {
toast.error('Network error');
} finally {
twoFAVerifying = false;
}
}
async function disableTwoFA() {
twoFADisabling = true;
try {
const res = await apiFetch('/api/user/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: '' })
});
if (res.ok) {
await authStore.refreshProfile();
toast.success('Two-factor authentication disabled');
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to disable two-factor authentication');
}
} catch {
toast.error('Network error');
} finally {
twoFADisabling = false;
}
}
// Image cropper state
let cropDialogOpen = $state(false);
let cropImageUrl = $state('');
@@ -2294,6 +2377,96 @@
<Separator />
<!-- Two-Factor Authentication -->
<div>
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
<p class="mb-3 text-sm text-gray-600">
Protect online card payments with a one-time verification code
</p>
{#if authStore.currentUser?.twoFactorEnabled}
<div class="rounded-lg border p-3">
<div class="text-sm font-medium">
Enabled
{#if authStore.currentUser?.twoFactorMethod}
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
{/if}
</div>
<div class="mt-1 text-xs text-gray-500">
A verification code is required for online card payments
</div>
<Button
variant="outline"
class="mt-3"
disabled={twoFADisabling}
onclick={disableTwoFA}
>
{twoFADisabling ? 'Disabling...' : 'Disable'}
</Button>
</div>
{:else}
{#if authStore.currentUser?.twoFactorRequired}
<div
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
>
You must enable 2FA to use online card payments.
</div>
{:else}
<p class="mb-3 text-xs text-gray-500">
2FA is optional right now (REQUIRE_2FA is off)
</p>
{/if}
{#if twoFASetupPending}
{#if twoFADevCode}
<p class="mb-2 text-xs text-gray-500">
Dev code: <strong>{twoFADevCode}</strong>
</p>
{/if}
<div class="flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFACode}
/>
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
{twoFAVerifying ? 'Verifying...' : 'Verify'}
</Button>
</div>
{:else}
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm">
<input
type="radio"
name="twofa-method"
value="email"
bind:group={twoFAMethod}
class="accent-fuchsia-600"
/>
Email
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="radio"
name="twofa-method"
value="sms"
bind:group={twoFAMethod}
class="accent-fuchsia-600"
/>
SMS
</label>
</div>
<Button class="mt-3" disabled={twoFASettingUp} onclick={startTwoFASetup}>
{twoFASettingUp ? 'Sending...' : 'Enable'}
</Button>
{/if}
{/if}
</div>
<Separator />
<!-- Log Out Button -->
<div>
<h3 class="mb-2 text-sm font-semibold">Session</h3>