feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup

SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent ecef5da516
commit 5dae0bba08
35 changed files with 3683 additions and 249 deletions
@@ -12,12 +12,19 @@
campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
@@ -44,6 +51,7 @@
| 'gift-confirming'
| 'saved-card-selecting'
| 'saved-card-processing'
| 'saved-card-waiting-sca'
| 'success'
| 'error';
@@ -78,6 +86,10 @@
// 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.
let lastSCAOutcome = $state('');
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
@@ -92,6 +104,7 @@
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
@@ -739,6 +752,7 @@
exp_month: number;
exp_year: number;
cardholder_name?: string;
square_card_id?: string;
}>
>([]);
let loadingSavedCards = $state(false);
@@ -829,7 +843,18 @@
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
// Saved-card (ccof) SCA: the backend returns 402 +
// `verification_required` when Square requires buyer verification
// and no verification_token was supplied. Run the client-side 3DS
// challenge (the CUSTOMER approves in their banking app) and retry
// with the fresh token + the SAME cached idempotency key.
if (isVerificationRequiredSignal(responseStatus, errData)) {
await runSavedCardSCA(chargeAmount);
return;
}
const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
const data = await response.json();
@@ -854,13 +879,17 @@
onComplete(paymentResult);
} catch (_err) {
status = 'error';
// Saved-card (ccof) charges skip the client-side SCA step, so a
// definitive 402 on the saved-card path means the issuer still
// requires verification — retrying the same saved card can never
// succeed. Surface the fix instead of the generic backend text.
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is the
// fallback for generic 402s (the SCA flow above intercepts the
// structured ones, so this is the belt-and-braces path).
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
if (isSavedCardVerificationRequired(responseStatus, true))
msg = SAVED_CARD_VERIFICATION_MESSAGE;
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
@@ -884,6 +913,101 @@
fetchSavedCards();
}
});
/**
* Saved-card (ccof) SCA challenge, run when the charge 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 charge with the fresh verification_token
* and the SAME cached idempotency key (never regenerated here);
* - 'challenge-cancelled' / 'sca-failed' → leaves the pending row retryable
* (the idempotency key stays cached) and reveals the 2FA-fallback input;
* - 'sca-unavailable' → demotes 2FA from backup to the available gate.
*/
async function runSavedCardSCA(chargeAmount: number) {
if (!selectedSavedCardId) return;
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
status = 'saved-card-waiting-sca';
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
status = 'error';
error = VERIFICATION_REQUIRED_MESSAGE;
toast.error(error);
return;
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(chargeAmount, squareCardId, {
givenName: booking.user?.first_name,
familyName: booking.user?.last_name,
email: booking.user?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
status = 'error';
error = _err instanceof Error ? _err.message : 'Card verification failed';
toast.error(error);
return;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
try {
const retry = await submitPaymentWithRetry(() =>
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
verification_token: result.verificationToken,
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: savedCardIdempotencyKey
})
})
);
if (!retry.ok) {
const retryText = await retry.text();
const err = new Error(
extractErrorMessage(retryText) || 'Failed to process saved card payment'
);
(err as { bodyText?: string }).bodyText = retryText;
throw err;
}
const data = await retry.json();
status = 'success';
paymentResult = {
checkout_id: data.payment_id || data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Saved card payment successful');
onComplete(paymentResult);
return;
} catch (_err) {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
toast.error(error);
return;
}
}
twoFactor.reveal = true;
status = 'error';
error =
result.outcome === 'sca-unavailable'
? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
: "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
toast.error(error);
}
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
@@ -1526,8 +1650,7 @@
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">
This card may require bank app confirmation to complete. Ensure the customer has their
phone ready.
Your card issuer will ask you to approve this payment in your banking app.
</p>
</div>
{/if}
@@ -1563,12 +1686,19 @@
</Button>
</div>
</div>
{:else if status === 'saved-card-processing'}
{:else if status === 'saved-card-processing' || status === 'saved-card-waiting-sca'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
{#if status === 'saved-card-waiting-sca'}
<p class="text-lg font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-2 text-sm text-gray-500">The customer may need to approve this payment in their banking app</p>
{:else}
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
{/if}
</div>
{:else if status === 'error' && error}
<div class="space-y-4">