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:
@@ -969,7 +969,11 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<!-- 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>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -16,16 +16,20 @@
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
shouldFallbackTo2FA,
|
||||
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
|
||||
scaFallbackConsentFields,
|
||||
SCA_REFUSAL_MESSAGE_TILL,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
tokenizeSavedCardWithVerification,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode,
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -34,7 +38,8 @@
|
||||
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 }> = [
|
||||
{ key: 'cash', label: 'Cash' },
|
||||
@@ -141,9 +146,9 @@
|
||||
// irrelevant to the backend gate, so `enabled` is always true.
|
||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||
let customerTwoFactorEnabled = $state(false);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
|
||||
// from backup to the only available gate (scaAvailable → false); every other
|
||||
// outcome keeps SCA primary for the next retry.
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
let lastSCAOutcome = $state('');
|
||||
// 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.
|
||||
@@ -152,7 +157,9 @@
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
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: () =>
|
||||
selectedCustomer?.id
|
||||
? adminRequestNewTwoFactorCode(selectedCustomer.id)
|
||||
@@ -348,7 +355,10 @@
|
||||
);
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -360,37 +370,84 @@
|
||||
// One sale per cart line × quantity — each till sale funds its own
|
||||
// gift card (the backend only accepts item_type 'gift_card').
|
||||
const saleBodies: Record<string, unknown>[] = [];
|
||||
for (const item of cart) {
|
||||
for (let i = 0; i < item.qty; i++) {
|
||||
const body: Record<string, unknown> = {
|
||||
item_type: 'gift_card',
|
||||
action: 'create',
|
||||
amount: item.price,
|
||||
payment_method: paymentMethod,
|
||||
idempotency_key: idempotencyKeyFor(item, i)
|
||||
};
|
||||
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
|
||||
body.user_id = selectedCustomer?.id;
|
||||
body.user_saved_card_id = selectedSavedCardId;
|
||||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||||
// verification code when the gate is enforced.
|
||||
if (twoFactor.showInput) body.verification_code = twoFactor.code;
|
||||
} 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;
|
||||
// M10: proactive saved-card (ccof) SCA. Run the client-side challenge
|
||||
// for every sale line BEFORE the first charge so a naked ccof till
|
||||
// charge is never sent to the backend (mirrors PaymentModal/UserPaymentModal
|
||||
// running SCA at charge init). Each line binds its token to its own
|
||||
// amount. 'challenge-cancelled'/'sca-failed' abort the whole sale
|
||||
// (retryable); 'sca-unavailable' aborts before any charge and surfaces
|
||||
// the C6 refusal notice (no 2FA fallback).
|
||||
let scaAborted = false;
|
||||
awaitingSCA = true;
|
||||
try {
|
||||
for (const item of cart) {
|
||||
for (let i = 0; i < item.qty; i++) {
|
||||
const body: Record<string, unknown> = {
|
||||
item_type: 'gift_card',
|
||||
action: 'create',
|
||||
amount: item.price,
|
||||
payment_method: paymentMethod,
|
||||
idempotency_key: idempotencyKeyFor(item, i)
|
||||
};
|
||||
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
|
||||
body.user_id = selectedCustomer?.id;
|
||||
body.user_saved_card_id = selectedSavedCardId;
|
||||
const squareCardId = savedCards.find(
|
||||
(c) => c.id === selectedSavedCardId
|
||||
)?.square_card_id;
|
||||
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) {
|
||||
const res = await submitPaymentWithRetry(
|
||||
@@ -404,7 +461,8 @@
|
||||
// code at the backend gate — a 503 auto-retry would re-send a
|
||||
// dead code and self-defeat.
|
||||
{
|
||||
verificationCodeGated: paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
|
||||
verificationCodeGated:
|
||||
paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
@@ -438,10 +496,27 @@
|
||||
twoFactor.reveal = false;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
// C6: a sca-unavailable refusal is communicated by the refusal dialog
|
||||
// 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
|
||||
// code, brute-force lockout) is recoverable — keep the code populated
|
||||
// and reveal the input so the sale can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||
// M13: a verification-required rejection means the backend did NOT
|
||||
// accept the fallback code (SCA-only posture / invalid token) —
|
||||
// withdraw consent so the code input never reappears and the error
|
||||
// surfaces clearly instead of looping on 2FA.
|
||||
if (
|
||||
msg === VERIFICATION_REQUIRED_MESSAGE ||
|
||||
isVerificationRequiredSignal(responseStatus, bodyText)
|
||||
) {
|
||||
twoFactor.declineConsent();
|
||||
}
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -452,13 +527,16 @@
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
|
||||
* with the verification-required signal. The CUSTOMER approves the 3DS
|
||||
* challenge in their banking app; the operator's screen shows the waiting
|
||||
* state. 'verified' retries the SAME sale line with the fresh verification_token
|
||||
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
|
||||
* demotes 2FA from backup to the available gate; '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.
|
||||
* with the verification-required signal (the proactive token was stale or
|
||||
* expired between tokenize and charge — the first attempt now always runs
|
||||
* proactive SCA, so this is the defensive path). The CUSTOMER approves the
|
||||
* 3DS challenge in their banking app; the operator's screen shows the waiting
|
||||
* state. 'verified' retries the SAME sale line with the fresh tokenize-result
|
||||
* token as new_card_token and its SAME cached idempotency key (never
|
||||
* 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> {
|
||||
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
|
||||
@@ -469,8 +547,8 @@
|
||||
try {
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
|
||||
twoFactor.declineConsent();
|
||||
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
@@ -479,7 +557,7 @@
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
twoFactor.declineConsent();
|
||||
throw err;
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
@@ -490,19 +568,22 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
verification_token: result.verificationToken
|
||||
// C1: the SCA tokenize-result token is the charge SOURCE.
|
||||
new_card_token: result.verificationToken
|
||||
})
|
||||
})
|
||||
);
|
||||
if (!retry.ok) {
|
||||
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;
|
||||
}
|
||||
twoFactor.reveal = true;
|
||||
twoFactor.declineConsent();
|
||||
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);
|
||||
} finally {
|
||||
@@ -898,6 +979,18 @@
|
||||
</div>
|
||||
{/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
|
||||
current 2FA verification code when the backend enforces
|
||||
the gate. -->
|
||||
@@ -909,8 +1002,7 @@
|
||||
{#if twoFactor.showInput}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
@@ -950,7 +1042,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
class="mt-3 min-h-11 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.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 { POLICY } from '$lib/constants/policy';
|
||||
import {
|
||||
@@ -51,7 +52,8 @@
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
shouldFallbackTo2FA,
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
@@ -168,9 +170,9 @@
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
|
||||
// from backup to the only available gate (scaAvailable → false); every other
|
||||
// outcome keeps SCA primary for the next retry.
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
let lastSCAOutcome = $state('');
|
||||
// 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.
|
||||
@@ -182,7 +184,9 @@
|
||||
enabled: () => twoFactorEnabled,
|
||||
gateActive: () =>
|
||||
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);
|
||||
@@ -470,6 +474,15 @@
|
||||
toast.error(depositError);
|
||||
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;
|
||||
} finally {
|
||||
waitingForSCA = false;
|
||||
@@ -482,8 +495,12 @@
|
||||
idempotency_key: depositIdempotencyKey,
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||
// C1: the SCA tokenize-result token for a saved card is 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)
|
||||
};
|
||||
|
||||
paymentAttempted = true;
|
||||
@@ -544,8 +561,8 @@
|
||||
}),
|
||||
// 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
|
||||
// code is the gate only when no SCA verification token is present.
|
||||
{ verificationCodeGated: twoFactor.showInput && !('verification_token' in body) }
|
||||
// code is the gate only when no SCA tokenize-result is present.
|
||||
{ verificationCodeGated: twoFactor.showInput && !('new_card_token' in body) }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -584,6 +601,11 @@
|
||||
// charge), surface the guidance and let the user retry — never re-run SCA
|
||||
// silently mid-flow.
|
||||
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;
|
||||
toast.warning(depositError);
|
||||
return;
|
||||
@@ -2773,6 +2795,20 @@
|
||||
</div>
|
||||
{/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
|
||||
current 2FA verification code when the backend
|
||||
enforces the gate. -->
|
||||
@@ -2785,8 +2821,7 @@
|
||||
{#if twoFactor.showInput && twoFactorEnabled}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
@@ -2797,13 +2832,18 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
class="min-h-11 bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
import type { SquareVerificationContact } from './SquareCardInput.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
export interface SelectableCard {
|
||||
id: string;
|
||||
@@ -46,12 +44,12 @@
|
||||
// would collide on the same checkbox id. Pure SPA, so no SSR concern.
|
||||
const consentId = `save-card-consent-${generateUUID()}`;
|
||||
|
||||
// B6/B10: saved-card charges require the customer's current 2FA verification
|
||||
// code. This no longer BLOCKS saved-card selection — the code is collected
|
||||
// at the charge step (the parent charge forms show the input). The new-card
|
||||
// (nonce) path keeps its own SCA via Square tokenizeWithVerification.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
// SCA-only posture (C6): every saved-card charge is authorised by Square
|
||||
// Strong Customer Authentication — there is no 2FA code fallback and no
|
||||
// 2FA-setup prerequisite for saving a card (the STORE-intent tokenize
|
||||
// carries its own SCA). The note below just forewarns the buyer that the
|
||||
// issuer may ask them to approve the payment in their banking app.
|
||||
const showSCANote = $derived(cards.length > 0);
|
||||
|
||||
// Auto-select the default saved card when cards first load. Guarded by
|
||||
// !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
|
||||
* for UK card-not-present charges). Returns the nonce AND the verification
|
||||
* token, which the caller must send to the backend as `verification_token`
|
||||
* alongside the nonce so the charge completes. Pass `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).
|
||||
* for UK card-not-present charges). In the CURRENT SDK the returned nonce
|
||||
* IS the SCA-verified tokenize-result — there is no separate verification
|
||||
* token (that nested shape only came from the deprecated verifyBuyer()
|
||||
* flow) — so the caller sends the `nonce` as the charge source. Pass
|
||||
* `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(
|
||||
amount: number,
|
||||
@@ -105,23 +105,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if savedCardChargeRequires2FACode}
|
||||
{#if twoFactorEnabled}
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline"
|
||||
>Enable it in your account settings</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSCANote}
|
||||
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
<p class="text-xs text-amber-800">
|
||||
Your card issuer may ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if cards.length > 0}
|
||||
@@ -186,7 +186,7 @@
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
|
||||
{#if canSaveCards && squareCardReady && !(savedCardChargeRequires2FACode && !twoFactorEnabled)}
|
||||
{#if canSaveCards && squareCardReady}
|
||||
<label
|
||||
class="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-600"
|
||||
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
|
||||
* deterministic counterpart of Square's buyer-verification flow for a saved
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
runSavedCardSCAProactively,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
@@ -27,6 +27,7 @@
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
|
||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
@@ -145,9 +146,9 @@
|
||||
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
|
||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||
let customerTwoFactorEnabled = $state(false);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
|
||||
// from backup to the only available gate (scaAvailable → false); every other
|
||||
// outcome keeps SCA primary for the next retry.
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
let lastSCAOutcome = $state('');
|
||||
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
@@ -163,7 +164,9 @@
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
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: () => {
|
||||
const customerID = booking.user_id ?? booking.user?.id;
|
||||
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
||||
@@ -591,6 +594,9 @@
|
||||
selectedMethod = null;
|
||||
checkoutId = 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(() => {
|
||||
@@ -936,13 +942,13 @@
|
||||
return;
|
||||
}
|
||||
if (proactive.outcome === 'sca-unavailable') {
|
||||
// MIT surface: a token-less ccof is never sent even when SCA
|
||||
// can't run — stop the charge and surface the 2FA fallback
|
||||
// gate (the operator enters the customer's code and re-taps).
|
||||
twoFactor.reveal = true;
|
||||
status = 'error';
|
||||
error = `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`;
|
||||
toast.error(error);
|
||||
// C6: SCA genuinely can't run. Stop the charge — a token-less
|
||||
// ccof is never sent — and surface the refusal notice; there
|
||||
// is NO 2FA fallback. The operator taps OK to close, or Back
|
||||
// to pick a different payment method / retry SCA.
|
||||
twoFactor.declineConsent();
|
||||
twoFactor.reveal = false;
|
||||
status = 'saved-card-selecting';
|
||||
return;
|
||||
}
|
||||
verificationToken = proactive.verificationToken ?? '';
|
||||
@@ -960,8 +966,14 @@
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
// C1: the SCA tokenize-result token is the charge SOURCE
|
||||
// (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
|
||||
})
|
||||
}),
|
||||
@@ -987,8 +999,11 @@
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
...(verificationToken ? { new_card_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput && !verificationToken
|
||||
? { verification_code: twoFactor.code }
|
||||
: {}),
|
||||
...scaFallbackConsentFields(twoFactor.consentAccepted),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
}
|
||||
};
|
||||
@@ -1035,6 +1050,11 @@
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
|
||||
// M13: a verification-required 402 means the backend did NOT
|
||||
// accept the fallback code (SCA-only posture / invalid token) —
|
||||
// withdraw consent so the code input never reappears and the
|
||||
// modal shows the SCA guidance instead of looping on 2FA.
|
||||
twoFactor.declineConsent();
|
||||
msg = VERIFICATION_REQUIRED_MESSAGE;
|
||||
}
|
||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||
@@ -1750,6 +1770,17 @@
|
||||
</div>
|
||||
{/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
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
@@ -1764,8 +1795,7 @@
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
@@ -1775,10 +1805,10 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="flex-1"
|
||||
class="min-h-11 flex-1"
|
||||
disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing}
|
||||
>
|
||||
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,
|
||||
isSquareConfigured,
|
||||
isSquareMock,
|
||||
parseTokenizeVerificationResult,
|
||||
type SquareTokenizeResult,
|
||||
type SquareVerificationContact
|
||||
type SquareVerificationContact as SquareContactType
|
||||
} from '$lib/square/square';
|
||||
|
||||
/** 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
|
||||
* logic (tokenizer + the proactive runner). Kept here so the six payment
|
||||
* surfaces' existing imports stay unchanged. */
|
||||
export {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SquareVerificationContact
|
||||
} from '$lib/square/square';
|
||||
export { tokenizeSavedCardWithVerification } from '$lib/square/square';
|
||||
export type { SquareVerificationContact } from '$lib/square/square';
|
||||
|
||||
/** Result of a tokenize-with-verification call. */
|
||||
export interface TokenizeWithVerificationResult {
|
||||
@@ -31,10 +28,10 @@
|
||||
|
||||
/** Square Web Payments `card.tokenize()` verificationDetails shape. */
|
||||
interface SquareVerificationDetails {
|
||||
amount: string;
|
||||
billingContact?: SquareVerificationContact;
|
||||
amount?: string;
|
||||
billingContact?: SquareContactType;
|
||||
intent: string;
|
||||
currencyCode: string;
|
||||
currencyCode?: string;
|
||||
customerInitiated: boolean;
|
||||
sellerKeyedIn: boolean;
|
||||
}
|
||||
@@ -188,9 +185,10 @@
|
||||
* Authentication for most online payments — without verificationDetails,
|
||||
* Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED.
|
||||
*
|
||||
* Returns BOTH the card nonce and the verification token, which the caller
|
||||
* must send to the backend as `verification_token` alongside
|
||||
* `new_card_token`/`card_token`.
|
||||
* In the CURRENT SDK the SCA-verified tokenize-result (`result.token`) is
|
||||
* the one-time source for the charge — there is no separate verification
|
||||
* 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).
|
||||
* Square requires this to match the eventual payment amount.
|
||||
@@ -209,7 +207,7 @@
|
||||
*/
|
||||
export async function tokenizeWithVerification(
|
||||
amount: number,
|
||||
contact?: SquareVerificationContact,
|
||||
contact?: SquareContactType,
|
||||
saveCard: boolean = false
|
||||
): Promise<TokenizeWithVerificationResult> {
|
||||
if (isSquareMock()) {
|
||||
@@ -258,6 +256,49 @@
|
||||
.join(', ') || 'Card details are incomplete';
|
||||
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>
|
||||
|
||||
{#if isSquareMock()}
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
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
|
||||
// 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
|
||||
// in ONE place so the three surfaces can't diverge. When embedded in a modal
|
||||
// (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 = {
|
||||
service_id: string;
|
||||
booking_id: string;
|
||||
@@ -65,7 +69,11 @@
|
||||
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');
|
||||
// Synchronous double-click guard for submitTip. paymentState is only set to
|
||||
@@ -118,9 +126,9 @@
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
|
||||
// from backup to the only available gate (scaAvailable → false); every other
|
||||
// outcome keeps SCA primary for the next retry.
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
let lastSCAOutcome = $state('');
|
||||
// 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.
|
||||
@@ -132,7 +140,9 @@
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => twoFactorEnabled,
|
||||
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);
|
||||
@@ -334,6 +344,15 @@
|
||||
toast.error(tipError);
|
||||
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;
|
||||
} finally {
|
||||
waitingForSCA = false;
|
||||
@@ -344,8 +363,14 @@
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||
// C1: the SCA tokenize-result token for a saved card is 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)
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(
|
||||
@@ -398,6 +423,11 @@
|
||||
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
|
||||
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||
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;
|
||||
}
|
||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||
@@ -574,6 +604,18 @@
|
||||
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
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
@@ -584,8 +626,7 @@
|
||||
{#if twoFactor.showInput && twoFactorEnabled}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
@@ -623,7 +664,7 @@
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing}
|
||||
loading={paymentState === 'processing'}
|
||||
|
||||
@@ -28,10 +28,12 @@
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
booking: Booking;
|
||||
@@ -61,9 +63,9 @@
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
|
||||
// from backup to the only available gate (scaAvailable → false); every other
|
||||
// outcome keeps SCA primary for the next retry.
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
let lastSCAOutcome = $state('');
|
||||
// True while the proactive saved-card SCA challenge (card.tokenize with
|
||||
// verificationDetails) is in flight — the challenge is out-of-band (the
|
||||
@@ -73,7 +75,9 @@
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => twoFactorEnabled,
|
||||
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';
|
||||
@@ -518,6 +522,15 @@
|
||||
toast.error(error);
|
||||
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;
|
||||
} finally {
|
||||
waitingForSCA = false;
|
||||
@@ -562,8 +575,14 @@
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
// C1: the SCA tokenize-result token for a saved card is
|
||||
// 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
|
||||
})
|
||||
}),
|
||||
@@ -659,6 +678,11 @@
|
||||
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||
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;
|
||||
}
|
||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||
@@ -1077,6 +1101,17 @@
|
||||
{/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
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
@@ -1087,8 +1122,7 @@
|
||||
{#if twoFactor.showInput && twoFactorEnabled}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
@@ -1139,7 +1173,7 @@
|
||||
<!-- Pay button -->
|
||||
<Button
|
||||
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={status === 'processing'}
|
||||
disabled={payButtonDisabled || twoFactor.missing}
|
||||
>
|
||||
@@ -1223,7 +1257,7 @@
|
||||
<!-- Pay button -->
|
||||
<Button
|
||||
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={status === 'processing'}
|
||||
disabled={payButtonDisabled || twoFactor.missing}
|
||||
>
|
||||
|
||||
@@ -77,7 +77,14 @@
|
||||
data-slot="alert-dialog-content"
|
||||
style={z > 0 ? `z-index: ${z}` : undefined}
|
||||
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)
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
SCA_FALLBACK_CONSENT_VERSION,
|
||||
SCA_REFUSAL_MESSAGE_ONLINE,
|
||||
SCA_REFUSAL_MESSAGE_TILL,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
campaignDiscountPence,
|
||||
@@ -17,8 +20,10 @@ import {
|
||||
parseTokenizeVerificationResult,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
type SavedCardVerificationOutcome
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
|
||||
@@ -306,7 +311,7 @@ describe('isVerificationRequiredSignal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFallbackTo2FA', () => {
|
||||
describe('shouldShowSCARefusal', () => {
|
||||
it.each([
|
||||
['sca-unavailable', true],
|
||||
['verified', false],
|
||||
@@ -314,19 +319,59 @@ describe('shouldFallbackTo2FA', () => {
|
||||
['sca-failed', false],
|
||||
['', false]
|
||||
])('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', () => {
|
||||
expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true);
|
||||
it('refuses the charge only on sca-unavailable (C6 SCA-only posture)', () => {
|
||||
expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
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' });
|
||||
});
|
||||
|
||||
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => {
|
||||
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (C6 refusal)', () => {
|
||||
expect(
|
||||
parseTokenizeVerificationResult({
|
||||
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', () => {
|
||||
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
|
||||
expect(depositChargePence(2000, 500)).toBe(1500);
|
||||
@@ -686,3 +748,333 @@ describe('depositChargePence', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,19 +123,19 @@ const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
* This is the DEFENSIVE/unexpected path: customer-initiated saved-card (ccof)
|
||||
* surfaces now run the client-side SCA challenge PROACTIVELY before the first
|
||||
* charge attempt (tokenizeSavedCardWithVerification) and carry a fresh
|
||||
* `verification_token` on the charge — or have demoted to the 2FA gate when
|
||||
* SCA is unavailable — so a naked ccof charge should never reach Square. A
|
||||
* 402 here therefore means the verification token was consumed/expired
|
||||
* between tokenize and charge (or a config drift), and the buyer should be
|
||||
* pointed at the retry affordance rather than silently re-challenged. The
|
||||
* backend sets customer_details.customer_initiated=true on saved-card (ccof)
|
||||
* charges and classifies issuer-verification rejections — Square's
|
||||
* CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the
|
||||
* response body is the generic "Payment failed" text with no distinguishing
|
||||
* code. Retrying the same saved card can never succeed, and the buyer must pay
|
||||
* with a freshly tokenized card, re-add theirs, or re-run the SCA challenge.
|
||||
* New-card (cnon) charges carry their own SCA verification token, so they are
|
||||
* never classified this way.
|
||||
* tokenize-result token as `new_card_token` on the charge — or refuse when SCA
|
||||
* is unavailable (C6) — so a naked ccof charge should never
|
||||
* reach Square. A 402 here therefore means the SCA tokenize-result token was
|
||||
* consumed/expired between tokenize and charge (or a config drift), and the
|
||||
* buyer should be pointed at the retry affordance rather than silently
|
||||
* re-challenged. The backend sets customer_details.customer_initiated=true on
|
||||
* saved-card (ccof) charges and classifies issuer-verification rejections —
|
||||
* Square's CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive
|
||||
* 402s, but the response body is the generic "Payment failed" text with no
|
||||
* distinguishing code. Retrying the same saved card can never succeed, and the
|
||||
* buyer must pay with a freshly tokenized card, re-add theirs, or re-run the
|
||||
* SCA challenge. New-card (cnon) charges carry their own SCA verification
|
||||
* token, so they are never classified this way.
|
||||
*/
|
||||
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
|
||||
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)
|
||||
* charge requires Strong Customer Authentication and no `verification_token`
|
||||
* was supplied. The saved-card charge path returns a JSON body of the form
|
||||
* `{"error": "...", "code": "verification_required"}` — the shared error-text
|
||||
* extractor only surfaces the human-readable message, so this checks the raw
|
||||
* body for the code field exactly like isOverflowTipConfirmationRequired does.
|
||||
* charge requires Strong Customer Authentication and no SCA tokenize-result
|
||||
* token (`new_card_token`) was supplied. The saved-card charge path returns a
|
||||
* JSON body of the form `{"error": "...", "code": "verification_required"}` —
|
||||
* the shared error-text extractor only surfaces the human-readable message, so
|
||||
* this checks the raw body for the code field exactly like
|
||||
* isOverflowTipConfirmationRequired does.
|
||||
*/
|
||||
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
|
||||
* unavailable (no 3DS challenge could be run), so the surface falls back to the
|
||||
* homegrown 2FA gate. 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).
|
||||
* unavailable (no 3DS challenge could be run). The C6 legal verdict (PSR 2017
|
||||
* SCA is non-waivable; the merchant is liable regardless of consent) made the
|
||||
* homegrown 2FA code gate unlawful as an SCA fallback for saved-card charges,
|
||||
* 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';
|
||||
}
|
||||
|
||||
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
|
||||
* 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 =
|
||||
'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
|
||||
|
||||
@@ -220,12 +225,13 @@ export interface SquareTokenizeResult {
|
||||
* - `status === 'OK'` means buyer verification either completed or was NOT
|
||||
* 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
|
||||
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square
|
||||
* risk rules are the fallback), never a dead-end.
|
||||
* demanded, so the charge proceeds token-less and the backend's SCA-only
|
||||
* verification-required gate is the arbiter, never a silent 2FA fallback.
|
||||
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
|
||||
* shown but not completed — the buyer can retry, so this is retryable.
|
||||
* - `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.
|
||||
*/
|
||||
export function parseTokenizeVerificationResult(
|
||||
@@ -276,7 +282,7 @@ interface SquareVerificationDetails {
|
||||
* Returns a verification token (retry the SAME charge with it) plus an
|
||||
* outcome the surfaces map to UX: 'verified' → retry with the token;
|
||||
* '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(
|
||||
amount: number,
|
||||
@@ -347,7 +353,7 @@ export async function tokenizeSavedCardWithVerification(
|
||||
result = await card.tokenize(verificationDetails, squareCardId);
|
||||
} catch (err) {
|
||||
// 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);
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
@@ -357,7 +363,7 @@ export async function tokenizeSavedCardWithVerification(
|
||||
// in the current SDK — never a nested verificationResult, which only
|
||||
// exists on the deprecated verifyBuyer() flow), tokenless when the issuer
|
||||
// demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback.
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED → sca-unavailable (C6 refusal).
|
||||
return parseTokenizeVerificationResult(result);
|
||||
}
|
||||
|
||||
@@ -370,7 +376,7 @@ export interface RunSavedCardSCAOptions {
|
||||
/** Billing contact passed to Square's verificationDetails (optional). */
|
||||
buyer?: SquareVerificationContact;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -386,8 +392,9 @@ export interface RunSavedCardSCAOptions {
|
||||
* everything downstream of the resolved squareCardId.
|
||||
*
|
||||
* Returns the outcome plus the verification token ('verified' → retry the
|
||||
* SAME charge with it). On 'sca-unavailable' the caller falls back to the 2FA
|
||||
* gate; 'challenge-cancelled'/'sca-failed' are retryable without a token.
|
||||
* SAME charge with it). On 'sca-unavailable' the caller shows the refusal
|
||||
* notice (C6 — no 2FA fallback); 'challenge-cancelled'/'sca-failed' are
|
||||
* retryable without a token.
|
||||
*/
|
||||
export async function runSavedCardSCAProactively(
|
||||
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
|
||||
* 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 =
|
||||
"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
|
||||
* only available authorisation and is surfaced as the fallback gate. */
|
||||
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE =
|
||||
"In-app approval isn't available for this card — enter the verification code instead.";
|
||||
/**
|
||||
* User-facing refusal shown when a saved-card charge hits genuine
|
||||
* `sca-unavailable` (the issuer's in-app SCA challenge cannot run). C6 legal
|
||||
* verdict: the homegrown 2FA code gate cannot legally substitute for SCA (PSR
|
||||
* 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
|
||||
* verification to complete. Retrying the same saved card is pointless — the
|
||||
|
||||
@@ -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) {
|
||||
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();
|
||||
this.user = userData;
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
|
||||
/**
|
||||
* Shared 2FA verification-code state for the saved-card charge surfaces.
|
||||
*
|
||||
* B6/B10: the backend's requireTwoFactorForCardAccess gate requires the CARD
|
||||
* OWNER's current one-time verification code on every saved-card charge in an
|
||||
* enforced environment. This composable owns the whole verification-code UX —
|
||||
* the code itself, the reveal flag (a charge that 403s for a missing code
|
||||
* reveals the input even when the session profile's 2FA flag is stale), the
|
||||
* show/missing derivations and the "Request a new code" handler — so the six
|
||||
* payment surfaces (booking modal, tip, account gift-card, booking-flow
|
||||
* deposit, admin till and admin payment modal) can't drift on any of them.
|
||||
* C6 (PSR 2017): the homegrown 2FA code gate can no longer substitute for SCA
|
||||
* on saved-card charges — the merchant stays liable regardless of consent — so
|
||||
* the charge surfaces REFUSE on genuine `sca-unavailable` instead of falling
|
||||
* back to a code. This composable therefore owns the verification-code UX for
|
||||
* the remaining legitimate uses ONLY: the defensive 403 self-heal (an opt-in
|
||||
* deployment whose backend actually allows the token-less 2FA-gated charge —
|
||||
* the code input surfaces when the backend asks for it, never preemptively)
|
||||
* 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:
|
||||
* - `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:
|
||||
* the operator always supplies the CUSTOMER's code, so the
|
||||
* session user's own flag is irrelevant to the gate.
|
||||
* - `gateActive()` — whether the pending charge hits the 2FA gate: a saved
|
||||
* card is selected, or a new card is being saved for reuse.
|
||||
* The surface passes its exact gate expression so each
|
||||
* - `gateActive()` — whether the pending charge would hit the backend's 2FA
|
||||
* gate. The surface passes its exact gate expression so each
|
||||
* surface's gate semantics are preserved verbatim.
|
||||
* - `scaAvailable()` — whether Square Strong Customer Authentication is the
|
||||
* active authorisation for this charge. Defaults to true
|
||||
* (SCA primary). When the last SCA attempt reported the
|
||||
* challenge is genuinely unavailable ('sca-unavailable'),
|
||||
* the surface passes `() => !shouldFallbackTo2FA(...)` so
|
||||
* the code input surfaces as the 2FA-BACKUP path — it shows
|
||||
* without a 403 self-heal because SCA can't authorise.
|
||||
* (SCA primary). Charge surfaces pass `() => true` under the
|
||||
* SCA-only posture: SCA is ALWAYS the authorisation, so the
|
||||
* code input never demotes in from the SCA-unavailable path
|
||||
* and only ever surfaces via the explicit `reveal` self-heal.
|
||||
* - `mint()` — optional; the code-request call. Customer surfaces omit
|
||||
* it (defaults to the session-scoped /api/user/2fa/code:
|
||||
* 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.
|
||||
let requesting = $state(false);
|
||||
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
// gate AND SCA isn't available to authorise instead (2FA is the BACKUP, not
|
||||
// the default), OR a failure has revealed it explicitly.
|
||||
// C6: the SCA-unavailable fallback is REFUSED, so the code input never
|
||||
// demotes in from an SCA outcome (charge surfaces pass `scaAvailable: () =>
|
||||
// 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 showInput = $derived(reveal || (options.gateActive() && !scaAvailable()));
|
||||
const showInput = $derived(
|
||||
reveal || (consentAccepted && options.gateActive() && !scaAvailable())
|
||||
);
|
||||
const missing = $derived(showInput && options.enabled() && code.trim() === '');
|
||||
|
||||
function acceptConsent() {
|
||||
consentAccepted = true;
|
||||
reveal = true;
|
||||
}
|
||||
|
||||
function declineConsent() {
|
||||
consentAccepted = false;
|
||||
reveal = false;
|
||||
}
|
||||
|
||||
async function requestNewCode() {
|
||||
if (requesting) return;
|
||||
requesting = true;
|
||||
@@ -112,6 +135,11 @@ export function useTwoFactorCodeForSavedCard(options: {
|
||||
get requesting() {
|
||||
return requesting;
|
||||
},
|
||||
requestNewCode
|
||||
requestNewCode,
|
||||
get consentAccepted() {
|
||||
return consentAccepted;
|
||||
},
|
||||
acceptConsent,
|
||||
declineConsent
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
@@ -93,7 +94,22 @@
|
||||
|
||||
{#if !hideFooter}
|
||||
<footer class="border-t py-3 text-center text-xs text-gray-500 md:py-4 md:text-sm">
|
||||
© {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 & Conditions</a
|
||||
>
|
||||
<a href={resolve('/cancellation-policy')} class="hover:text-gray-700 hover:underline"
|
||||
>Booking, Deposit & 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">© {new Date().getFullYear()} Crussell Nails. All rights reserved.</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
|
||||
import {
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
canSaveCardsForRole,
|
||||
@@ -17,7 +18,8 @@
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
shouldFallbackTo2FA,
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
@@ -159,7 +161,16 @@
|
||||
if (!addCardSquareCardInput) return;
|
||||
let token: string;
|
||||
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) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -175,8 +186,21 @@
|
||||
toast.success('Card saved');
|
||||
await savedCardsStore.invalidate();
|
||||
} 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();
|
||||
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 {
|
||||
toast.error('Network error');
|
||||
@@ -260,9 +284,9 @@
|
||||
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
|
||||
// from backup to the only available gate (scaAvailable → false); every other
|
||||
// outcome keeps SCA primary for the next retry.
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
let buyLastSCAOutcome = $state('');
|
||||
// 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.
|
||||
@@ -273,7 +297,9 @@
|
||||
const buyTwoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => buyTwoFactorEnabled,
|
||||
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
|
||||
@@ -525,6 +551,17 @@
|
||||
toast.error(buyError);
|
||||
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;
|
||||
} finally {
|
||||
buyWaitingForSCA = false;
|
||||
@@ -542,8 +579,14 @@
|
||||
recipient_email: buyRecipientEmail,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
||||
// C1: the SCA tokenize-result token for a saved card
|
||||
// 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
|
||||
})
|
||||
}),
|
||||
@@ -590,6 +633,13 @@
|
||||
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
||||
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;
|
||||
toast.error(buyErrMsg);
|
||||
// A definitive charge failure (e.g. declined card) consumes the
|
||||
@@ -2364,7 +2414,7 @@
|
||||
onReady={(r) => (addCardReady = r)}
|
||||
/>
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
class="mt-3 min-h-11 w-full"
|
||||
onclick={addCard}
|
||||
disabled={addingCard || !addCardReady}
|
||||
loading={addingCard}
|
||||
@@ -2654,6 +2704,16 @@
|
||||
bind:saveCard={buySaveCard}
|
||||
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
|
||||
current 2FA verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
@@ -2664,8 +2724,7 @@
|
||||
{#if buyTwoFactor.showInput && buyTwoFactorEnabled}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="min-h-11 w-full"
|
||||
loading={buyTwoFactor.requesting}
|
||||
disabled={buyTwoFactor.requesting}
|
||||
onclick={buyTwoFactor.requestNewCode}
|
||||
@@ -2712,7 +2771,7 @@
|
||||
!isBuyCardValid ||
|
||||
buyTwoFactor.missing ||
|
||||
buyDailyTotal + buyAmount > dailyGiftCardBuyLimit}
|
||||
class="mt-2 w-full"
|
||||
class="mt-2 min-h-11 w-full"
|
||||
>
|
||||
{buyingGiftCard
|
||||
? 'Processing Payment...'
|
||||
@@ -2992,10 +3051,10 @@
|
||||
|
||||
<!-- Two-Factor Authentication (visible to all roles; the notification
|
||||
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>
|
||||
<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>
|
||||
|
||||
{#if authStore.currentUser?.twoFactorEnabled}
|
||||
@@ -3007,14 +3066,14 @@
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
{:else if authStore.currentUser?.twoFactorRequired}
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
You must enable 2FA to use online card payments.
|
||||
Two-factor authentication adds an extra verification step to your account.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3491,8 +3550,8 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Disabling two-factor authentication means online card payments will be blocked while 2FA
|
||||
is required. You can re-enable it at any time.
|
||||
Disabling two-factor authentication removes the extra verification step from your account.
|
||||
You can re-enable it at any time.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
|
||||
@@ -100,6 +100,13 @@
|
||||
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.
|
||||
</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 3 -->
|
||||
@@ -123,19 +130,23 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
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,
|
||||
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 — so if you only paid a
|
||||
20% deposit, no more than that is ever retained.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50/50 p-4">
|
||||
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
|
||||
<p class="mt-1 text-xs text-gray-600">
|
||||
All booking payments and deposits are entirely non-refundable and will be retained. The
|
||||
cancellation will be logged as a missed appointment history strike.
|
||||
All booking payments and deposits are retained (no refund). The cancellation will be
|
||||
logged as a missed appointment history strike.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,10 +235,9 @@
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
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
|
||||
your account returns to normal — no upfront deposits required — until a new no-show occurs.
|
||||
The salon can also forgive individual no-shows at management's discretion, which immediately
|
||||
removes them from the count.
|
||||
count reaches zero, your account returns to normal — no upfront deposits required — until a
|
||||
new no-show occurs. The salon can also forgive individual no-shows at management's
|
||||
discretion, which immediately removes them from the count.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -257,8 +267,12 @@
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
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
|
||||
full position.
|
||||
is issued, so the remaining balance cannot then be spent. 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.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
If you believe your statutory consumer rights have not been met, you can get free, impartial
|
||||
|
||||
@@ -159,12 +159,9 @@
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
verification_codes?: Array<{
|
||||
purpose: string;
|
||||
created_at: string;
|
||||
used_at?: string;
|
||||
expires_at: string;
|
||||
}>;
|
||||
// verification codes are excluded from the export (they are authentication
|
||||
// tokens, not personal data), so there is deliberately no verification_codes
|
||||
// key and no card for it below.
|
||||
forgiven_no_shows?: Array<{
|
||||
id: string;
|
||||
booking_id: string;
|
||||
@@ -1412,41 +1409,9 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Verification Codes -->
|
||||
{#if gdprData.verification_codes && gdprData.verification_codes.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
<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}
|
||||
<!-- Verification codes are not exported (authentication tokens are excluded
|
||||
from the GDPR export), so there is deliberately no Verification Codes
|
||||
card here; it could never populate. -->
|
||||
|
||||
<!-- Forgiven No-Shows -->
|
||||
{#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="mb-2 flex items-center gap-3">
|
||||
<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 — for review
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: August 2026</p>
|
||||
|
||||
@@ -77,6 +72,13 @@
|
||||
<p>Edinburgh, Scotland</p>
|
||||
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. -->
|
||||
<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 — nothing in the Platform registers the business. See the ICO website
|
||||
(ico.org.uk) for the fee and exemptions.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -84,6 +86,33 @@
|
||||
<section>
|
||||
<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> — 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> — 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> — 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> — 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 §2.4).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
|
||||
2.1 Personal Data (Identifiable Information)
|
||||
</h3>
|
||||
@@ -204,6 +233,43 @@
|
||||
to you if your account is deleted, and allergy/access information held in your treatment notes
|
||||
is retained de-identified (see §3.1).
|
||||
</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’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 3 -->
|
||||
@@ -255,6 +321,27 @@
|
||||
<td class="px-3 py-2">7 years</td>
|
||||
<td class="px-3 py-2">Insurance requirement</td>
|
||||
</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 §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>
|
||||
<td class="px-3 py-2"
|
||||
>Treatment & safety notes (incl. allergy/access information)</td
|
||||
@@ -338,7 +425,10 @@
|
||||
</ul>
|
||||
<p class="mb-4">
|
||||
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
|
||||
to the Information Commissioner’s Office (ICO) at any time.
|
||||
to the Information Commissioner’s Office (ICO) at any time — 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 class="text-xs text-gray-500">
|
||||
Questions about how we handle your data? Please use our official
|
||||
|
||||
@@ -43,13 +43,8 @@
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Terms & 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 — for review
|
||||
</span>
|
||||
</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}
|
||||
<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">
|
||||
<li>Card payments are processed securely via Square.</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>
|
||||
<strong>Refunds are returned to the original payment method where possible:</strong>
|
||||
<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).
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase within
|
||||
14 days for a refund to the original payment method. If the card has been partly used on salon
|
||||
services, only the unspent balance is refunded and the card is then cancelled. A card that has
|
||||
been redeemed to an account balance or fully spent cannot be cancelled. See our Gift Card Terms
|
||||
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase
|
||||
within 14 days for a refund to the original payment method. If the card has been partly used
|
||||
on salon services, only the unspent balance is refunded and the card is then cancelled. A
|
||||
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.
|
||||
</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> — 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 —
|
||||
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’s
|
||||
rights.
|
||||
</li>
|
||||
<li>
|
||||
Attempt to access another user’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 & Dispute Resolution
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
If you are unhappy with any part of our service, please contact us first at
|
||||
{'{{SUPPORT_EMAIL}}'} — 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
|
||||
£5,000 can be pursued through the Scottish courts’ 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 class="border-t border-gray-200 pt-6">
|
||||
|
||||
Reference in New Issue
Block a user