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
@@ -11,12 +11,19 @@
import {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
requestNewTwoFactorCode,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = {
id: string;
@@ -87,6 +94,9 @@
exp_month: number;
exp_year: number;
cardholder_name?: string;
// Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
// challenge (tokenizeSavedCardWithVerification).
square_card_id?: string;
};
let customerQuery = $state('');
@@ -129,9 +139,17 @@
// 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.
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.
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
});
@@ -384,6 +402,16 @@
if (!res.ok) {
responseStatus = res.status;
const errText = await res.text();
// 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 and retry the SAME sale line with the fresh token and
// its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome.
if (paymentMethod === 'saved_card' && isVerificationRequiredSignal(responseStatus, errText)) {
await runTillSavedCardSCA(body);
continue;
}
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
const data = await res.json();
@@ -405,11 +433,75 @@
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
processing = false;
}
} finally {
isProcessingPaymentSync = false;
processing = false;
}
}
/**
* 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.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
return;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(
`${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
);
}
throw new Error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
} finally {
awaitingSCA = false;
}
}
</script>
<div class="rounded-xl border bg-card">
@@ -793,10 +885,9 @@
<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">
This card may require bank app confirmation to complete. Ensure the customer has
their phone ready.
</p>
<p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app.
</p>
</div>
{/if}
@@ -836,18 +927,32 @@
</p>
{/if}
<Button
class="mt-3 w-full"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
{#if awaitingSCA}
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="mt-3 text-sm font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-1 text-xs text-muted-foreground">
The customer may need to approve this payment in their banking app
</p>
</div>
{:else}
<Button
class="mt-3 w-full"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
{/if}
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<p class="mt-1 text-xs text-muted-foreground">
Gift card sales are processed through the till; retail items require manual recording for