feat: frontend SCA-only posture — tokenize-result as charge source (C1), C6 refusal dialog, no 2FA fallback

- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine
  'sca-unavailable' now drives the REFUSAL path (the customer is told the
  payment cannot complete and to pay online later), never the 2FA code fallback
  (PSR 2017 SCA is non-waivable; merchant liability is not cured by consent).
  SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1'
  + scaFallbackConsentFields() carry the versioned consent on the explicit
  opt-in path only (shipped surfaces send none). SquareTokenizeResult docs
  updated: tokenize-result token is the charge source, tokenless OK proceeds
  token-less under the backend's SCA-only gate.
- C1 wire contract on every saved-card surface (booking, tip, gift-card buy,
  till, account): the proactive SCA tokenize-result is sent as new_card_token
  (the charge SOURCE alongside the saved-card ref), never the legacy
  verification_token; 402 verification-required now means the tokenize-result
  was consumed/expired between tokenize and charge.
- New ScaFallbackConsentDialog surfaces the refusal notice; the code input
  (useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via
  a backend gate rejection (defensive/opt-in).
- Till (M10): proactive saved-card SCA runs per sale line BEFORE the first
  charge; sca-unavailable aborts the whole sale before any charge.
- Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization;
  402 verification-required on save surfaces SCA-first guidance instead of a
  generic failure.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 9a75ebc794
commit 0fdb2f02cd
21 changed files with 1404 additions and 315 deletions
@@ -969,7 +969,11 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<div class="px-4 pb-4"> <div class="px-4 pb-4">
<!-- Shared with /tip and /pay-tip/[id] so preset/custom/2FA/SCA/retry logic can't diverge. --> <!-- Shared with /tip and /pay-tip/[id] so preset/custom/2FA/SCA/retry logic can't diverge. -->
<TipPayment booking={selectedBooking} onSuccess={handleTipSuccess} /> <TipPayment
booking={selectedBooking}
onSuccess={handleTipSuccess}
onCancel={() => (showTipModal = false)}
/>
</div> </div>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
@@ -16,16 +16,20 @@
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
runSavedCardSCAProactively, runSavedCardSCAProactively,
shouldFallbackTo2FA, scaFallbackConsentFields,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE, SCA_REFUSAL_MESSAGE_TILL,
shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
tokenizeSavedCardWithVerification,
adminRequestNewTwoFactorCode, adminRequestNewTwoFactorCode,
requestNewTwoFactorCode, requestNewTwoFactorCode,
PAYMENT_METHOD_SAVED_CARD, PAYMENT_METHOD_SAVED_CARD,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE,
type SavedCardVerificationResult
} from '$lib/square/square'; } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
type CartItem = { type CartItem = {
id: string; id: string;
@@ -34,7 +38,8 @@
qty: number; qty: number;
}; };
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | (typeof PAYMENT_METHOD_SAVED_CARD); type TillPaymentMethod =
'cash' | 'card_machine' | 'online_square' | typeof PAYMENT_METHOD_SAVED_CARD;
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [ const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
{ key: 'cash', label: 'Cash' }, { key: 'cash', label: 'Cash' },
@@ -141,9 +146,9 @@
// irrelevant to the backend gate, so `enabled` is always true. // irrelevant to the backend gate, so `enabled` is always true.
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired); const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false); let customerTwoFactorEnabled = $state(false);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// from backup to the only available gate (scaAvailable → false); every other // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// outcome keeps SCA primary for the next retry. // fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); let lastSCAOutcome = $state('');
// True while the saved-card 3DS challenge is open and the CUSTOMER must // 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. // approve it in their banking app — drives the "waiting for approval" panel.
@@ -152,7 +157,9 @@
enabled: () => true, enabled: () => true,
gateActive: () => gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD, twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome), // 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: () => mint: () =>
selectedCustomer?.id selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id) ? adminRequestNewTwoFactorCode(selectedCustomer.id)
@@ -348,7 +355,10 @@
); );
return; return;
} }
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && (!selectedCustomer || !selectedSavedCardId)) { if (
paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
(!selectedCustomer || !selectedSavedCardId)
) {
toast.error('Select a customer and a saved card before charging'); toast.error('Select a customer and a saved card before charging');
return; return;
} }
@@ -360,37 +370,84 @@
// One sale per cart line × quantity — each till sale funds its own // One sale per cart line × quantity — each till sale funds its own
// gift card (the backend only accepts item_type 'gift_card'). // gift card (the backend only accepts item_type 'gift_card').
const saleBodies: Record<string, unknown>[] = []; const saleBodies: Record<string, unknown>[] = [];
for (const item of cart) { // M10: proactive saved-card (ccof) SCA. Run the client-side challenge
for (let i = 0; i < item.qty; i++) { // for every sale line BEFORE the first charge so a naked ccof till
const body: Record<string, unknown> = { // charge is never sent to the backend (mirrors PaymentModal/UserPaymentModal
item_type: 'gift_card', // running SCA at charge init). Each line binds its token to its own
action: 'create', // amount. 'challenge-cancelled'/'sca-failed' abort the whole sale
amount: item.price, // (retryable); 'sca-unavailable' aborts before any charge and surfaces
payment_method: paymentMethod, // the C6 refusal notice (no 2FA fallback).
idempotency_key: idempotencyKeyFor(item, i) let scaAborted = false;
}; awaitingSCA = true;
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) { try {
body.user_id = selectedCustomer?.id; for (const item of cart) {
body.user_saved_card_id = selectedSavedCardId; for (let i = 0; i < item.qty; i++) {
// B6/B10: the backend requires the CARD OWNER's current 2FA const body: Record<string, unknown> = {
// verification code when the gate is enforced. item_type: 'gift_card',
if (twoFactor.showInput) body.verification_code = twoFactor.code; action: 'create',
} else if (paymentMethod === 'online_square') { amount: item.price,
if (!onlineSquareCardInput) { payment_method: paymentMethod,
throw new Error('Card form is not ready — please wait a moment and try again'); idempotency_key: idempotencyKeyFor(item, i)
} };
// SCA verification amount must match the sale amount (pence). if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
const tokenized = await onlineSquareCardInput.tokenizeWithVerification( body.user_id = selectedCustomer?.id;
Math.round(item.price * 100) body.user_saved_card_id = selectedSavedCardId;
); const squareCardId = savedCards.find(
body.card_token = tokenized.nonce; (c) => c.id === selectedSavedCardId
if (tokenized.verificationToken) { )?.square_card_id;
body.verification_token = tokenized.verificationToken; const sca = await runSavedCardSCAProactively({
// The till body carries the amount in POUNDS (the
// backend multiplies by 100); the SCA challenge binds
// to pence, so convert for the challenge.
amountPence: Math.round(item.price * 100),
squareCardId: squareCardId ?? '',
buyer: { email: selectedCustomer?.email },
onOutcome: (o) => (lastSCAOutcome = o)
});
if (sca.outcome === 'challenge-cancelled' || sca.outcome === 'sca-failed') {
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
}
if (sca.outcome === 'sca-unavailable') {
// 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;
}
// C1: the SCA tokenize-result token is the charge SOURCE
// (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');
}
// SCA verification amount must match the sale amount (pence).
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
Math.round(item.price * 100)
);
body.card_token = tokenized.nonce;
if (tokenized.verificationToken) {
body.verification_token = tokenized.verificationToken;
}
} }
saleBodies.push(body);
} }
saleBodies.push(body); if (scaAborted) break;
} }
} finally {
awaitingSCA = false;
} }
if (scaAborted) return;
for (const body of saleBodies) { for (const body of saleBodies) {
const res = await submitPaymentWithRetry( const res = await submitPaymentWithRetry(
@@ -404,7 +461,8 @@
// code at the backend gate — a 503 auto-retry would re-send a // code at the backend gate — a 503 auto-retry would re-send a
// dead code and self-defeat. // dead code and self-defeat.
{ {
verificationCodeGated: paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput verificationCodeGated:
paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
} }
); );
if (!res.ok) { if (!res.ok) {
@@ -438,10 +496,27 @@
twoFactor.reveal = false; twoFactor.reveal = false;
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed'; const msg = err instanceof Error ? err.message : 'Sale failed';
// C6: a sca-unavailable refusal is communicated by the refusal dialog
// above the Charge button — don't duplicate it in the error panel.
if (shouldShowSCARefusal(lastSCAOutcome)) {
paymentError = null;
return;
}
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated // code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the sale can be retried with a fresh code. // and reveal the input so the sale can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true; 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; paymentError = msg;
toast.error(msg); toast.error(msg);
} finally { } finally {
@@ -452,13 +527,16 @@
/** /**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402 * Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal. The CUSTOMER approves the 3DS * with the verification-required signal (the proactive token was stale or
* challenge in their banking app; the operator's screen shows the waiting * expired between tokenize and charge — the first attempt now always runs
* state. 'verified' retries the SAME sale line with the fresh verification_token * proactive SCA, so this is the defensive path). The CUSTOMER approves the
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable' * 3DS challenge in their banking app; the operator's screen shows the waiting
* demotes 2FA from backup to the available gate; 'challenge-cancelled' / * state. 'verified' retries the SAME sale line with the fresh tokenize-result
* 'sca-failed' keep the pending row retryable (the idempotency key stays * token as new_card_token and its SAME cached idempotency key (never
* cached). Throws to stop the whole sale on any non-verified outcome. * regenerated here); 'sca-unavailable' refuses the sale (C6 — the refusal
* dialog is driven by lastSCAOutcome); 'challenge-cancelled' / 'sca-failed'
* keep the pending row retryable (the idempotency key stays cached). Throws
* to stop the whole sale on any non-verified outcome.
*/ */
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> { async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id; const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
@@ -469,8 +547,8 @@
try { try {
if (!squareCardId) { if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable'; lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true; twoFactor.declineConsent();
throw new Error(VERIFICATION_REQUIRED_MESSAGE); throw new Error(SCA_REFUSAL_MESSAGE_TILL);
} }
let result: SavedCardVerificationResult; let result: SavedCardVerificationResult;
try { try {
@@ -479,7 +557,7 @@
}); });
} catch (err) { } catch (err) {
lastSCAOutcome = 'sca-unavailable'; lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true; twoFactor.declineConsent();
throw err; throw err;
} }
lastSCAOutcome = result.outcome; lastSCAOutcome = result.outcome;
@@ -490,19 +568,22 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
...body, ...body,
verification_token: result.verificationToken // C1: the SCA tokenize-result token is the charge SOURCE.
new_card_token: result.verificationToken
}) })
}) })
); );
if (!retry.ok) { if (!retry.ok) {
const errText = await retry.text(); const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed'); const err = new Error(extractErrorMessage(errText) || 'Till sale failed');
(err as { bodyText?: string }).bodyText = errText;
throw err;
} }
return; return;
} }
twoFactor.reveal = true; twoFactor.declineConsent();
if (result.outcome === 'sca-unavailable') { if (result.outcome === 'sca-unavailable') {
throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`); throw new Error(SCA_REFUSAL_MESSAGE_TILL);
} }
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE); throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally { } finally {
@@ -898,6 +979,18 @@
</div> </div>
{/if} {/if}
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
customer must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
message={SCA_REFUSAL_MESSAGE_TILL}
onOk={() => {
lastSCAOutcome = '';
paymentError = null;
}}
/>
<!-- B6/B10: saved-card till charges require the customer's <!-- B6/B10: saved-card till charges require the customer's
current 2FA verification code when the backend enforces current 2FA verification code when the backend enforces
the gate. --> the gate. -->
@@ -909,8 +1002,7 @@
{#if twoFactor.showInput} {#if twoFactor.showInput}
<Button <Button
variant="outline" variant="outline"
size="sm" class="min-h-11 w-full"
class="w-full"
loading={twoFactor.requesting} loading={twoFactor.requesting}
disabled={twoFactor.requesting} disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode} onclick={twoFactor.requestNewCode}
@@ -950,7 +1042,7 @@
</div> </div>
{:else} {:else}
<Button <Button
class="mt-3 w-full" class="mt-3 min-h-11 w-full"
onclick={chargeCart} onclick={chargeCart}
loading={processing} loading={processing}
disabled={!canCharge || disabled={!canCharge ||
@@ -39,6 +39,7 @@
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte'; import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte'; import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy'; import { POLICY } from '$lib/constants/policy';
import { import {
@@ -51,7 +52,8 @@
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
runSavedCardSCAProactively, runSavedCardSCAProactively,
shouldFallbackTo2FA, scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
@@ -168,9 +170,9 @@
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. // new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// from backup to the only available gate (scaAvailable → false); every other // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// outcome keeps SCA primary for the next retry. // fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer // True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel. // approves in their banking app) — drives the "approve in banking app" panel.
@@ -182,7 +184,9 @@
enabled: () => twoFactorEnabled, enabled: () => twoFactorEnabled,
gateActive: () => gateActive: () =>
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard), savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome) // 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
}); });
const depositCardFormValid = $derived(paymentCardSelectionValid); const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -470,6 +474,15 @@
toast.error(depositError); toast.error(depositError);
return; return;
} }
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Abort this attempt BEFORE
// any charge is submitted and surface the refusal notice —
// there is NO 2FA fallback; the deposit is paid online later.
twoFactor.declineConsent();
twoFactor.reveal = false;
paymentAttempted = false;
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken; if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally { } finally {
waitingForSCA = false; waitingForSCA = false;
@@ -482,8 +495,12 @@
idempotency_key: depositIdempotencyKey, idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}), ...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}), // C1: the SCA tokenize-result token for a saved card is the
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}) // charge SOURCE (new_card_token) alongside the card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken ? { verification_code: twoFactor.code } : {}),
...scaFallbackConsentFields(twoFactor.consentAccepted)
}; };
paymentAttempted = true; paymentAttempted = true;
@@ -544,8 +561,8 @@
}), }),
// Finding 4: a 2FA-gated charge consumed its code at the backend gate // 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. The // — a 503 auto-retry would re-send a dead code and self-defeat. The
// code is the gate only when no SCA verification token is present. // code is the gate only when no SCA tokenize-result is present.
{ verificationCodeGated: twoFactor.showInput && !('verification_token' in body) } { verificationCodeGated: twoFactor.showInput && !('new_card_token' in body) }
); );
if (response.ok) { if (response.ok) {
@@ -584,6 +601,11 @@
// charge), surface the guidance and let the user retry — never re-run SCA // charge), surface the guidance and let the user retry — never re-run SCA
// silently mid-flow. // silently mid-flow.
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) { if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
// 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 user sees the
// SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
depositError = VERIFICATION_REQUIRED_MESSAGE; depositError = VERIFICATION_REQUIRED_MESSAGE;
toast.warning(depositError); toast.warning(depositError);
return; return;
@@ -2773,6 +2795,20 @@
</div> </div>
{/if} {/if}
<!-- C6: SCA-unavailable refusal — the ONLY behaviour
on a genuine sca-unavailable outcome: the charge
cannot complete and the user must pay online later
(no 2FA code fallback). -->
<div class="mb-6">
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
onOk={() => {
lastSCAOutcome = '';
prevStep();
}}
/>
</div>
<!-- B6/B10: saved-card deposits require the customer's <!-- B6/B10: saved-card deposits require the customer's
current 2FA verification code when the backend current 2FA verification code when the backend
enforces the gate. --> enforces the gate. -->
@@ -2785,8 +2821,7 @@
{#if twoFactor.showInput && twoFactorEnabled} {#if twoFactor.showInput && twoFactorEnabled}
<Button <Button
variant="outline" variant="outline"
size="sm" class="min-h-11 w-full"
class="w-full"
loading={twoFactor.requesting} loading={twoFactor.requesting}
disabled={twoFactor.requesting} disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode} onclick={twoFactor.requestNewCode}
@@ -2797,13 +2832,18 @@
</div> </div>
<div class="flex items-center justify-between border-t pt-4"> <div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}> <Button
variant="ghost"
onclick={prevStep}
disabled={isProcessingPayment}
class="min-h-11"
>
Back Back
</Button> </Button>
<Button <Button
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing} disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
onclick={() => processPayment(calculateDepositAmount())} onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground" class="min-h-11 bg-primary text-primary-foreground"
> >
{isProcessingPayment {isProcessingPayment
? 'Processing...' ? 'Processing...'
@@ -5,9 +5,7 @@
import type { SquareVerificationContact } from './SquareCardInput.svelte'; import type { SquareVerificationContact } from './SquareCardInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { isSquareConfigured } from '$lib/square/square'; import { isSquareConfigured } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { generateUUID } from '$lib/utils/uuid'; import { generateUUID } from '$lib/utils/uuid';
import { resolve } from '$app/paths';
export interface SelectableCard { export interface SelectableCard {
id: string; id: string;
@@ -46,12 +44,12 @@
// would collide on the same checkbox id. Pure SPA, so no SSR concern. // would collide on the same checkbox id. Pure SPA, so no SSR concern.
const consentId = `save-card-consent-${generateUUID()}`; const consentId = `save-card-consent-${generateUUID()}`;
// B6/B10: saved-card charges require the customer's current 2FA verification // SCA-only posture (C6): every saved-card charge is authorised by Square
// code. This no longer BLOCKS saved-card selection — the code is collected // Strong Customer Authentication — there is no 2FA code fallback and no
// at the charge step (the parent charge forms show the input). The new-card // 2FA-setup prerequisite for saving a card (the STORE-intent tokenize
// (nonce) path keeps its own SCA via Square tokenizeWithVerification. // carries its own SCA). The note below just forewarns the buyer that the
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); // issuer may ask them to approve the payment in their banking app.
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); const showSCANote = $derived(cards.length > 0);
// Auto-select the default saved card when cards first load. Guarded by // Auto-select the default saved card when cards first load. Guarded by
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is // !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
@@ -87,11 +85,13 @@
/** /**
* Tokenizes the new-card form with SCA verification details (SCA-mandated * Tokenizes the new-card form with SCA verification details (SCA-mandated
* for UK card-not-present charges). Returns the nonce AND the verification * for UK card-not-present charges). In the CURRENT SDK the returned nonce
* token, which the caller must send to the backend as `verification_token` * IS the SCA-verified tokenize-result — there is no separate verification
* alongside the nonce so the charge completes. Pass `saveCard=true` when the * token (that nested shape only came from the deprecated verifyBuyer()
* card will ALSO be saved for reuse — the SCA intent becomes * flow) — so the caller sends the `nonce` as the charge source. Pass
* `CHARGE_AND_STORE` (Square requires it for charge-and-store flows). * `saveCard=true` when the card will ALSO be saved for reuse — the SCA
* intent becomes `CHARGE_AND_STORE` (Square requires it for charge-and-
* store flows).
*/ */
export async function tokenizeWithVerification( export async function tokenizeWithVerification(
amount: number, amount: number,
@@ -105,23 +105,23 @@
} }
</script> </script>
{#if savedCardChargeRequires2FACode} {#if showSCANote}
{#if twoFactorEnabled} <div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="rounded-md border border-blue-200 bg-blue-50 p-3"> <svg
<p class="text-sm text-blue-800"> class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
Your card issuer will ask you to approve this payment in your banking app. viewBox="0 0 24 24"
</p> fill="none"
</div> stroke="currentColor"
{:else} stroke-width="2"
<div class="rounded-md border border-amber-200 bg-amber-50 p-3"> >
<p class="text-sm text-amber-800"> <circle cx="12" cy="12" r="10" />
Two-factor authentication is required to use online card payments. <line x1="12" y1="8" x2="12" y2="12" />
<a href={resolve('/account')} class="font-medium underline" <line x1="12" y1="16" x2="12.01" y2="16" />
>Enable it in your account settings</a </svg>
>. <p class="text-xs text-amber-800">
</p> Your card issuer may ask you to approve this payment in your banking app.
</div> </p>
{/if} </div>
{/if} {/if}
{#if cards.length > 0} {#if cards.length > 0}
@@ -186,7 +186,7 @@
<CardEntryUnavailable /> <CardEntryUnavailable />
{/if} {/if}
{#if canSaveCards && squareCardReady && !(savedCardChargeRequires2FACode && !twoFactorEnabled)} {#if canSaveCards && squareCardReady}
<label <label
class="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-600" class="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-600"
for={consentId} for={consentId}
@@ -264,6 +264,24 @@
}); });
} }
/**
* DEV-ONLY save-only tokenization (account "Add a card" flow) — the
* counterpart of SquareCardInput.tokenizeForStore(). A pure save runs no
* amount-bound challenge in the mock; it returns the same deterministic
* nonce the backend dev mock stores.
*/
export async function tokenizeForStore(_contact?: {
givenName?: string;
familyName?: string;
email?: string;
}): Promise<{ token: string }> {
if (!complete) {
throw new Error('Card details are incomplete');
}
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
return Promise.resolve({ token });
}
/** /**
* DEV-ONLY saved-card SCA verification token for a ccof charge — the * DEV-ONLY saved-card SCA verification token for a ccof charge — the
* deterministic counterpart of Square's buyer-verification flow for a saved * deterministic counterpart of Square's buyer-verification flow for a saved
@@ -18,8 +18,8 @@
PAYMENT_METHOD_SAVED_CARD, PAYMENT_METHOD_SAVED_CARD,
runSavedCardSCAProactively, runSavedCardSCAProactively,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, scaFallbackConsentFields,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE, shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE, VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode, adminRequestNewTwoFactorCode,
@@ -27,6 +27,7 @@
} from '$lib/square/square'; } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.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 OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid'; import { generateUUID } from '$lib/utils/uuid';
@@ -145,9 +146,9 @@
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor). // GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired); const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false); let customerTwoFactorEnabled = $state(false);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// from backup to the only available gate (scaAvailable → false); every other // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// outcome keeps SCA primary for the next retry. // fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); let lastSCAOutcome = $state('');
const stamps = $derived(booking.user?.loyalty_stamps ?? 0); const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
@@ -163,7 +164,9 @@
enabled: () => true, enabled: () => true,
gateActive: () => gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === PAYMENT_METHOD_SAVED_CARD, twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === PAYMENT_METHOD_SAVED_CARD,
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome), // 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: () => { mint: () => {
const customerID = booking.user_id ?? booking.user?.id; const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode(); return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
@@ -591,6 +594,9 @@
selectedMethod = null; selectedMethod = null;
checkoutId = null; checkoutId = null;
error = null; error = null;
// Clear any refusal from a previous attempt so re-entering the saved-card
// screen doesn't re-show it before a fresh SCA attempt.
lastSCAOutcome = '';
} }
$effect(() => { $effect(() => {
@@ -936,13 +942,13 @@
return; return;
} }
if (proactive.outcome === 'sca-unavailable') { if (proactive.outcome === 'sca-unavailable') {
// MIT surface: a token-less ccof is never sent even when SCA // C6: SCA genuinely can't run. Stop the charge — a token-less
// can't run — stop the charge and surface the 2FA fallback // ccof is never sent — and surface the refusal notice; there
// gate (the operator enters the customer's code and re-taps). // is NO 2FA fallback. The operator taps OK to close, or Back
twoFactor.reveal = true; // to pick a different payment method / retry SCA.
status = 'error'; twoFactor.declineConsent();
error = `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`; twoFactor.reveal = false;
toast.error(error); status = 'saved-card-selecting';
return; return;
} }
verificationToken = proactive.verificationToken ?? ''; verificationToken = proactive.verificationToken ?? '';
@@ -960,8 +966,14 @@
payment_type: 'full', payment_type: 'full',
payment_method: 'saved_card', payment_method: 'saved_card',
saved_card_id: selectedSavedCardId, saved_card_id: selectedSavedCardId,
...(verificationToken ? { verification_token: verificationToken } : {}), // C1: the SCA tokenize-result token is the charge SOURCE
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}), // (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 idempotency_key: savedCardIdempotencyKey
}) })
}), }),
@@ -987,8 +999,11 @@
payment_type: 'full', payment_type: 'full',
payment_method: 'saved_card', payment_method: 'saved_card',
saved_card_id: selectedSavedCardId, saved_card_id: selectedSavedCardId,
...(verificationToken ? { verification_token: verificationToken } : {}), ...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}), ...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: savedCardIdempotencyKey idempotency_key: savedCardIdempotencyKey
} }
}; };
@@ -1035,6 +1050,11 @@
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment'; let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? ''; const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
if (isVerificationRequiredSignal(responseStatus, 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();
msg = VERIFICATION_REQUIRED_MESSAGE; msg = VERIFICATION_REQUIRED_MESSAGE;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -1750,6 +1770,17 @@
</div> </div>
{/if} {/if}
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
customer must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
onOk={() => {
lastSCAOutcome = '';
handleClose();
}}
/>
<!-- B6/B10: saved-card charges require the customer's current 2FA <!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. --> verification code when the backend enforces the gate. -->
<TwoFactorCodeInput <TwoFactorCodeInput
@@ -1764,8 +1795,7 @@
</p> </p>
<Button <Button
variant="outline" variant="outline"
size="sm" class="min-h-11 w-full"
class="w-full"
loading={twoFactor.requesting} loading={twoFactor.requesting}
disabled={twoFactor.requesting} disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode} onclick={twoFactor.requestNewCode}
@@ -1775,10 +1805,10 @@
{/if} {/if}
<div class="flex gap-3"> <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 <Button
onclick={handleSavedCardPayment} onclick={handleSavedCardPayment}
class="flex-1" class="min-h-11 flex-1"
disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing} disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing}
> >
Charge Saved Card Charge Saved Card
@@ -0,0 +1,56 @@
<!--
ScaFallbackConsentDialog.svelte — C6 refusal notice shown whenever a
saved-card charge hits genuine `sca-unavailable` (the issuer's in-app SCA
challenge cannot run). The C6 legal verdict (PSR 2017 SCA is non-waivable;
the merchant is liable regardless of consent) removed the homegrown 2FA code
gate as an SCA fallback for saved-card charges, so this is now a REFUSAL:
the customer is told the payment cannot complete and invited to pay online
later, with a single OK button that closes the flow cleanly (no charge
submitted). It must NEVER offer a "continue with verification code" action
under the default SCA-only posture.
Inline panel (no portal) so it nests cleanly inside every payment surface —
the modals, the booking-flow step, the tip card and the till card.
-->
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { SCA_REFUSAL_MESSAGE_ONLINE } from '$lib/square/square';
let {
open = false,
onOk,
message = SCA_REFUSAL_MESSAGE_ONLINE
}: {
open?: boolean;
onOk: () => void;
message?: string;
} = $props();
</script>
{#if open}
<div
role="alertdialog"
aria-label="Payment cannot be completed"
class="rounded-lg border border-red-200 bg-red-50 p-4"
>
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-red-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<div class="min-w-0">
<h4 class="text-sm font-semibold text-red-900">Payment can't be processed right now</h4>
<p class="mt-1 text-sm text-red-800">{message}</p>
</div>
</div>
<Button class="mt-4 min-h-11 w-full" onclick={onOk}>OK</Button>
</div>
{/if}
@@ -3,9 +3,8 @@
getSquarePayments, getSquarePayments,
isSquareConfigured, isSquareConfigured,
isSquareMock, isSquareMock,
parseTokenizeVerificationResult,
type SquareTokenizeResult, type SquareTokenizeResult,
type SquareVerificationContact type SquareVerificationContact as SquareContactType
} from '$lib/square/square'; } from '$lib/square/square';
/** Re-exported for the payment surfaces that import these from this /** Re-exported for the payment surfaces that import these from this
@@ -18,10 +17,8 @@
/** Re-exported from square.ts — the single shared home of the saved-card SCA /** Re-exported from square.ts — the single shared home of the saved-card SCA
* logic (tokenizer + the proactive runner). Kept here so the six payment * logic (tokenizer + the proactive runner). Kept here so the six payment
* surfaces' existing imports stay unchanged. */ * surfaces' existing imports stay unchanged. */
export { export { tokenizeSavedCardWithVerification } from '$lib/square/square';
tokenizeSavedCardWithVerification, export type { SquareVerificationContact } from '$lib/square/square';
type SquareVerificationContact
} from '$lib/square/square';
/** Result of a tokenize-with-verification call. */ /** Result of a tokenize-with-verification call. */
export interface TokenizeWithVerificationResult { export interface TokenizeWithVerificationResult {
@@ -31,10 +28,10 @@
/** Square Web Payments `card.tokenize()` verificationDetails shape. */ /** Square Web Payments `card.tokenize()` verificationDetails shape. */
interface SquareVerificationDetails { interface SquareVerificationDetails {
amount: string; amount?: string;
billingContact?: SquareVerificationContact; billingContact?: SquareContactType;
intent: string; intent: string;
currencyCode: string; currencyCode?: string;
customerInitiated: boolean; customerInitiated: boolean;
sellerKeyedIn: boolean; sellerKeyedIn: boolean;
} }
@@ -188,9 +185,10 @@
* Authentication for most online payments — without verificationDetails, * Authentication for most online payments — without verificationDetails,
* Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED. * Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED.
* *
* Returns BOTH the card nonce and the verification token, which the caller * In the CURRENT SDK the SCA-verified tokenize-result (`result.token`) is
* must send to the backend as `verification_token` alongside * the one-time source for the charge — there is no separate verification
* `new_card_token`/`card_token`. * token (that nested shape only came from the deprecated verifyBuyer()
* flow), so the caller sends the returned `nonce` as the charge source.
* *
* @param amount The amount that WILL be charged, in pence (minor units). * @param amount The amount that WILL be charged, in pence (minor units).
* Square requires this to match the eventual payment amount. * Square requires this to match the eventual payment amount.
@@ -209,7 +207,7 @@
*/ */
export async function tokenizeWithVerification( export async function tokenizeWithVerification(
amount: number, amount: number,
contact?: SquareVerificationContact, contact?: SquareContactType,
saveCard: boolean = false saveCard: boolean = false
): Promise<TokenizeWithVerificationResult> { ): Promise<TokenizeWithVerificationResult> {
if (isSquareMock()) { if (isSquareMock()) {
@@ -258,6 +256,49 @@
.join(', ') || 'Card details are incomplete'; .join(', ') || 'Card details are incomplete';
throw new Error(detail); throw new Error(detail);
} }
/**
* Tokenizes the entered card for SAVE-ONLY (the account "Add a card"
* flow): intent `STORE`, no amount, no currencyCode — Square's current
* card-on-file save contract. The SCA challenge runs at tokenization, so
* the returned token is the SCA-verified token the backend stores as the
* card's source (M11: the 2FA gate must not fire for an SCA-authorised
* save). Pass the billing contact we already hold when available.
*/
export async function tokenizeForStore(contact?: SquareContactType): Promise<{ token: string }> {
if (isSquareMock()) {
if (!mockForm) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
return mockForm.tokenizeForStore(contact);
}
const card = cardInstance as {
tokenize: (verificationDetails: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
const verificationDetails: SquareVerificationDetails = {
// STORE intent — no amount: saving a card does not charge, so there
// is no amount to bind the SCA challenge to.
intent: 'STORE',
customerInitiated: true,
sellerKeyedIn: false
};
if (contact && (contact.givenName || contact.familyName || contact.email)) {
verificationDetails.billingContact = contact;
}
const result = await card.tokenize(verificationDetails);
if (result.status === 'OK' && result.token) {
return { token: result.token };
}
const detail =
result.errors
?.map((e) => e.message || e.code)
.filter(Boolean)
.join(', ') || 'Card details are incomplete';
throw new Error(detail);
}
</script> </script>
{#if isSquareMock()} {#if isSquareMock()}
@@ -23,11 +23,13 @@
isVerificationRequiredSignal, isVerificationRequiredSignal,
runSavedCardSCAProactively, runSavedCardSCAProactively,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account // Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
// booking-modal tip dialog. The routes resolve the booking (most-recent past // booking-modal tip dialog. The routes resolve the booking (most-recent past
@@ -36,7 +38,9 @@
// idempotency-key derivation, submitTip, success and error handling — lives // idempotency-key derivation, submitTip, success and error handling — lives
// in ONE place so the three surfaces can't diverge. When embedded in a modal // in ONE place so the three surfaces can't diverge. When embedded in a modal
// (UserBookingModal), `onSuccess` lets the host close itself and refresh // (UserBookingModal), `onSuccess` lets the host close itself and refresh
// instead of navigating home; standalone pages omit it. // instead of navigating home; standalone pages omit it. `onCancel` lets a
// modal host close itself when the customer chooses "Cancel and pay later"
// on the SCA-unavailable consent notice.
type BookingService = { type BookingService = {
service_id: string; service_id: string;
booking_id: string; booking_id: string;
@@ -65,7 +69,11 @@
payments?: Payment[]; payments?: Payment[];
}; };
const { booking, onSuccess }: { booking: Booking; onSuccess?: () => void } = $props(); const {
booking,
onSuccess,
onCancel
}: { booking: Booking; onSuccess?: () => void; onCancel?: () => void } = $props();
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle'); let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Synchronous double-click guard for submitTip. paymentState is only set to // Synchronous double-click guard for submitTip. paymentState is only set to
@@ -118,9 +126,9 @@
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. // new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// from backup to the only available gate (scaAvailable → false); every other // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// outcome keeps SCA primary for the next retry. // fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer // True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel. // approves in their banking app) — drives the "approve in banking app" panel.
@@ -132,7 +140,9 @@
const twoFactor = useTwoFactorCodeForSavedCard({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled, enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard), gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome) // 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
}); });
const isCardValid = $derived(cardSelectionValid); const isCardValid = $derived(cardSelectionValid);
@@ -334,6 +344,15 @@
toast.error(tipError); toast.error(tipError);
return; return;
} }
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Abort this attempt BEFORE
// any charge is submitted and surface the refusal notice —
// there is NO 2FA fallback; the tip is paid online later.
twoFactor.declineConsent();
twoFactor.reveal = false;
paymentState = 'idle';
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken; if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally { } finally {
waitingForSCA = false; waitingForSCA = false;
@@ -344,8 +363,14 @@
idempotency_key: tipIdempotencyKey, idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}), ...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}), // C1: the SCA tokenize-result token for a saved card is the
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}) // charge SOURCE (new_card_token) alongside the card ref —
// never the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted)
}; };
const response = await submitPaymentWithRetry( const response = await submitPaymentWithRetry(
@@ -398,6 +423,11 @@
const bodyText = (err as { bodyText?: string })?.bodyText ?? ''; const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText); const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) { if (verificationFailure) {
// 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 user sees the SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
errorMessage = VERIFICATION_REQUIRED_MESSAGE; errorMessage = VERIFICATION_REQUIRED_MESSAGE;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -574,6 +604,18 @@
onValidityChange={(v) => (cardSelectionValid = v)} onValidityChange={(v) => (cardSelectionValid = v)}
/> />
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
user must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
onOk={() => {
lastSCAOutcome = '';
tipError = null;
onCancel?.();
}}
/>
<!-- B6/B10: saved-card tips require the customer's current 2FA <!-- B6/B10: saved-card tips require the customer's current 2FA
verification code when the backend enforces the gate. --> verification code when the backend enforces the gate. -->
<TwoFactorCodeInput <TwoFactorCodeInput
@@ -584,8 +626,7 @@
{#if twoFactor.showInput && twoFactorEnabled} {#if twoFactor.showInput && twoFactorEnabled}
<Button <Button
variant="outline" variant="outline"
size="sm" class="min-h-11 w-full"
class="w-full"
loading={twoFactor.requesting} loading={twoFactor.requesting}
disabled={twoFactor.requesting} disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode} onclick={twoFactor.requestNewCode}
@@ -623,7 +664,7 @@
{/if} {/if}
<Button <Button
class="w-full" class="min-h-11 w-full"
size="lg" size="lg"
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing} disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing}
loading={paymentState === 'processing'} loading={paymentState === 'processing'}
@@ -28,10 +28,12 @@
isVerificationRequiredSignal, isVerificationRequiredSignal,
runSavedCardSCAProactively, runSavedCardSCAProactively,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
interface Props { interface Props {
booking: Booking; booking: Booking;
@@ -61,9 +63,9 @@
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. // new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// from backup to the only available gate (scaAvailable → false); every other // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// outcome keeps SCA primary for the next retry. // fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state(''); let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge (card.tokenize with // True while the proactive saved-card SCA challenge (card.tokenize with
// verificationDetails) is in flight — the challenge is out-of-band (the // verificationDetails) is in flight — the challenge is out-of-band (the
@@ -73,7 +75,9 @@
const twoFactor = useTwoFactorCodeForSavedCard({ const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled, enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard), gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome) // 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
}); });
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error'; type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
@@ -518,6 +522,15 @@
toast.error(error); toast.error(error);
return; return;
} }
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Abort this attempt BEFORE any
// charge is submitted and surface the refusal notice — there
// is NO 2FA fallback; the user pays online later.
twoFactor.declineConsent();
twoFactor.reveal = false;
status = 'idle';
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken; if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally { } finally {
waitingForSCA = false; waitingForSCA = false;
@@ -562,8 +575,14 @@
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}), ...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
...(cardId ? { card_id: cardId } : {}), ...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}), // C1: the SCA tokenize-result token for a saved card is
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}), // the charge SOURCE (new_card_token) alongside the
// card ref — never the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: payIdempotencyKey idempotency_key: payIdempotencyKey
}) })
}), }),
@@ -659,6 +678,11 @@
const bodyText = (_err as { bodyText?: string })?.bodyText ?? ''; const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText); const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) { if (verificationFailure) {
// 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
// user sees the SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
msg = VERIFICATION_REQUIRED_MESSAGE; msg = VERIFICATION_REQUIRED_MESSAGE;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -1077,6 +1101,17 @@
{/if} {/if}
{/if} {/if}
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
user must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
onOk={() => {
lastSCAOutcome = '';
handleClose();
}}
/>
<!-- B6/B10: saved-card charges require the customer's current 2FA <!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. --> verification code when the backend enforces the gate. -->
<TwoFactorCodeInput <TwoFactorCodeInput
@@ -1087,8 +1122,7 @@
{#if twoFactor.showInput && twoFactorEnabled} {#if twoFactor.showInput && twoFactorEnabled}
<Button <Button
variant="outline" variant="outline"
size="sm" class="min-h-11 w-full"
class="w-full"
loading={twoFactor.requesting} loading={twoFactor.requesting}
disabled={twoFactor.requesting} disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode} onclick={twoFactor.requestNewCode}
@@ -1139,7 +1173,7 @@
<!-- Pay button --> <!-- Pay button -->
<Button <Button
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())} onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full" class="min-h-11 w-full"
loading={status === 'processing'} loading={status === 'processing'}
disabled={payButtonDisabled || twoFactor.missing} disabled={payButtonDisabled || twoFactor.missing}
> >
@@ -1223,7 +1257,7 @@
<!-- Pay button --> <!-- Pay button -->
<Button <Button
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())} onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full" class="min-h-11 w-full"
loading={status === 'processing'} loading={status === 'processing'}
disabled={payButtonDisabled || twoFactor.missing} disabled={payButtonDisabled || twoFactor.missing}
> >
@@ -77,7 +77,14 @@
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
style={z > 0 ? `z-index: ${z}` : undefined} style={z > 0 ? `z-index: ${z}` : undefined}
class={cn( class={cn(
'mm:max-w-lg fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', // Mirrors dialog-content.svelte: on <sm the alert is a bottom sheet
// pinned to the bottom edge (max-h uses dvh so it shrinks above the
// iOS keyboard), with safe-area padding so the last row clears the
// home indicator. max-h is split into a plain `max-h-[90vh]` plus a
// `max-sm:`-scoped dvh value so a consumer `max-h-*` override can't
// wipe the keyboard-safe mobile height. Desktop (sm+) is the
// original centered dialog.
'fixed right-0 bottom-0 left-0 grid max-h-[90vh] w-full max-w-none translate-x-0 translate-y-0 gap-4 overflow-y-auto rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-sm:max-h-[calc(100dvh-4rem)] sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
stripZIndexClasses(className) stripZIndexClasses(className)
)} )}
{...restProps} {...restProps}
+401 -9
View File
@@ -3,6 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
NONCE_STALENESS_MS, NONCE_STALENESS_MS,
SAVED_CARD_VERIFICATION_MESSAGE, SAVED_CARD_VERIFICATION_MESSAGE,
SCA_FALLBACK_CONSENT_VERSION,
SCA_REFUSAL_MESSAGE_ONLINE,
SCA_REFUSAL_MESSAGE_TILL,
VERIFICATION_REQUIRED_MESSAGE, VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode, adminRequestNewTwoFactorCode,
campaignDiscountPence, campaignDiscountPence,
@@ -17,8 +20,10 @@ import {
parseTokenizeVerificationResult, parseTokenizeVerificationResult,
requestNewTwoFactorCode, requestNewTwoFactorCode,
sanitizeDecimalInput, sanitizeDecimalInput,
shouldFallbackTo2FA, scaFallbackConsentFields,
submitPaymentWithRetry shouldShowSCARefusal,
submitPaymentWithRetry,
type SavedCardVerificationOutcome
} from './square'; } from './square';
import type * as SquareModule from './square'; import type * as SquareModule from './square';
@@ -306,7 +311,7 @@ describe('isVerificationRequiredSignal', () => {
}); });
}); });
describe('shouldFallbackTo2FA', () => { describe('shouldShowSCARefusal', () => {
it.each([ it.each([
['sca-unavailable', true], ['sca-unavailable', true],
['verified', false], ['verified', false],
@@ -314,19 +319,59 @@ describe('shouldFallbackTo2FA', () => {
['sca-failed', false], ['sca-failed', false],
['', false] ['', false]
])('outcome %s → %s', (outcome, expected) => { ])('outcome %s → %s', (outcome, expected) => {
expect(shouldFallbackTo2FA(outcome)).toBe(expected); expect(shouldShowSCARefusal(outcome)).toBe(expected);
}); });
it('demotes to the 2FA gate only on sca-unavailable', () => { it('refuses the charge only on sca-unavailable (C6 SCA-only posture)', () => {
expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true); expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
}); });
it('keeps SCA primary after a successful verification', () => { it('keeps SCA primary after a successful verification', () => {
expect(shouldFallbackTo2FA('verified')).toBe(false); expect(shouldShowSCARefusal('verified')).toBe(false);
}); });
it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => { it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => {
expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false); expect(shouldShowSCARefusal('challenge-cancelled')).toBe(false);
});
});
describe('C6 SCA-unavailable refusal (no 2FA fallback for saved-card charges)', () => {
it('online refusal tells the customer the payment failed and can be retried', () => {
expect(SCA_REFUSAL_MESSAGE_ONLINE.length).toBeGreaterThan(0);
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('not');
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('did not go through');
// Online payments are refused as failed — never "pay online later" (the
// customer IS online; the deposit/gift-card purchase simply fails).
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('pay online later');
// The refusal must NOT offer the verification-code fallback.
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('verification code');
});
it('till refusal is the only surface that invites paying online later', () => {
expect(SCA_REFUSAL_MESSAGE_TILL.length).toBeGreaterThan(0);
// Only the in-person till surface may suggest paying online later.
expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).toContain('pay online later');
// It still must NOT offer the verification-code fallback.
expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).not.toContain('verification code');
});
it('the refusal path never sends consent_accepted on its own', () => {
// A refused charge (sca-unavailable) shows the refusal and proceeds
// nowhere — the versioned consent payload exists only for the explicit
// opt-in path (TWO_FACTOR_FALLBACK deployments) via
// scaFallbackConsentFields(true), which the refusal never reaches: the
// charge surfaces always call it with consent unaccepted, so it yields
// an empty object and no consent_accepted/consent_version is sent.
expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
expect(scaFallbackConsentFields(false)).toEqual({});
});
it('SCA_FALLBACK_CONSENT_VERSION stays versioned for the explicit opt-in path', () => {
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
expect(scaFallbackConsentFields(true)).toEqual({
consent_version: SCA_FALLBACK_CONSENT_VERSION,
consent_accepted: true
});
}); });
}); });
@@ -363,7 +408,7 @@ describe('parseTokenizeVerificationResult', () => {
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' }); ).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
}); });
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => { it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (C6 refusal)', () => {
expect( expect(
parseTokenizeVerificationResult({ parseTokenizeVerificationResult({
status: 'FAILED', status: 'FAILED',
@@ -669,6 +714,23 @@ describe('adminRequestNewTwoFactorCode', () => {
}); });
}); });
describe('SCA-unavailable fallback consent (C6)', () => {
it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => {
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
});
it('scaFallbackConsentFields returns the versioned payload when accepted', () => {
expect(scaFallbackConsentFields(true)).toEqual({
consent_version: SCA_FALLBACK_CONSENT_VERSION,
consent_accepted: true
});
});
it('scaFallbackConsentFields returns an empty object when not accepted', () => {
expect(scaFallbackConsentFields(false)).toEqual({});
});
});
describe('depositChargePence', () => { describe('depositChargePence', () => {
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => { it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
expect(depositChargePence(2000, 500)).toBe(1500); expect(depositChargePence(2000, 500)).toBe(1500);
@@ -686,3 +748,333 @@ describe('depositChargePence', () => {
expect(depositChargePence(2000, 0)).toBe(2000); expect(depositChargePence(2000, 0)).toBe(2000);
}); });
}); });
// The mock card form's saved-card SCA helper drives every outcome of
// runSavedCardSCAProactively. tokenizeSavedCardWithVerification dynamically
// imports MockCardForm when isSquareMock() is true; the vi.hoisted mock lets
// the tests choose the tokenize result per scenario, so the outcome mapping
// (verified / challenge-cancelled / sca-unavailable / sca-failed) and the
// onOutcome wiring are exercised through the real shared implementation.
const mockSCATokenize = vi.hoisted(() => ({ fn: vi.fn() }));
vi.mock('$lib/components/payments/MockCardForm.svelte', () => ({
tokenizeSavedCard: mockSCATokenize.fn
}));
describe('runSavedCardSCAProactively', () => {
async function loadSquareInMockMode(): Promise<typeof SquareModule> {
vi.resetModules();
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', 'mock');
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', '');
vi.stubEnv('VITE_SQUARE_LOCATION_ID', '');
vi.stubEnv('DEV', true);
return await import('./square');
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
mockSCATokenize.fn.mockClear();
});
it('maps a verified tokenize result and records it via onOutcome', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: 'verify_mock_ok',
outcome: 'verified'
});
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toBe('verify_mock_ok');
expect(outcomes).toEqual(['verified']);
});
it('maps a challenge-cancelled tokenize result (retryable via SCA, never 2FA)', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: null,
outcome: 'challenge-cancelled'
});
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('challenge-cancelled');
expect(res.verificationToken).toBeUndefined();
expect(outcomes).toEqual(['challenge-cancelled']);
});
it('maps an sca-unavailable tokenize result (C6 refusal)', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: null,
outcome: 'sca-unavailable'
});
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: () => {}
});
expect(res.outcome).toBe('sca-unavailable');
expect(res.verificationToken).toBeUndefined();
});
it('maps an sca-failed tokenize result', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: null,
outcome: 'sca-failed'
});
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: () => {}
});
expect(res.outcome).toBe('sca-failed');
});
it('demotes to sca-unavailable when no saved card is resolved', async () => {
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: '',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('sca-unavailable');
expect(outcomes).toEqual(['sca-unavailable']);
expect(mockSCATokenize.fn).not.toHaveBeenCalled();
});
it('falls back to the deterministic dev token when the mock tokenizer throws', async () => {
// tokenizeSavedCardWithVerification swallows a throwing MockCardForm
// tokenizer (dev-mock convenience) and returns the deterministic fake
// token — so the shared runner reports verified, never a dead-end.
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toMatch(/^verify_mock_/);
expect(outcomes).toEqual(['verified']);
});
});
// The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs:
// this vitest config has no svelte plugin, so `$state` / `$derived` are
// provided as plain-value globals (no reactivity — each composable instance is
// a state snapshot). That still exercises the full imperative surface: the
// consent accept/decline flags, the code setter, the mint selection and the
// requestNewCode toast flow for every outcome.
const toastMocks = vi.hoisted(() => ({
toast: { success: vi.fn(), error: vi.fn() }
}));
vi.mock('svelte-sonner', () => toastMocks);
vi.mock('$lib/square/square', () => ({
requestNewTwoFactorCode: vi.fn(async () => ({
status: 200,
ok: true,
message: 'sent'
}))
}));
describe('useTwoFactorCodeForSavedCard', () => {
async function loadComposable() {
vi.stubGlobal('$state', (v: unknown) => v);
vi.stubGlobal('$derived', (v: unknown) => v);
const mod = (await import('../stores/twoFactorCode.svelte')) as {
useTwoFactorCodeForSavedCard: (options: {
enabled: () => boolean;
gateActive: () => boolean;
mint?: () => Promise<{ status: number; ok: boolean; message: string }>;
scaAvailable?: () => boolean;
}) => {
code: string;
setCode: (v: string) => void;
reveal: boolean;
showInput: boolean;
missing: boolean;
requesting: boolean;
requestNewCode: () => Promise<void>;
consentAccepted: boolean;
acceptConsent: () => void;
declineConsent: () => void;
};
};
return mod;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('starts in a clean state with the 2FA input hidden', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
expect(comp.showInput).toBe(false);
expect(comp.missing).toBe(false);
expect(comp.reveal).toBe(false);
expect(comp.consentAccepted).toBe(false);
expect(comp.code).toBe('');
expect(comp.requesting).toBe(false);
});
it('still exposes the 2FA code state for account/admin uses (C6)', async () => {
// The composable keeps the full code-state API even though charge
// surfaces no longer auto-trigger it: code, setCode, reveal, missing,
// requestNewCode and the consent flags all remain available.
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
comp.setCode('123456');
expect(comp.code).toBe('123456');
comp.reveal = true;
expect(comp.reveal).toBe(true);
expect(comp.consentAccepted).toBe(false);
comp.acceptConsent();
expect(comp.consentAccepted).toBe(true);
});
it('an sca-unavailable outcome alone never surfaces the 2FA input (C6 refusal)', async () => {
// C6: even when SCA is reported unavailable, the code input must NOT
// appear without the explicit opt-in consent (or a backend reveal) —
// the charge surfaces refuse instead, so no verification-code fallback
// is ever offered off an SCA outcome on its own.
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
scaAvailable: () => false
});
expect(comp.consentAccepted).toBe(false);
expect(comp.showInput).toBe(false);
expect(comp.missing).toBe(false);
});
it('acceptConsent reveals the input; declineConsent hides it again', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
comp.acceptConsent();
expect(comp.reveal).toBe(true);
expect(comp.consentAccepted).toBe(true);
comp.declineConsent();
expect(comp.reveal).toBe(false);
expect(comp.consentAccepted).toBe(false);
});
it('setCode populates the code the caller later submits', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
comp.setCode('123456');
expect(comp.code).toBe('123456');
});
it('requestNewCode clears the code and toasts the server message on success', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const mint = vi.fn(async () => ({
status: 200,
ok: true,
message: 'A new verification code has been sent.'
}));
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
comp.setCode('654321');
await comp.requestNewCode();
expect(mint).toHaveBeenCalledTimes(1);
expect(comp.code).toBe('');
expect(toastMocks.toast.success).toHaveBeenCalledWith('A new verification code has been sent.');
});
it('requestNewCode toasts the mint-cooldown error on 429', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const mint = vi.fn(async () => ({
status: 429,
ok: false,
message: 'Too many requests. Wait before requesting a new code.'
}));
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
await comp.requestNewCode();
expect(toastMocks.toast.error).toHaveBeenCalledWith(
'Too many requests. Wait before requesting a new code.'
);
});
it('requestNewCode toasts the delivery-unavailable error on 503', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const mint = vi.fn(async () => ({
status: 503,
ok: false,
message: 'Verification codes are unavailable right now. Try again later.'
}));
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
await comp.requestNewCode();
expect(toastMocks.toast.error).toHaveBeenCalledWith(
'Verification codes are unavailable right now. Try again later.'
);
});
it('requestNewCode defaults to the session mint when none is supplied', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
await comp.requestNewCode();
expect(toastMocks.toast.success).toHaveBeenCalled();
});
it('requestNewCode guards against concurrent mints', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const mint = vi.fn(async () => {
await gate;
return { status: 200, ok: true, message: 'slow' };
});
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
const first = comp.requestNewCode();
const second = comp.requestNewCode();
release();
await Promise.all([first, second]);
expect(mint).toHaveBeenCalledTimes(1);
});
});
+90 -39
View File
@@ -123,19 +123,19 @@ const PAYMENT_DEFINITIVE_STATUS = 402;
* This is the DEFENSIVE/unexpected path: customer-initiated saved-card (ccof) * This is the DEFENSIVE/unexpected path: customer-initiated saved-card (ccof)
* surfaces now run the client-side SCA challenge PROACTIVELY before the first * surfaces now run the client-side SCA challenge PROACTIVELY before the first
* charge attempt (tokenizeSavedCardWithVerification) and carry a fresh * charge attempt (tokenizeSavedCardWithVerification) and carry a fresh
* `verification_token` on the charge or have demoted to the 2FA gate when * tokenize-result token as `new_card_token` on the charge or refuse when SCA
* SCA is unavailable so a naked ccof charge should never reach Square. A * is unavailable (C6) so a naked ccof charge should never
* 402 here therefore means the verification token was consumed/expired * reach Square. A 402 here therefore means the SCA tokenize-result token was
* between tokenize and charge (or a config drift), and the buyer should be * consumed/expired between tokenize and charge (or a config drift), and the
* pointed at the retry affordance rather than silently re-challenged. The * buyer should be pointed at the retry affordance rather than silently
* backend sets customer_details.customer_initiated=true on saved-card (ccof) * re-challenged. The backend sets customer_details.customer_initiated=true on
* charges and classifies issuer-verification rejections Square's * saved-card (ccof) charges and classifies issuer-verification rejections
* CARD_DECLINED_VERIFICATION_REQUIRED and friends as definitive 402s, but the * Square's CARD_DECLINED_VERIFICATION_REQUIRED and friends as definitive
* response body is the generic "Payment failed" text with no distinguishing * 402s, but the response body is the generic "Payment failed" text with no
* code. Retrying the same saved card can never succeed, and the buyer must pay * distinguishing code. Retrying the same saved card can never succeed, and the
* with a freshly tokenized card, re-add theirs, or re-run the SCA challenge. * buyer must pay with a freshly tokenized card, re-add theirs, or re-run the
* New-card (cnon) charges carry their own SCA verification token, so they are * SCA challenge. New-card (cnon) charges carry their own SCA verification
* never classified this way. * token, so they are never classified this way.
*/ */
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean { export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS; return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
@@ -143,11 +143,12 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
/** /**
* Machine-readable code the backend returns on a 402 when a saved-card (ccof) * Machine-readable code the backend returns on a 402 when a saved-card (ccof)
* charge requires Strong Customer Authentication and no `verification_token` * charge requires Strong Customer Authentication and no SCA tokenize-result
* was supplied. The saved-card charge path returns a JSON body of the form * token (`new_card_token`) was supplied. The saved-card charge path returns a
* `{"error": "...", "code": "verification_required"}` the shared error-text * JSON body of the form `{"error": "...", "code": "verification_required"}`
* extractor only surfaces the human-readable message, so this checks the raw * the shared error-text extractor only surfaces the human-readable message, so
* body for the code field exactly like isOverflowTipConfirmationRequired does. * this checks the raw body for the code field exactly like
* isOverflowTipConfirmationRequired does.
*/ */
const VERIFICATION_REQUIRED_CODE = 'verification_required'; const VERIFICATION_REQUIRED_CODE = 'verification_required';
@@ -179,18 +180,22 @@ export function isVerificationRequiredSignal(status: number, bodyText: string):
/** /**
* True only when an SCA attempt reported that buyer verification is genuinely * True only when an SCA attempt reported that buyer verification is genuinely
* unavailable (no 3DS challenge could be run), so the surface falls back to the * unavailable (no 3DS challenge could be run). The C6 legal verdict (PSR 2017
* homegrown 2FA gate. Every other outcome verified, a cancelled challenge, or * SCA is non-waivable; the merchant is liable regardless of consent) made the
* a hard SCA failure keeps SCA as the primary path (a cancelled/failed * homegrown 2FA code gate unlawful as an SCA fallback for saved-card charges,
* challenge is retryable, and SCA should be attempted again). * so this now drives the REFUSAL path: the charge cannot complete and the
* customer is told to pay online later never offered the verification-code
* fallback. Every other outcome verified, a cancelled challenge, or a hard
* SCA failure keeps SCA as the primary path (a cancelled/failed challenge is
* retryable, and SCA should be attempted again).
*/ */
export function shouldFallbackTo2FA(scaOutcome: string): boolean { export function shouldShowSCARefusal(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable'; return scaOutcome === 'sca-unavailable';
} }
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to /** Outcome of a saved-card SCA challenge, used by the payment surfaces to
* decide whether to retry with the fresh verification token, surface a * decide whether to retry with the fresh verification token, surface a
* retryable failure, or fall back to the 2FA gate. */ * retryable failure, or refuse the charge (SCA-only posture). */
export type SavedCardVerificationOutcome = export type SavedCardVerificationOutcome =
'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed'; 'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
@@ -220,12 +225,13 @@ export interface SquareTokenizeResult {
* - `status === 'OK'` means buyer verification either completed or was NOT * - `status === 'OK'` means buyer verification either completed or was NOT
* required by the issuer the charge may proceed. The verification-aware * required by the issuer the charge may proceed. The verification-aware
* token (when present) is the `token` field; a tokenless OK means no SCA was * token (when present) is the `token` field; a tokenless OK means no SCA was
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square * demanded, so the charge proceeds token-less and the backend's SCA-only
* risk rules are the fallback), never a dead-end. * verification-required gate is the arbiter, never a silent 2FA fallback.
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was * - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
* shown but not completed the buyer can retry, so this is retryable. * shown but not completed the buyer can retry, so this is retryable.
* - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run SCA * - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run SCA
* is unavailable and the surface falls back to the 2FA gate. * is unavailable and the surface refuses the charge (C6, see
* shouldShowSCARefusal).
* - anything else is a hard SCA failure. * - anything else is a hard SCA failure.
*/ */
export function parseTokenizeVerificationResult( export function parseTokenizeVerificationResult(
@@ -276,7 +282,7 @@ interface SquareVerificationDetails {
* Returns a verification token (retry the SAME charge with it) plus an * Returns a verification token (retry the SAME charge with it) plus an
* outcome the surfaces map to UX: 'verified' retry with the token; * outcome the surfaces map to UX: 'verified' retry with the token;
* 'challenge-cancelled' / 'sca-failed' retryable, keep the pending row; * 'challenge-cancelled' / 'sca-failed' retryable, keep the pending row;
* 'sca-unavailable' no challenge could run, fall back to the 2FA gate. * 'sca-unavailable' no challenge could run, refuse the charge (C6).
*/ */
export async function tokenizeSavedCardWithVerification( export async function tokenizeSavedCardWithVerification(
amount: number, amount: number,
@@ -347,7 +353,7 @@ export async function tokenizeSavedCardWithVerification(
result = await card.tokenize(verificationDetails, squareCardId); result = await card.tokenize(verificationDetails, squareCardId);
} catch (err) { } catch (err) {
// A thrown error (SDK load failure, network) means no challenge could // A thrown error (SDK load failure, network) means no challenge could
// run — SCA is unavailable for this charge, fall back to the 2FA gate. // run — SCA is unavailable for this charge and the surface refuses.
console.error('Saved-card SCA tokenization failed:', err); console.error('Saved-card SCA tokenization failed:', err);
return { verificationToken: null, outcome: 'sca-unavailable' }; return { verificationToken: null, outcome: 'sca-unavailable' };
} }
@@ -357,7 +363,7 @@ export async function tokenizeSavedCardWithVerification(
// in the current SDK — never a nested verificationResult, which only // in the current SDK — never a nested verificationResult, which only
// exists on the deprecated verifyBuyer() flow), tokenless when the issuer // exists on the deprecated verifyBuyer() flow), tokenless when the issuer
// demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable; // demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
// CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback. // CARD_DECLINED_VERIFICATION_REQUIRED → sca-unavailable (C6 refusal).
return parseTokenizeVerificationResult(result); return parseTokenizeVerificationResult(result);
} }
@@ -370,7 +376,7 @@ export interface RunSavedCardSCAOptions {
/** Billing contact passed to Square's verificationDetails (optional). */ /** Billing contact passed to Square's verificationDetails (optional). */
buyer?: SquareVerificationContact; buyer?: SquareVerificationContact;
/** Records the challenge outcome on the calling surface every surface /** Records the challenge outcome on the calling surface every surface
* keeps its own `lastSCAOutcome` state to drive SCA-vs-2FA fallback. */ * keeps its own `lastSCAOutcome` state to drive the refusal path. */
onOutcome: (outcome: SavedCardVerificationOutcome) => void; onOutcome: (outcome: SavedCardVerificationOutcome) => void;
} }
@@ -386,8 +392,9 @@ export interface RunSavedCardSCAOptions {
* everything downstream of the resolved squareCardId. * everything downstream of the resolved squareCardId.
* *
* Returns the outcome plus the verification token ('verified' retry the * Returns the outcome plus the verification token ('verified' retry the
* SAME charge with it). On 'sca-unavailable' the caller falls back to the 2FA * SAME charge with it). On 'sca-unavailable' the caller shows the refusal
* gate; 'challenge-cancelled'/'sca-failed' are retryable without a token. * notice (C6 no 2FA fallback); 'challenge-cancelled'/'sca-failed' are
* retryable without a token.
*/ */
export async function runSavedCardSCAProactively( export async function runSavedCardSCAProactively(
options: RunSavedCardSCAOptions options: RunSavedCardSCAOptions
@@ -416,15 +423,59 @@ export const VERIFICATION_REQUIRED_MESSAGE =
/** User-facing message for a saved-card SCA challenge that was cancelled or did /** User-facing message for a saved-card SCA challenge that was cancelled or did
* not complete. Retryable via SCA deliberately does NOT promise the 2FA code * not complete. Retryable via SCA deliberately does NOT promise the 2FA code
* input, which the customer surfaces only surface on 'sca-unavailable'. */ * input, which the customer surfaces no longer offer (C6 SCA-only posture). */
export const CARD_VERIFICATION_RETRY_MESSAGE = export const CARD_VERIFICATION_RETRY_MESSAGE =
"Card verification was cancelled or didn't complete. Please try again."; "Card verification was cancelled or didn't complete. Please try again.";
/** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the /**
* issuer's SCA challenge genuinely cannot run the 2FA code input is the * User-facing refusal shown when a saved-card charge hits genuine
* only available authorisation and is surfaced as the fallback gate. */ * `sca-unavailable` (the issuer's in-app SCA challenge cannot run). C6 legal
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE = * verdict: the homegrown 2FA code gate cannot legally substitute for SCA (PSR
"In-app approval isn't available for this card — enter the verification code instead."; * 2017 SCA is non-waivable and the merchant stays liable regardless of
* consent), so the customer is told the payment cannot complete and is invited
* to pay online later never offered a verification-code fallback.
*/
export const SCA_REFUSAL_MESSAGE_ONLINE =
"We couldn't complete secure authentication with your bank. To keep your payment protected, it can't be processed right now, so this deposit, payment or purchase did not go through. You can try again later.";
/**
* In-person till variant: the customer is physically at the salon, so the
* natural fallback is to pay online later from home.
*/
export const SCA_REFUSAL_MESSAGE_TILL =
"We couldn't complete secure authentication with your bank. To keep your payment protected, it can't be processed at the till right now, so you can pay online later instead.";
/**
* Version of the SCA-unavailable 2FA-fallback informational notice. Bump when
* the notice's wording or the consent payload changes so a charge can never
* claim consent under an outdated notice. Under the C6 SCA-only posture this
* notice is shown ONLY on the explicit opt-in path (a deployment that enables
* the backend `TWO_FACTOR_FALLBACK`); the shipped surfaces refuse instead and
* never reach it. The notice is an INFORMATION notice (approved as such by
* legal review) it tells the customer the fallback is less secure than
* their bank's authentication and that their refund/chargeback rights are
* unaffected; it is NOT a liability waiver and must never be framed as one.
*/
export const SCA_FALLBACK_CONSENT_VERSION = 'v1';
/**
* Consent payload carried on a 2FA-fallback charge once the customer accepted
* the SCA-unavailable notice the EXPLICIT OPT-IN path only (a deployment
* that enables the backend `TWO_FACTOR_FALLBACK`). The frontend cannot detect
* the deployment posture (no build-time env, no /api/config endpoint), so the
* shipped charge surfaces default to the C6 refusal and never produce
* consent_accepted on their own: they call this with `false`, which returns
* `{}` and sends no consent fields. The backend ignores unknown fields until
* it adds audit capture, so sending these is forward-compatible.
* `consent_accepted` is true exactly when the customer chose to continue with
* the verification code (never when they chose "Cancel and pay later" no
* charge is sent then).
*/
export function scaFallbackConsentFields(consentAccepted: boolean): Record<string, unknown> {
return consentAccepted
? { consent_version: SCA_FALLBACK_CONSENT_VERSION, consent_accepted: true }
: {};
}
/** User-facing guidance for a saved-card charge the issuer requires /** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the * verification to complete. Retrying the same saved card is pointless the
+17 -2
View File
@@ -208,14 +208,29 @@ class AuthStore {
} }
}); });
if (response.status === 401 || response.status === 403) {
// The server definitively rejected the token (invalid signature,
// revoked family, expired) — the session is dead, clear it.
this.clearAuth();
return;
}
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch profile'); // Transient server error — keep the session; a later call (or the
// next page load's initializeAuth) will retry. Clearing auth here
// would log the user out on a hiccup.
return;
} }
const userData = await response.json(); const userData = await response.json();
this.user = userData; this.user = userData;
} catch { } catch {
this.clearAuth(); // Network error OR the fetch was aborted by a page navigation
// (window.location redirect right after setToken on the login page).
// The token is still valid and persisted — do NOT clear it. This was
// the login bug: the navigation-aborted profile fetch called
// clearAuth() and wiped the freshly-stored token before the
// destination page's initializeAuth could read it.
} }
} }
+49 -21
View File
@@ -5,14 +5,16 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
/** /**
* Shared 2FA verification-code state for the saved-card charge surfaces. * Shared 2FA verification-code state for the saved-card charge surfaces.
* *
* B6/B10: the backend's requireTwoFactorForCardAccess gate requires the CARD * C6 (PSR 2017): the homegrown 2FA code gate can no longer substitute for SCA
* OWNER's current one-time verification code on every saved-card charge in an * on saved-card charges the merchant stays liable regardless of consent so
* enforced environment. This composable owns the whole verification-code UX * the charge surfaces REFUSE on genuine `sca-unavailable` instead of falling
* the code itself, the reveal flag (a charge that 403s for a missing code * back to a code. This composable therefore owns the verification-code UX for
* reveals the input even when the session profile's 2FA flag is stale), the * the remaining legitimate uses ONLY: the defensive 403 self-heal (an opt-in
* show/missing derivations and the "Request a new code" handler so the six * deployment whose backend actually allows the token-less 2FA-gated charge
* payment surfaces (booking modal, tip, account gift-card, booking-flow * the code input surfaces when the backend asks for it, never preemptively)
* deposit, admin till and admin payment modal) can't drift on any of them. * and any account/admin verification flow that reuses the code state. The
* `consentAccepted`/`acceptConsent`/`declineConsent` machinery is kept for the
* explicit opt-in path (TWO_FACTOR_FALLBACK deployments) and never auto-triggers.
* *
* Each surface supplies its own predicates: * Each surface supplies its own predicates:
* - `enabled()` whether the session user's own 2FA is active (customer * - `enabled()` whether the session user's own 2FA is active (customer
@@ -20,17 +22,15 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
* Admin surfaces (till, admin payment modal) return true: * Admin surfaces (till, admin payment modal) return true:
* the operator always supplies the CUSTOMER's code, so the * the operator always supplies the CUSTOMER's code, so the
* session user's own flag is irrelevant to the gate. * session user's own flag is irrelevant to the gate.
* - `gateActive()` whether the pending charge hits the 2FA gate: a saved * - `gateActive()` whether the pending charge would hit the backend's 2FA
* card is selected, or a new card is being saved for reuse. * gate. The surface passes its exact gate expression so each
* The surface passes its exact gate expression so each
* surface's gate semantics are preserved verbatim. * surface's gate semantics are preserved verbatim.
* - `scaAvailable()` whether Square Strong Customer Authentication is the * - `scaAvailable()` whether Square Strong Customer Authentication is the
* active authorisation for this charge. Defaults to true * active authorisation for this charge. Defaults to true
* (SCA primary). When the last SCA attempt reported the * (SCA primary). Charge surfaces pass `() => true` under the
* challenge is genuinely unavailable ('sca-unavailable'), * SCA-only posture: SCA is ALWAYS the authorisation, so the
* the surface passes `() => !shouldFallbackTo2FA(...)` so * code input never demotes in from the SCA-unavailable path
* the code input surfaces as the 2FA-BACKUP path it shows * and only ever surfaces via the explicit `reveal` self-heal.
* without a 403 self-heal because SCA can't authorise.
* - `mint()` optional; the code-request call. Customer surfaces omit * - `mint()` optional; the code-request call. Customer surfaces omit
* it (defaults to the session-scoped /api/user/2fa/code: * it (defaults to the session-scoped /api/user/2fa/code:
* session user == card owner). Admin surfaces MUST pass * session user == card owner). Admin surfaces MUST pass
@@ -55,13 +55,36 @@ export function useTwoFactorCodeForSavedCard(options: {
// Code-request state for the "Request a new code" button. // Code-request state for the "Request a new code" button.
let requesting = $state(false); let requesting = $state(false);
// Show the code input whenever the pending charge hits the backend's 2FA // C6: the SCA-unavailable fallback is REFUSED, so the code input never
// gate AND SCA isn't available to authorise instead (2FA is the BACKUP, not // demotes in from an SCA outcome (charge surfaces pass `scaAvailable: () =>
// the default), OR a failure has revealed it explicitly. // true`). The `consentAccepted` gate below is kept for the explicit opt-in
// path only; the input otherwise surfaces solely via the explicit `reveal`
// self-heal (a backend 2FA-gate rejection on a token-less charge in a
// deployment that actually allows it).
let consentAccepted = $state(false);
// Show the code input when a charge failure has revealed it explicitly
// (403/429/400 gate rejection), OR on the explicit opt-in path — a
// deployment that allows the token-less 2FA-gated charge after the customer
// accepted the SCA-unavailable notice (consentAccepted). Charge surfaces
// pass `scaAvailable: () => true`, so the SCA-outcome demotion branch can
// never surface the input on its own.
const scaAvailable = options.scaAvailable ?? (() => true); const scaAvailable = options.scaAvailable ?? (() => true);
const showInput = $derived(reveal || (options.gateActive() && !scaAvailable())); const showInput = $derived(
reveal || (consentAccepted && options.gateActive() && !scaAvailable())
);
const missing = $derived(showInput && options.enabled() && code.trim() === ''); const missing = $derived(showInput && options.enabled() && code.trim() === '');
function acceptConsent() {
consentAccepted = true;
reveal = true;
}
function declineConsent() {
consentAccepted = false;
reveal = false;
}
async function requestNewCode() { async function requestNewCode() {
if (requesting) return; if (requesting) return;
requesting = true; requesting = true;
@@ -112,6 +135,11 @@ export function useTwoFactorCodeForSavedCard(options: {
get requesting() { get requesting() {
return requesting; return requesting;
}, },
requestNewCode requestNewCode,
get consentAccepted() {
return consentAccepted;
},
acceptConsent,
declineConsent
}; };
} }
+17 -1
View File
@@ -8,6 +8,7 @@
import { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js'; import { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { resolve } from '$app/paths';
const { children } = $props(); const { children } = $props();
@@ -93,7 +94,22 @@
{#if !hideFooter} {#if !hideFooter}
<footer class="border-t py-3 text-center text-xs text-gray-500 md:py-4 md:text-sm"> <footer class="border-t py-3 text-center text-xs text-gray-500 md:py-4 md:text-sm">
&copy; {new Date().getFullYear()} Crussell Nails. All rights reserved. <div class="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-x-4 gap-y-1">
<a href={resolve('/privacy-policy')} class="hover:text-gray-700 hover:underline"
>Privacy Policy</a
>
<a href={resolve('/terms')} class="hover:text-gray-700 hover:underline"
>Terms &amp; Conditions</a
>
<a href={resolve('/cancellation-policy')} class="hover:text-gray-700 hover:underline"
>Booking, Deposit &amp; Cancellation Policy</a
>
<a href={resolve('/gift-card-terms')} class="hover:text-gray-700 hover:underline"
>Gift Card Terms</a
>
<a href={resolve('/gdpr')} class="hover:text-gray-700 hover:underline">Your Data</a>
</div>
<div class="mt-1">&copy; {new Date().getFullYear()} Crussell Nails. All rights reserved.</div>
</footer> </footer>
{/if} {/if}
</div> </div>
+78 -19
View File
@@ -9,6 +9,7 @@
import CardSelection from '$lib/components/payments/CardSelection.svelte'; import CardSelection from '$lib/components/payments/CardSelection.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte'; import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import { import {
CARD_VERIFICATION_RETRY_MESSAGE, CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole, canSaveCardsForRole,
@@ -17,7 +18,8 @@
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal, isVerificationRequiredSignal,
runSavedCardSCAProactively, runSavedCardSCAProactively,
shouldFallbackTo2FA, scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry, submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square'; } from '$lib/square/square';
@@ -159,7 +161,16 @@
if (!addCardSquareCardInput) return; if (!addCardSquareCardInput) return;
let token: string; let token: string;
try { try {
token = await addCardSquareCardInput.tokenize(); // M11: SCA is performed at tokenization (Square's save-only STORE
// intent), so the stored token is the SCA-verified source and the
// backend's 2FA gate can skip this save — the frontend never
// surfaces the 2FA code input here.
const stored = await addCardSquareCardInput.tokenizeForStore({
givenName: userData?.firstName,
familyName: userData?.lastName,
email: userData?.email
});
token = stored.token;
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed'); toast.error(err instanceof Error ? err.message : 'Card entry failed');
return; return;
@@ -175,8 +186,21 @@
toast.success('Card saved'); toast.success('Card saved');
await savedCardsStore.invalidate(); await savedCardsStore.invalidate();
} else { } else {
// Capture the status BEFORE consuming the body — the SCA
// classification below needs it, and text() can only be read once.
const status = res.status;
const errText = await res.text(); const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to add card'); // M12: a 402 carrying the structured verification-required signal
// (or the dev/mock text parity) surfaces the SCA-first guidance —
// the STORE-intent save's SCA already ran at tokenization, so a
// refusal must tell the customer to approve with their issuer, not
// surface as a generic "Failed to add card". A plain decline stays
// a normal error.
const verificationRequired = isVerificationRequiredSignal(status, errText);
const addCardErrMsg = verificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: extractErrorMessage(errText) || 'Failed to add card';
toast.error(addCardErrMsg);
} }
} catch { } catch {
toast.error('Network error'); toast.error('Network error');
@@ -260,9 +284,9 @@
// handler) — see $lib/stores/twoFactorCode.svelte.ts. // handler) — see $lib/stores/twoFactorCode.svelte.ts.
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// from backup to the only available gate (scaAvailable → false); every other // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// outcome keeps SCA primary for the next retry. // fallback); every other outcome keeps SCA primary for the next retry.
let buyLastSCAOutcome = $state(''); let buyLastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer // True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel. // approves in their banking app) — drives the "approve in banking app" panel.
@@ -273,7 +297,9 @@
const buyTwoFactor = useTwoFactorCodeForSavedCard({ const buyTwoFactor = useTwoFactorCodeForSavedCard({
enabled: () => buyTwoFactorEnabled, enabled: () => buyTwoFactorEnabled,
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard), gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
scaAvailable: () => !shouldFallbackTo2FA(buyLastSCAOutcome) // 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
}); });
// Client-side mirror of the £500/day online purchase cap. The backend is // Client-side mirror of the £500/day online purchase cap. The backend is
@@ -525,6 +551,17 @@
toast.error(buyError); toast.error(buyError);
return; return;
} }
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Abort this attempt
// BEFORE any charge is submitted and surface the refusal
// notice — there is NO 2FA fallback; the gift card is
// bought online later.
buyTwoFactor.declineConsent();
buyTwoFactor.reveal = false;
buyingGiftCard = false;
buyError = null;
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken; if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally { } finally {
buyWaitingForSCA = false; buyWaitingForSCA = false;
@@ -542,8 +579,14 @@
recipient_email: buyRecipientEmail, recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}), ...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}), // C1: the SCA tokenize-result token for a saved card
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}), // is the charge SOURCE (new_card_token) alongside
// the card ref — never the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(buyTwoFactor.showInput && !verificationToken
? { verification_code: buyTwoFactor.code }
: {}),
...scaFallbackConsentFields(buyTwoFactor.consentAccepted),
idempotency_key: buyIdempotencyKey idempotency_key: buyIdempotencyKey
}) })
}), }),
@@ -590,6 +633,13 @@
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) { if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
buyTwoFactor.reveal = true; buyTwoFactor.reveal = true;
} }
if (verificationRequired) {
// 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 SCA guidance is shown instead of looping on 2FA.
buyTwoFactor.declineConsent();
}
buyError = buyErrMsg; buyError = buyErrMsg;
toast.error(buyErrMsg); toast.error(buyErrMsg);
// A definitive charge failure (e.g. declined card) consumes the // A definitive charge failure (e.g. declined card) consumes the
@@ -2364,7 +2414,7 @@
onReady={(r) => (addCardReady = r)} onReady={(r) => (addCardReady = r)}
/> />
<Button <Button
class="mt-3 w-full" class="mt-3 min-h-11 w-full"
onclick={addCard} onclick={addCard}
disabled={addingCard || !addCardReady} disabled={addingCard || !addCardReady}
loading={addingCard} loading={addingCard}
@@ -2654,6 +2704,16 @@
bind:saveCard={buySaveCard} bind:saveCard={buySaveCard}
onValidityChange={(v) => (buyCardSelectionValid = v)} onValidityChange={(v) => (buyCardSelectionValid = v)}
/> />
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
user must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(buyLastSCAOutcome)}
onOk={() => {
buyLastSCAOutcome = '';
buyError = null;
}}
/>
<!-- B6/B10: saved-card gift-card charges require the card owner's <!-- B6/B10: saved-card gift-card charges require the card owner's
current 2FA verification code when the backend enforces the gate. --> current 2FA verification code when the backend enforces the gate. -->
<TwoFactorCodeInput <TwoFactorCodeInput
@@ -2664,8 +2724,7 @@
{#if buyTwoFactor.showInput && buyTwoFactorEnabled} {#if buyTwoFactor.showInput && buyTwoFactorEnabled}
<Button <Button
variant="outline" variant="outline"
size="sm" class="min-h-11 w-full"
class="w-full"
loading={buyTwoFactor.requesting} loading={buyTwoFactor.requesting}
disabled={buyTwoFactor.requesting} disabled={buyTwoFactor.requesting}
onclick={buyTwoFactor.requestNewCode} onclick={buyTwoFactor.requestNewCode}
@@ -2712,7 +2771,7 @@
!isBuyCardValid || !isBuyCardValid ||
buyTwoFactor.missing || buyTwoFactor.missing ||
buyDailyTotal + buyAmount > dailyGiftCardBuyLimit} buyDailyTotal + buyAmount > dailyGiftCardBuyLimit}
class="mt-2 w-full" class="mt-2 min-h-11 w-full"
> >
{buyingGiftCard {buyingGiftCard
? 'Processing Payment...' ? 'Processing Payment...'
@@ -2992,10 +3051,10 @@
<!-- Two-Factor Authentication (visible to all roles; the notification <!-- Two-Factor Authentication (visible to all roles; the notification
preferences above are the role-gated part of this area) --> preferences above are the role-gated part of this area) -->
<div> <div id="two-factor-settings">
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3> <h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
<p class="mb-3 text-sm text-gray-600"> <p class="mb-3 text-sm text-gray-600">
Protect online card payments with a one-time verification code Protect sensitive account actions with a one-time verification code
</p> </p>
{#if authStore.currentUser?.twoFactorEnabled} {#if authStore.currentUser?.twoFactorEnabled}
@@ -3007,14 +3066,14 @@
{/if} {/if}
</div> </div>
<div class="mt-1 text-xs text-gray-500"> <div class="mt-1 text-xs text-gray-500">
A verification code is required for online card payments A verification code is required for sensitive account actions
</div> </div>
</div> </div>
{:else if authStore.currentUser?.twoFactorRequired} {:else if authStore.currentUser?.twoFactorRequired}
<div <div
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800" 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. Two-factor authentication adds an extra verification step to your account.
</div> </div>
{/if} {/if}
@@ -3491,8 +3550,8 @@
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title> <AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
Disabling two-factor authentication means online card payments will be blocked while 2FA Disabling two-factor authentication removes the extra verification step from your account.
is required. You can re-enable it at any time. You can re-enable it at any time.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
@@ -100,6 +100,13 @@
If the slot has not yet been claimed by another client, paying your outstanding deposit will If the slot has not yet been claimed by another client, paying your outstanding deposit will
instantly restore your booking to a fully confirmed status. instantly restore your booking to a fully confirmed status.
</p> </p>
<p class="mb-3">
<strong>If your booking is evicted, you are refunded in full.</strong> If another customer books
the overlapping time and pays while your booking is in "Pending Release", your booking is evicted
and every payment you made toward it is refunded to you (the slot was lost through no fault of
yours, so no cancellation fee applies). This refund follows the same refund-method rules in Section
3 below.
</p>
</section> </section>
<!-- Section 3 --> <!-- Section 3 -->
@@ -123,19 +130,23 @@
</div> </div>
<div class="p-4"> <div class="p-4">
<p class="font-semibold text-gray-900">Notice between 24 and 72 hours</p> <p class="font-semibold text-gray-900">
Notice between 24 and 72 hours (including exactly 24 or 72 hours)
</p>
<p class="mt-1 text-xs text-gray-600"> <p class="mt-1 text-xs text-gray-600">
Any booking payments made up to 50% of the total booking value are treated as a Any booking payments made up to 50% of the total booking value are treated as a
Protected Deposit. This Protected Deposit is retained to cover the short-notice vacancy, Protected Deposit. This Protected Deposit is retained to cover the short-notice vacancy,
while any balance paid above 50% will be fully refunded. while any balance paid above 50% will be fully refunded. The protected deposit is capped
at 50% of the booking total AND at what you actually paid &mdash; so if you only paid a
20% deposit, no more than that is ever retained.
</p> </p>
</div> </div>
<div class="bg-gray-50/50 p-4"> <div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p> <p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
<p class="mt-1 text-xs text-gray-600"> <p class="mt-1 text-xs text-gray-600">
All booking payments and deposits are entirely non-refundable and will be retained. The All booking payments and deposits are retained (no refund). The cancellation will be
cancellation will be logged as a missed appointment history strike. logged as a missed appointment history strike.
</p> </p>
</div> </div>
</div> </div>
@@ -224,10 +235,9 @@
</p> </p>
<p class="mb-3"> <p class="mb-3">
Each completed booking with a payment reduces the required deposit count by one. Once the Each completed booking with a payment reduces the required deposit count by one. Once the
count reaches zero, all prior no-show records within the 6-month window are forgiven and count reaches zero, your account returns to normal — no upfront deposits required — until a
your account returns to normal — no upfront deposits required — until a new no-show occurs. new no-show occurs. The salon can also forgive individual no-shows at management's
The salon can also forgive individual no-shows at management's discretion, which immediately discretion, which immediately removes them from the count.
removes them from the count.
</p> </p>
</section> </section>
@@ -257,8 +267,12 @@
</p> </p>
<p class="mb-3"> <p class="mb-3">
Where a partly-used card is cancelled, the card is cancelled automatically when the refund Where a partly-used card is cancelled, the card is cancelled automatically when the refund
is issued, so the remaining balance cannot then be spent. See our Gift Card Terms for the is issued, so the remaining balance cannot then be spent. See our
full position. <a
href={resolve('/gift-card-terms')}
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
>
for the full position.
</p> </p>
<p class="mb-3"> <p class="mb-3">
If you believe your statutory consumer rights have not been met, you can get free, impartial If you believe your statutory consumer rights have not been met, you can get free, impartial
+6 -41
View File
@@ -159,12 +159,9 @@
status: string; status: string;
created_at: string; created_at: string;
}>; }>;
verification_codes?: Array<{ // verification codes are excluded from the export (they are authentication
purpose: string; // tokens, not personal data), so there is deliberately no verification_codes
created_at: string; // key and no card for it below.
used_at?: string;
expires_at: string;
}>;
forgiven_no_shows?: Array<{ forgiven_no_shows?: Array<{
id: string; id: string;
booking_id: string; booking_id: string;
@@ -1412,41 +1409,9 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<!-- Verification Codes --> <!-- Verification codes are not exported (authentication tokens are excluded
{#if gdprData.verification_codes && gdprData.verification_codes.length > 0} from the GDPR export), so there is deliberately no Verification Codes
<Card.Root class="mb-4"> card here; it could never populate. -->
<Card.Header><Card.Title>Verification Codes</Card.Title></Card.Header>
<Card.Content class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead
><tr class="border-b"
><th class="px-4 py-3 font-medium text-gray-500">Purpose</th><th
class="px-4 py-3 font-medium text-gray-500">Created</th
><th class="px-4 py-3 font-medium text-gray-500">Used At</th><th
class="px-4 py-3 font-medium text-gray-500">Expires</th
></tr
></thead
>
<tbody>
{#each gdprData.verification_codes as vc (vc.purpose + vc.created_at)}
<tr class="border-b last:border-b-0">
<td class="px-4 py-3 font-medium">{vc.purpose.replace(/_/g, ' ')}</td>
<td class="px-4 py-3 whitespace-nowrap">{fmtDate(vc.created_at)}</td>
<td class="px-4 py-3 whitespace-nowrap text-gray-500"
>{vc.used_at ? fmtDate(vc.used_at) : '—'}</td
>
<td class="px-4 py-3 whitespace-nowrap text-gray-500"
>{fmtDate(vc.expires_at)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Forgiven No-Shows --> <!-- Forgiven No-Shows -->
{#if gdprData.forgiven_no_shows && gdprData.forgiven_no_shows.length > 0} {#if gdprData.forgiven_no_shows && gdprData.forgiven_no_shows.length > 0}
@@ -43,11 +43,6 @@
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900"> <div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
<div class="mb-2 flex items-center gap-3"> <div class="mb-2 flex items-center gap-3">
<h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Privacy Policy</h1> <h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Privacy Policy</h1>
<span
class="no-print shrink-0 rounded-full border border-amber-300 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-800"
>
DRAFT &mdash; for review
</span>
</div> </div>
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: August 2026</p> <p class="mb-8 font-mono text-xs text-gray-500">Last updated: August 2026</p>
@@ -77,6 +72,13 @@
<p>Edinburgh, Scotland</p> <p>Edinburgh, Scotland</p>
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. --> <!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. -->
<p>Email: {'{{SUPPORT_EMAIL}}'}</p> <p>Email: {'{{SUPPORT_EMAIL}}'}</p>
<p class="mt-2 font-semibold text-gray-900">ICO registration (operator responsibility)</p>
<p class="mt-1">
As a data controller, the salon must register with the Information Commissioner's Office
(ICO) and pay the data-protection fee unless an exemption applies. This is the operator's
responsibility &mdash; nothing in the Platform registers the business. See the ICO website
(ico.org.uk) for the fee and exemptions.
</p>
</div> </div>
</section> </section>
@@ -84,6 +86,33 @@
<section> <section>
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2> <h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">Lawful Bases at a Glance</h3>
<p class="mb-3">
We only process personal data where UK GDPR gives us a lawful basis. The main bases we rely
on:
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>Contract performance (Article 6(1)(b))</strong> &mdash; holding and managing your booking,
taking and refunding payments, and saving your card at your request.
</li>
<li>
<strong>Legal obligation (Article 6(1)(c))</strong> &mdash; keeping financial and tax records
for 7 years (HMRC), and holding health-and-safety records for insurance.
</li>
<li>
<strong>Legitimate interest (Article 6(1)(f))</strong> &mdash; running the salon safely and
efficiently, preventing fraud (including rate limiting and account lockouts), and defending
against claims. We weigh these interests against your rights before relying on them.
</li>
<li>
<strong>Consent (Article 6(1)(a))</strong> &mdash; marketing preferences and explicit consent
for health data (Article 9(2)(a)) only. Card payment authorisation is not consent-based: the
SCA check on saved-card payments is carried out under contract performance and our legitimate
interest in fraud prevention (see &sect;2.4).
</li>
</ul>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800"> <h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.1 Personal Data (Identifiable Information) 2.1 Personal Data (Identifiable Information)
</h3> </h3>
@@ -204,6 +233,43 @@
to you if your account is deleted, and allergy/access information held in your treatment notes to you if your account is deleted, and allergy/access information held in your treatment notes
is retained de-identified (see &sect;3.1). is retained de-identified (see &sect;3.1).
</p> </p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.4 Secure Card Authorisation (SCA Only)
</h3>
<p class="mb-3">
Online card payments, including saved-card payments, are authorised exclusively through your
bank&rsquo;s in-app approval step (Strong Customer Authentication, SCA / 3-D Secure),
carried out by Square PSD2 SCA. When you pay online, your bank may ask you to approve the
payment in your banking app. No saved-card payment is taken without this bank-level
authentication.
</p>
<p class="mb-3">
If your bank cannot complete the SCA step, the payment cannot be processed and is refused.
For online payments, this means the relevant deposit, early payment or gift-card purchase
does not go through. If you are paying in person at the salon and your bank cannot complete
SCA, we may ask you to pay online later instead.
</p>
<p class="mb-3">
<strong>Lawful basis:</strong> the SCA check is carried out under contract performance (Article
6(1)(b)) and our legitimate interest in fraud prevention (Article 6(1)(f)). No consent-based processing
is used for card authorisation.
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.5 Request Snapshots (Payment Replay Records)
</h3>
<p class="mb-3">
To rescue a payment that is stuck in a pending state, the Platform stores the exact payment
request for replay. In sandbox/production deployments these snapshots are encrypted at rest
(AES-256-GCM) under a deployment-provided key (<code>SNAPSHOT_ENC_KEY</code>).
</p>
<p class="mb-4">
<strong>Deployment requirement:</strong> if the key is not set, snapshots are stored in
plaintext at rest (a startup warning is logged). The operator must set
<code>SNAPSHOT_ENC_KEY</code> before go-live so buyer email and card-token data in these records
is encrypted.
</p>
</section> </section>
<!-- Section 3 --> <!-- Section 3 -->
@@ -255,6 +321,27 @@
<td class="px-3 py-2">7 years</td> <td class="px-3 py-2">7 years</td>
<td class="px-3 py-2">Insurance requirement</td> <td class="px-3 py-2">Insurance requirement</td>
</tr> </tr>
<tr>
<td class="px-3 py-2">Guest booking PII</td>
<td class="px-3 py-2">6 months after the appointment, then anonymized</td>
<td class="px-3 py-2">GDPR storage limitation</td>
</tr>
<tr>
<td class="px-3 py-2">Gift cards</td>
<td class="px-3 py-2">
24-month rolling expiry (from last use) for the card; account balances do not expire
</td>
<td class="px-3 py-2">Consumer-protection fairness; rolling-expiry terms</td>
</tr>
<tr>
<td class="px-3 py-2">Card-authorisation records</td>
<td class="px-3 py-2"
>None stored. Card payments are authorised by your bank's secure-authentication step
(see &sect;2.4); we do not record any separate consent or authorisation records for
a card payment.</td
>
<td class="px-3 py-2">N/A</td>
</tr>
<tr> <tr>
<td class="px-3 py-2" <td class="px-3 py-2"
>Treatment &amp; safety notes (incl. allergy/access information)</td >Treatment &amp; safety notes (incl. allergy/access information)</td
@@ -338,7 +425,10 @@
</ul> </ul>
<p class="mb-4"> <p class="mb-4">
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
to the Information Commissioner&rsquo;s Office (ICO) at any time. to the Information Commissioner&rsquo;s Office (ICO) at any time &mdash; via the ICO website
(ico.org.uk) or by writing to the ICO, Wycliffe House, Water Lane, Wilmslow, Cheshire SK9
5AF. If you have concerns, we would ask you to contact us first so we can try to resolve
them.
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Questions about how we handle your data? Please use our official Questions about how we handle your data? Please use our official
+106 -10
View File
@@ -43,13 +43,8 @@
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900"> <div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
<div class="mb-2 flex items-center gap-3"> <div class="mb-2 flex items-center gap-3">
<h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Terms &amp; Conditions</h1> <h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Terms &amp; Conditions</h1>
<span
class="no-print shrink-0 rounded-full border border-amber-300 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-800"
>
DRAFT &mdash; for review
</span>
</div> </div>
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: June 2026</p> <p class="mb-8 font-mono text-xs text-gray-500">Last updated: August 2026</p>
{#if format === 'pdf' && pdfNotice} {#if format === 'pdf' && pdfNotice}
<p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic"> <p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic">
@@ -194,6 +189,20 @@
<ul class="mb-3 list-disc space-y-1 pl-5"> <ul class="mb-3 list-disc space-y-1 pl-5">
<li>Card payments are processed securely via Square.</li> <li>Card payments are processed securely via Square.</li>
<li>We do not store full card details.</li> <li>We do not store full card details.</li>
<li>
<strong>Strong Customer Authentication (SCA / 3-D Secure):</strong> online card payments, including
saved-card payments, are authenticated by Square PSD2 SCA. Your bank may ask you to approve
the payment in your banking app. If your bank cannot complete the secure authentication, the
payment cannot be processed: for online payments the relevant deposit, early payment or gift-card
purchase does not go through. If you are paying in person at the salon and your bank cannot
complete SCA, we may ask you to pay online later instead.
</li>
<li>
<strong>Chargebacks:</strong> if you dispute a payment with your card issuer, we will respond
with the booking and payment records we hold and, where the payment was SCA-authenticated, the
authentication evidence. We comply with the card-scheme dispute process and may be required
to refund a disputed payment if the scheme decides against us.
</li>
<li> <li>
<strong>Refunds are returned to the original payment method where possible:</strong> <strong>Refunds are returned to the original payment method where possible:</strong>
<ul class="mt-1 list-disc space-y-1 pl-5"> <ul class="mt-1 list-disc space-y-1 pl-5">
@@ -270,12 +279,99 @@
balance, no additional VAT is charged (it has already been paid). balance, no additional VAT is charged (it has already been paid).
</p> </p>
<p class="mb-3"> <p class="mb-3">
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase within <strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase
14 days for a refund to the original payment method. If the card has been partly used on salon within 14 days for a refund to the original payment method. If the card has been partly used
services, only the unspent balance is refunded and the card is then cancelled. A card that has on salon services, only the unspent balance is refunded and the card is then cancelled. A
been redeemed to an account balance or fully spent cannot be cancelled. See our Gift Card Terms card that has been redeemed to an account balance or fully spent cannot be cancelled. See
our
<a
href={resolve('/gift-card-terms')}
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
>
for the full position. for the full position.
</p> </p>
<p class="mb-3">
<strong>Non-refundable except where the law requires:</strong> except for the 14-day right
to cancel online purchases above, and except as required by consumer law or by an express
refund we offer under our cancellation policy, gift cards and gift-card balances are
<strong>non-refundable</strong> &mdash; they are not redeemable for cash and cannot be exchanged
for money. Where consumer law gives you a right of refund, that right is unaffected.
</p>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">6. Liability</h2>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
Nothing in these Terms excludes or limits any rights you have under consumer law &mdash;
including your statutory rights under the Consumer Rights Act 2015 that services are
provided with reasonable care and skill and match their description.
</li>
<li>
We are not liable for losses that were not a foreseeable consequence of a breach of these
Terms, or for losses caused by events outside our reasonable control.
</li>
<li>
To the fullest extent permitted by law, our total liability arising out of or in
connection with these Terms is limited to the total amount you have paid us for the
service concerned.
</li>
<li>
We are not liable for the acts or omissions of third parties we rely on to provide the
Platform (for example our payment processor), except as required by law.
</li>
</ul>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">7. Acceptable Use</h2>
<p class="mb-3">By using the Platform you agree not to:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
Use the Platform for any unlawful purpose, or in a way that infringes anyone else&rsquo;s
rights.
</li>
<li>
Attempt to access another user&rsquo;s account, or to interfere with the operation of the
Platform.
</li>
<li>
Misuse the booking system (for example by repeatedly reserving slots with no intention of
completing a booking, or by booking under false details).
</li>
<li>Use the Platform to send spam, offensive content, or content that misleads others.</li>
</ul>
<p class="mb-3">
We may suspend or refuse service where we reasonably believe these rules are being breached,
while respecting your legal rights.
</p>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
8. Complaints &amp; Dispute Resolution
</h2>
<p class="mb-3">
If you are unhappy with any part of our service, please contact us first at
{'{{SUPPORT_EMAIL}}'} &mdash; we will do our best to resolve your complaint fairly. You can get
free, impartial consumer advice from
<a
href="https://consumeradvice.scot"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800">consumeradvice.scot</a
>, and you can escalate a complaint to your local Trading Standards office. Claims up to
&pound;5,000 can be pursued through the Scottish courts&rsquo; Simple Procedure.
</p>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">9. Governing Law</h2>
<p class="mb-3">
These Terms are governed by the laws of <strong>Scotland</strong>. Any dispute arising out
of or in connection with these Terms is subject to the exclusive jurisdiction of the
Scottish courts. Nothing in this clause limits your statutory rights as a consumer.
</p>
</section> </section>
<section class="border-t border-gray-200 pt-6"> <section class="border-t border-gray-200 pt-6">