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
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
@@ -13,23 +13,16 @@
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD,
runSavedCardSCAProactively,
sanitizeDecimalInput,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { POLICY } from '$lib/constants/policy';
@@ -136,16 +129,6 @@
selectedMethod = null;
}
// B6/B10: charging a customer's saved card requires the customer's current
// 2FA verification code when the backend enforces the gate. The backend keys
// on the CARD OWNER (the booking's user), so the input is surfaced whenever
// the customer has 2FA enabled in an enforced environment — the operator
// relays the customer's code. `twoFactorRequired` is env-wide enforcement
// (true for every session user when the gate is on); the CUSTOMER's setup
// flag is not carried by the admin booking payload, so it is fetched from
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
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.
@@ -154,34 +137,6 @@
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
// B6/B10: charging a customer's saved card requires the customer's current
// 2FA verification code when the backend enforces the gate. Shared
// verification-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 twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 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: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
}
});
// Focus the verification-code input whenever the saved-card screen shows it
// (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the
// operator can type the customer's code without an extra click.
$effect(() => {
if (status === 'saved-card-selecting' && twoFactor.showInput) {
tick().then(() => document.getElementById('two-factor-code')?.focus());
}
});
// B3: pence already paid against this booking. The AppointmentInfo handed in
// by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint
@@ -274,30 +229,11 @@
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
// B6/B10: the admin booking payload carries no 2FA state for the owner, so
// the customer's flag is fetched from the admin user detail endpoint (the
// same source the customer-flag fix keys on). A failure leaves the flag
// false — the charge 403 self-heal still reveals the input.
async function fetchCustomerTwoFactor() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
}
} catch {
customerTwoFactorEnabled = false;
}
}
$effect(() => {
const uid = booking.user_id ?? booking.user?.id;
if (uid) {
fetchCustomerGiftCardBalance();
fetchSavedCards();
fetchCustomerTwoFactor();
}
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
@@ -946,8 +882,6 @@
// ccof is never sent — and surface the refusal notice; there
// is NO 2FA fallback. The operator taps OK to close, or Back
// to pick a different payment method / retry SCA.
twoFactor.declineConsent();
twoFactor.reveal = false;
status = 'saved-card-selecting';
return;
}
@@ -970,16 +904,9 @@
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: savedCardIdempotencyKey
})
}),
// Finding 4: a 2FA-gated charge consumed its code at the backend
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
{ verificationCodeGated: twoFactor.showInput }
})
);
if (!response.ok) {
@@ -1000,10 +927,6 @@
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: savedCardIdempotencyKey
}
};
@@ -1037,8 +960,6 @@
// charge gets a fresh UUID and can't be deduped against this one.
savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Saved card payment successful');
onComplete(paymentResult);
} catch (_err) {
@@ -1050,17 +971,11 @@
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
// M13: a verification-required 402 means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token) —
// withdraw consent so the code input never reappears and the
// modal shows the SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
// A verification-required 402 means the backend did NOT accept
// the token — surface the SCA-first guidance and let the
// operator retry.
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// 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 charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
// A DEFINITIVE 402 (declined card / stale token) means the charge did
// NOT land — Square's idempotency key would otherwise reject a retry
// that re-runs SCA and mints a fresh token. Regenerate the key on 402
@@ -1109,7 +1024,7 @@
handleClose();
}}
>
<Dialog.Content class="max-w-lg">
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header>
@@ -1149,7 +1064,6 @@
<input
type="text"
inputmode="decimal"
tabindex={-1}
class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ??
@@ -1316,7 +1230,7 @@
<button
type="button"
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="rounded-lg border py-6 text-center text-sm font-semibold 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 {selectedMethod ===
'card'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1340,7 +1254,7 @@
<button
type="button"
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="rounded-lg border py-6 text-center text-sm font-semibold 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 {selectedMethod ===
'cash'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1365,7 +1279,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold 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 sm:block {selectedMethod ===
PAYMENT_METHOD_SAVED_CARD
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1391,7 +1305,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold 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 sm:block {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1422,7 +1336,7 @@
<button
type="button"
disabled={nothingToCharge}
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
status = 'saved-card-selecting';
@@ -1434,7 +1348,7 @@
<button
type="button"
disabled={nothingToCharge}
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
@@ -1445,7 +1359,7 @@
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Cancel</Button>
</div>
</div>
{:else if status === 'selecting'}
@@ -1473,7 +1387,7 @@
{#each tipPercentages as tip (tip.pct)}
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none hover:bg-fuchsia-50 {selectedTipPercent ===
tip.pct
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
@@ -1489,7 +1403,6 @@
<Input
type="text"
inputmode="decimal"
tabindex={-1}
placeholder="Custom tip amount"
value={customTipAmount}
oninput={handleCustomTipInput}
@@ -1516,8 +1429,8 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1" disabled={nothingToCharge}>
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button onclick={handleCardPayment} class="min-h-11 flex-1" disabled={nothingToCharge}>
Charge Card
</Button>
</div>
@@ -1545,7 +1458,6 @@
id="cash-amount"
type="text"
inputmode="decimal"
tabindex={-1}
value={cashAmount}
oninput={handleCashInput}
class="pl-7 text-lg font-semibold"
@@ -1571,10 +1483,10 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="flex-1"
class="min-h-11 flex-1"
disabled={cashAmountNum < totalDue || nothingToCharge}
>
Confirm Cash
@@ -1662,7 +1574,6 @@
id="gift-card-id"
type="text"
inputmode="text"
tabindex={-1}
value={giftCardId}
oninput={handleGiftCardInput}
placeholder="XXXX-XXXX-XXXX"
@@ -1676,10 +1587,10 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="flex-1"
class="min-h-11 flex-1"
disabled={!giftCardValid || nothingToCharge}
>
Apply Gift Card
@@ -1721,7 +1632,7 @@
{#each savedCards 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'}"
@@ -1781,35 +1692,12 @@
}}
/>
<!-- B6/B10: saved-card 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}
<p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh
code from their account.
</p>
<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 class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleSavedCardPayment}
class="min-h-11 flex-1"
disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing}
disabled={!selectedSavedCardId || nothingToCharge}
>
Charge Saved Card
</Button>
@@ -1837,8 +1725,8 @@
<p class="text-sm text-red-800">{error}</p>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
<Button onclick={resetToSelect} class="flex-1">Try Again</Button>
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Close</Button>
<Button onclick={resetToSelect} class="min-h-11 flex-1">Try Again</Button>
</div>
</div>
{:else if status === 'success' && paymentResult}
@@ -1884,7 +1772,7 @@
</div>
</div>
<Button onclick={handleSuccessDone} class="w-full">Done</Button>
<Button onclick={handleSuccessDone} class="min-h-11 w-full">Done</Button>
</div>
{/if}
</Dialog.Content>