fix: frontend payment surfaces — SCA wire shapes (explicit token precedence), mock token parity, infinite-loop guard, money display, delete-account re-auth, admin progress UI, mobile touch targets

- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent f9e8385d5a
commit 01e7cc575d
34 changed files with 1154 additions and 665 deletions
@@ -9,26 +9,18 @@
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
scaFallbackConsentFields,
SCA_REFUSAL_MESSAGE_TILL,
shouldShowSCARefusal,
submitPaymentWithRetry,
tokenizeSavedCardWithVerification,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
PAYMENT_METHOD_SAVED_CARD,
VERIFICATION_REQUIRED_MESSAGE,
type SavedCardVerificationResult
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
type CartItem = {
@@ -127,25 +119,6 @@
})
);
// B6/B10: charging a customer's saved card via the till requires the
// customer's current 2FA verification code when the backend enforces the
// gate. The backend keys on the CARD OWNER (not the admin) and only gates
// customers who have actually ENABLED 2FA (requireTwoFactorForCardAccess:
// twoFactorEnforced() && UserTwoFactorEnabled(cardUserID)), so the input is
// surfaced only when BOTH hold — mirroring PaymentModal. The customer's
// setup flag is not carried by the till customer search, so it is fetched
// from GET /api/admin/users/{id} when a customer is selected (see
// fetchCustomerTwoFactor). For a 2FA-disabled customer in an enforced
// environment the input stays hidden so the charge can be attempted; the
// backend then returns the clear "Enable it in your account settings" 403,
// which the isTwoFactorVerificationGateFailure self-heal surfaces. Cash,
// card machine, and online (new-card nonce) payments are unaffected. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. The admin
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -153,18 +126,6 @@
// True while the saved-card 3DS challenge is open and the CUSTOMER must
// approve it in their banking app — drives the "waiting for approval" panel.
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true,
mint: () =>
selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id)
: requestNewTwoFactorCode()
});
// The saved-card option is hidden outright unless a customer is selected
// AND has at least one currently-valid card on file.
@@ -216,7 +177,6 @@
customerResults = [];
showCustomerResults = false;
fetchSavedCards(customer.id);
fetchCustomerTwoFactor(customer.id);
}
function clearSelectedCustomer() {
@@ -226,7 +186,6 @@
selectedSavedCardId = null;
customerResults = [];
showCustomerResults = false;
customerTwoFactorEnabled = false;
}
async function fetchSavedCards(userId: string) {
@@ -248,25 +207,6 @@
}
}
// B6/B10: the till customer search (GET /api/admin/users) carries no 2FA
// state, so the selected customer's setup flag is fetched from the admin
// user detail endpoint — the same source PaymentModal's fetchCustomerTwoFactor
// keys on. A failure leaves the flag false; the charge 403 self-heal still
// reveals the input.
async function fetchCustomerTwoFactor(userId: string) {
try {
const res = await apiFetch(`/api/admin/users/${userId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
} else {
customerTwoFactorEnabled = false;
}
} catch {
customerTwoFactorEnabled = false;
}
}
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
@@ -411,8 +351,6 @@
// C6: SCA genuinely can't run — abort the whole sale
// BEFORE any charge is submitted; the refusal notice
// is shown above the Charge button (no 2FA fallback).
twoFactor.declineConsent();
twoFactor.reveal = false;
scaAborted = true;
break;
}
@@ -420,13 +358,6 @@
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
if (sca.verificationToken) body.new_card_token = sca.verificationToken;
// B6/B10: the backend requires the CARD OWNER's current 2FA
// verification code when the gate is enforced and no SCA
// token authorises the charge.
if (twoFactor.showInput && !sca.verificationToken) {
body.verification_code = twoFactor.code;
}
Object.assign(body, scaFallbackConsentFields(twoFactor.consentAccepted));
} else if (paymentMethod === 'online_square') {
if (!onlineSquareCardInput) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -450,20 +381,12 @@
if (scaAborted) return;
for (const body of saleBodies) {
const res = await submitPaymentWithRetry(
() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
// Finding 4: a saved-card till line gated on 2FA consumed its
// code at the backend gate — a 503 auto-retry would re-send a
// dead code and self-defeat.
{
verificationCodeGated:
paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
}
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!res.ok) {
responseStatus = res.status;
@@ -492,8 +415,6 @@
toast.success('Sale complete');
cart = [];
idempotencyKeys.clear();
twoFactor.setCode('');
twoFactor.reveal = false;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed';
// C6: a sca-unavailable refusal is communicated by the refusal dialog
@@ -502,21 +423,6 @@
paymentError = null;
return;
}
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the sale can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
// M13: a verification-required rejection means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token) —
// withdraw consent so the code input never reappears and the error
// surfaces clearly instead of looping on 2FA.
if (
msg === VERIFICATION_REQUIRED_MESSAGE ||
isVerificationRequiredSignal(responseStatus, bodyText)
) {
twoFactor.declineConsent();
}
paymentError = msg;
toast.error(msg);
} finally {
@@ -547,7 +453,6 @@
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.declineConsent();
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
let result: SavedCardVerificationResult;
@@ -557,7 +462,6 @@
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.declineConsent();
throw err;
}
lastSCAOutcome = result.outcome;
@@ -581,7 +485,6 @@
}
return;
}
twoFactor.declineConsent();
if (result.outcome === 'sca-unavailable') {
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
@@ -884,7 +787,7 @@
{#each availablePaymentMethods as m (m.key)}
<button
type="button"
class="rounded-lg border py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
class="min-h-11 rounded-lg border py-3 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
m.key
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
@@ -933,7 +836,7 @@
{#each validCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedSavedCardId ===
card.id
? 'border-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
@@ -990,26 +893,6 @@
paymentError = null;
}}
/>
<!-- B6/B10: saved-card till charges require the customer's
current 2FA verification code when the backend enforces
the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<Button
variant="outline"
class="min-h-11 w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
{/if}
@@ -1041,18 +924,17 @@
</p>
</div>
{:else}
<Button
class="mt-3 min-h-11 w-full"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
<Button
class="mt-3 min-h-11 w-full active:bg-primary/85 active:shadow-none"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
{/if}
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<p class="mt-1 text-xs text-muted-foreground">