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

- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine
  'sca-unavailable' now drives the REFUSAL path (the customer is told the
  payment cannot complete and to pay online later), never the 2FA code fallback
  (PSR 2017 SCA is non-waivable; merchant liability is not cured by consent).
  SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1'
  + scaFallbackConsentFields() carry the versioned consent on the explicit
  opt-in path only (shipped surfaces send none). SquareTokenizeResult docs
  updated: tokenize-result token is the charge source, tokenless OK proceeds
  token-less under the backend's SCA-only gate.
- C1 wire contract on every saved-card surface (booking, tip, gift-card buy,
  till, account): the proactive SCA tokenize-result is sent as new_card_token
  (the charge SOURCE alongside the saved-card ref), never the legacy
  verification_token; 402 verification-required now means the tokenize-result
  was consumed/expired between tokenize and charge.
- New ScaFallbackConsentDialog surfaces the refusal notice; the code input
  (useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via
  a backend gate rejection (defensive/opt-in).
- Till (M10): proactive saved-card SCA runs per sale line BEFORE the first
  charge; sca-unavailable aborts the whole sale before any charge.
- Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization;
  402 verification-required on save surfaces SCA-first guidance instead of a
  generic failure.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 9a75ebc794
commit 0fdb2f02cd
21 changed files with 1404 additions and 315 deletions
@@ -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