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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user