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
@@ -46,8 +46,15 @@
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry
isVerificationRequiredSignal,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
@@ -107,6 +114,9 @@
exp_month: number;
exp_year: number;
is_default?: boolean;
// Square's card-on-file id (`ccof:...`), needed to run the saved-card
// SCA challenge (tokenizeSavedCardWithVerification).
square_card_id?: string;
}>
>([]);
let paymentMethodsLoading = $state(false);
@@ -158,10 +168,15 @@
// 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.
let lastSCAOutcome = $state('');
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard)
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
});
const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -520,6 +535,20 @@
}
const text = await response.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 with the fresh
// token — the SAME body and idempotency key (never regenerated here). A
// token-carrying retry is never re-intercepted (the backend skips the 2FA
// gate when a verification_token is present).
if (
selectedPaymentMethod &&
!body.verification_token &&
isVerificationRequiredSignal(response.status, text)
) {
await runDepositSCA({ body, amountPence, depositAmount, confirmOverflowTip });
return;
}
// 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 deposit can be retried with a fresh code.
@@ -610,6 +639,56 @@
depositTokenizedForSaveCard = false;
}
/**
* Saved-card (ccof) SCA challenge, run when the deposit charge came back 402
* with the verification-required signal. 'verified' retries the SAME deposit
* body with the fresh verification_token and the SAME cached idempotency key
* (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
* pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
* demotes 2FA from backup to the available gate.
*/
async function runDepositSCA(options: {
body: Record<string, unknown>;
amountPence: number;
depositAmount: number;
confirmOverflowTip: boolean;
}) {
const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
toast.error(VERIFICATION_REQUIRED_MESSAGE);
return;
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, {
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
toast.error(err instanceof Error ? err.message : 'Card verification failed');
return;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
await submitDepositPayment({
...options,
body: { ...options.body, verification_token: result.verificationToken }
});
return;
}
twoFactor.reveal = true;
toast.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."
);
}
let paymentAttempted = $state(false);
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
@@ -15,6 +15,10 @@
exp_month: number;
exp_year: number;
is_default?: boolean;
// Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
// challenge (tokenizeSavedCardWithVerification). Optional so surfaces that
// haven't populated it from the API can still render the list.
square_card_id?: string;
}
let {
@@ -104,7 +108,7 @@
{#if twoFactorEnabled}
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
<p class="text-sm text-blue-800">
A verification code is required to use a saved card — you'll be asked for it at checkout.
Your card issuer will ask you to approve this payment in your banking app.
</p>
</div>
{:else}
@@ -1,6 +1,33 @@
<!-- DEV-ONLY mock card form — enabled only by VITE_SQUARE_ENVIRONMENT === 'mock'
(never in production). The PAN lives only in local component state; only a
cnon: token is ever returned. -->
<script module lang="ts">
/**
* DEV-ONLY saved-card SCA challenge. SquareCardInput.tokenizeSavedCardWithVerification
* dynamically imports this module and calls this export when the mock form is
* active, so the saved-card (ccof) 3DS/SCA path runs end to end. The token is
* deterministic and stateless — it encodes the card prefix, the bound amount and
* the encoded outcome (`_ok`) — and the backend dev mock
* (square_dev.go parseVerifyToken) parses the same shape back, so local-dev
* mirrors production without shared state.
*/
export function tokenizeSavedCard(
amount: number,
squareCardId: string,
_contact?: { givenName?: string; familyName?: string; email?: string }
): Promise<{
verificationToken: string | null;
outcome: 'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
}> {
const stripped = squareCardId.startsWith('ccof:') ? squareCardId.slice(5) : squareCardId;
const prefix = stripped.slice(0, 4) || 'test';
return Promise.resolve({
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_ok`,
outcome: 'verified'
});
}
</script>
<script lang="ts">
import CardBrandIcon from './CardBrandIcon.svelte';
@@ -15,14 +42,46 @@
let {
disabled = false,
onReady = () => {}
}: { disabled?: boolean; onReady?: (ready: boolean) => void } = $props();
onReady = () => {},
// Dev toggles for the mock 3DS/SCA challenge simulation:
// simulateChallenge waits for the "Approve in banking app" button before
// resolving; challengeResult is the deterministic outcome encoded into the
// verification token (verify_mock_<prefix>_<amount>_ok|_deny).
simulateChallenge = false,
challengeResult = 'approve'
}: {
disabled?: boolean;
onReady?: (ready: boolean) => void;
simulateChallenge?: boolean;
challengeResult?: 'approve' | 'deny';
} = $props();
let cardNumber = $state('');
let expiry = $state('');
let cvc = $state('');
let cardholderName = $state('');
// Mock challenge state: while awaitingChallenge is true, the form shows the
// "Approve in banking app" panel and the in-flight tokenize call waits.
let awaitingChallenge = $state(false);
let challengeResolver: ((outcome: 'approve' | 'deny') => void) | null = null;
async function runChallenge(): Promise<'approve' | 'deny'> {
if (!simulateChallenge) return challengeResult ?? 'approve';
awaitingChallenge = true;
return await new Promise<'approve' | 'deny'>((resolve) => {
challengeResolver = resolve;
});
}
/** Dev toggle: resolves a simulated banking-app challenge with the configured outcome. */
export function resolveMockChallenge(): void {
const resolver = challengeResolver;
challengeResolver = null;
awaitingChallenge = false;
resolver?.(challengeResult ?? 'approve');
}
let cardNumberTouched = $state(false);
let expiryTouched = $state(false);
let cvcTouched = $state(false);
@@ -169,8 +228,12 @@
/**
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
* exercises the full SCA path (card nonce + verification token) end to end.
* The fake verification token is deterministic and the backend mock accepts
* it alongside the cnon: nonce.
* The fake verification token is deterministic — it encodes the card prefix,
* the bound amount and the challenge outcome (verify_mock_<prefix>_<amount>_ok|_deny)
* — and the backend dev mock parses it back (square_dev.go
* parseVerifyToken), so the frontend and backend agree on the outcome without
* shared state. The outcome is challengeResult (default approve), optionally
* gated behind the mock "Approve in banking app" panel via simulateChallenge.
*
* @param amount The amount that WILL be charged, in pence — same pence input
* contract as the real form. The real form serializes this to
@@ -190,13 +253,36 @@
if (!complete) {
throw new Error('Card details are incomplete');
}
const outcome = await runChallenge();
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
const prefix = digits.slice(0, 4) || 'test';
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
return Promise.resolve({
nonce: token,
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`
});
}
/**
* DEV-ONLY saved-card SCA verification token for a ccof charge — the
* deterministic counterpart of Square's buyer-verification flow for a saved
* card. SquareCardInput.tokenizeSavedCardWithVerification delegates to the
* module-level tokenizeSavedCard for the same flow; this instance method is
* available for direct use and honours the challenge simulation toggles. The
* token encodes the card prefix (first 4 chars after `ccof:`), the bound
* amount (pence) and the challenge outcome:
* verify_mock_<prefix>_<amount>_ok|_deny.
*
* @param amount The amount the saved-card charge will be for, in pence.
* @param ccofToken The saved card's ccof: token (the charge source).
*/
export async function verifySavedCard(amount: number, ccofToken: string): Promise<string> {
const outcome = await runChallenge();
const stripped = ccofToken.startsWith('ccof:') ? ccofToken.slice(5) : ccofToken;
const prefix = stripped.slice(0, 4) || 'test';
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
return `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`;
}
</script>
<div class="space-y-3">
@@ -282,4 +368,21 @@
class={inputClasses}
/>
</div>
{#if awaitingChallenge}
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<p class="text-sm font-medium text-amber-800">Waiting for approval in the banking app…</p>
<p class="mt-1 text-xs text-amber-700">
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome
({challengeResult === 'deny' ? 'deny' : 'approve'}).
</p>
<button
type="button"
onclick={resolveMockChallenge}
class="mt-2 rounded-md bg-amber-600 px-3 py-1 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50"
>
Approve in banking app
</button>
</div>
{/if}
</div>
@@ -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">
@@ -1,4 +1,164 @@
<script module lang="ts">
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
/**
* Billing contact passed to Square's tokenize() verificationDetails for
* Strong Customer Authentication (SCA). Only fields we already hold are
* included; omit the object entirely when nothing is available.
*/
export interface SquareVerificationContact {
givenName?: string;
familyName?: string;
email?: string;
}
/** Result of a tokenize-with-verification call. */
export interface TokenizeWithVerificationResult {
nonce: string;
verificationToken: string | null;
}
/** 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. */
export type SavedCardVerificationOutcome =
| 'verified'
| 'challenge-cancelled'
| 'sca-unavailable'
| 'sca-failed';
/** Result of tokenizeSavedCardWithVerification. */
export interface SavedCardVerificationResult {
verificationToken: string | null;
outcome: SavedCardVerificationOutcome;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails {
amount: string;
billingContact?: SquareVerificationContact;
intent: string;
currencyCode: string;
customerInitiated: boolean;
sellerKeyedIn: boolean;
}
/** Square Web Payments `card.tokenize()` result shape (verification path). */
interface SquareTokenizeResult {
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}
/**
* Runs the SCA challenge for a SAVED card (ccof) whose charge Square refused
* with a "verification required" signal. Square's card-on-file flow binds
* buyer verification to the exact charge amount, so the challenge must use
* the same major-units amount as the pending charge.
*
* 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.
*/
export async function tokenizeSavedCardWithVerification(
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
): Promise<SavedCardVerificationResult> {
if (isSquareMock()) {
// DEV-ONLY mock: the mock agent extends MockCardForm with a saved-card
// SCA method. Use it when present (so the mock exercises the same
// challenge path), otherwise fall back to a deterministic fake token
// the backend dev mock accepts.
try {
const mockModule = (await import('./MockCardForm.svelte')) as {
tokenizeSavedCard?: (
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
) => Promise<SavedCardVerificationResult>;
default?: {
tokenizeSavedCard?: (
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
) => Promise<SavedCardVerificationResult>;
};
};
const mockTokenize = mockModule.tokenizeSavedCard ?? mockModule.default?.tokenizeSavedCard;
if (mockTokenize) {
return await mockTokenize(amount, squareCardId, contact);
}
} catch {
// Dynamic import failure → fall through to the deterministic token.
}
const prefix = squareCardId.replace(/^ccof:/, '').slice(0, 4) || 'test';
return {
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`,
outcome: 'verified'
};
}
const payments = (await getSquarePayments()) as {
card: () => Promise<{
tokenize: (
verificationDetails: SquareVerificationDetails,
cardId: string
) => Promise<SquareTokenizeResult>;
}>;
};
const card = await payments.card();
// Same verification-details shape as tokenizeWithVerification: a
// MAJOR-units decimal amount string (W3C valid-decimal-monetary-value)
// bound to the exact pending charge, intent CHARGE (the card is already
// stored — nothing new to save), GBP, customer-initiated, not seller-keyed.
const verificationDetails: SquareVerificationDetails = {
amount: (amount / 100).toFixed(2),
intent: 'CHARGE',
currencyCode: 'GBP',
customerInitiated: true,
sellerKeyedIn: false
};
if (contact && (contact.givenName || contact.familyName || contact.email)) {
verificationDetails.billingContact = contact;
}
let result: SquareTokenizeResult;
try {
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.
console.error('Saved-card SCA tokenization failed:', err);
return { verificationToken: null, outcome: 'sca-unavailable' };
}
if (result.status === 'OK' && result.verificationResult?.token) {
return { verificationToken: result.verificationResult.token, outcome: 'verified' };
}
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
const errorText =
codes.join(' ') +
' ' +
(result.errors ?? [])
.map((e) => e.message ?? '')
.join(' ');
// VERIFICATION_CHALLENGE / cancel-coded errors mean the challenge was
// shown but not completed — the buyer can retry, so this is retryable.
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
return { verificationToken: null, outcome: 'challenge-cancelled' };
}
// The card/issuer cannot complete buyer verification at all — SCA is not
// available for this charge, so the surface falls back to the 2FA gate.
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
return { verificationToken: null, outcome: 'sca-unavailable' };
}
return { verificationToken: null, outcome: 'sca-failed' };
}
/**
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
@@ -43,34 +203,6 @@
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import type MockCardForm from './MockCardForm.svelte';
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
/**
* Billing contact passed to Square's tokenize() verificationDetails for
* Strong Customer Authentication (SCA). Only fields we already hold are
* included; omit the object entirely when nothing is available.
*/
export interface SquareVerificationContact {
givenName?: string;
familyName?: string;
email?: string;
}
/** Result of a tokenize-with-verification call. */
export interface TokenizeWithVerificationResult {
nonce: string;
verificationToken: string | null;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails {
amount: string;
billingContact?: SquareVerificationContact;
intent: string;
currencyCode: string;
customerInitiated: boolean;
sellerKeyedIn: boolean;
}
interface Props {
/** Disable the form while a payment is processing. */
@@ -18,11 +18,18 @@
isNonceStale,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.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
@@ -113,9 +120,14 @@
// 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.
let lastSCAOutcome = $state('');
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
});
const isCardValid = $derived(cardSelectionValid);
@@ -313,7 +325,17 @@
if (!response.ok) {
responseStatus = response.status;
const errorText = await response.text();
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
// 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 with the fresh token (same idempotency key).
if (selectedCardId && isVerificationRequiredSignal(responseStatus, errorText)) {
await runTipSCA(amountInPence, selectedCardId);
return;
}
const err = new Error(extractErrorMessage(errorText) || 'Payment failed');
(err as { bodyText?: string }).bodyText = errorText;
throw err;
}
paymentState = 'success';
@@ -332,12 +354,19 @@
} catch (err) {
paymentState = 'error';
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// 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.
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
// 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.
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (
scaVerificationRequired ||
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
) {
errorMessage = 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
@@ -361,6 +390,91 @@
}
}
/**
* Saved-card (ccof) SCA challenge, run when the tip charge came back 402
* with the verification-required signal. 'verified' retries the SAME tip
* with the fresh verification_token and the SAME cached idempotency key
* (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
* pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
* demotes 2FA from backup to the available gate.
*/
async function runTipSCA(amountInPence: number, cardId: string) {
const squareCardId = savedCards.find((c) => c.id === cardId)?.square_card_id;
paymentState = 'processing';
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
paymentState = 'error';
toast.error(VERIFICATION_REQUIRED_MESSAGE);
return;
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountInPence, squareCardId, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
paymentState = 'error';
toast.error(err instanceof Error ? err.message : 'Card verification failed');
return;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
try {
const retryBody: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
card_id: cardId,
verification_token: result.verificationToken,
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
};
const retry = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(retryBody)
})
);
if (!retry.ok) {
const retryText = await retry.text();
const retryMsg = extractErrorMessage(retryText) || 'Payment failed';
if (isTwoFactorVerificationGateFailure(retry.status, retryMsg)) twoFactor.reveal = true;
paymentState = 'error';
toast.error(retryMsg);
return;
}
paymentState = 'success';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Thank you for your tip!');
onSuccess?.();
} catch (err) {
paymentState = 'error';
toast.error(err instanceof Error ? err.message : 'Payment failed');
}
return;
}
twoFactor.reveal = true;
paymentState = 'error';
toast.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."
);
}
function retryPayment() {
paymentState = 'idle';
}
@@ -1,8 +1,11 @@
<!--
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
charges. The backend's requireTwoFactorForCardAccess gate requires the CARD
OWNER's current one-time code on every saved-card charge in an enforced
environment; this input collects it so the charge body carries
charges. With Square 3DS SCA now the PRIMARY authorisation for saved-card
(ccof) charges, this input is the BACKUP path: it shows only when the charge
hits the backend's requireTwoFactorForCardAccess gate and SCA couldn't
authorise (sca-unavailable), or a charge 403/SCA-failure has revealed it. The
backend requires the CARD OWNER's current one-time code on every 2FA-gated
saved-card charge; this input collects it so the charge body carries
`verification_code`. Shared by the customer booking, tip, admin booking and
till saved-card surfaces so the field name, hint copy and the
enabled/not-enabled presentation can't drift between them.
@@ -47,8 +50,7 @@
class="mt-1 font-mono tracking-widest"
/>
<p class="mt-1 text-xs text-gray-500">
A current 2FA verification code is required for this saved-card charge. Ask the customer
for their code, or retrieve it from the server log.
Your bank doesn't support in-app approval — enter the code sent to you / your phone.
</p>
</div>
{:else}
@@ -23,10 +23,17 @@
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
const LOYALTY_DISCOUNT_RATE = 0.1;
@@ -58,9 +65,14 @@
// 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.
let lastSCAOutcome = $state('');
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
});
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
@@ -540,7 +552,20 @@
status = 'idle';
return;
}
throw new Error(extractErrorMessage(errData) || 'Failed to initiate 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 and retry with the fresh token (same idempotency key,
// which stays cached) instead of surfacing a dead-end decline. A
// token-carrying retry is never re-intercepted — the backend skips
// the 2FA gate when a verification_token is present.
if (cardId && !verificationToken && isVerificationRequiredSignal(responseStatus, errData)) {
await runSavedCardSCA(paymentType, amountPence, cardId, confirmOverflowTip);
return;
}
const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
const data = await response.json();
@@ -581,12 +606,16 @@
status = 'error';
overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined';
// 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.
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE;
// A 402 with the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance; the legacy
// saved-card verification check is the fallback for generic 402s.
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
const verificationFailure =
scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, !!cardId);
if (verificationFailure) {
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.
@@ -607,6 +636,69 @@
}
}
/**
* Saved-card (ccof) SCA challenge, run when the charge came back 402 with
* the verification-required signal. Shows the 3DS challenge (customer
* approves in their banking app), then:
* - '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 and
* reveals the code input so the charge can be retried with a code.
*/
async function runSavedCardSCA(
paymentType: string,
amountPence: number,
cardId: string,
confirmOverflowTip: boolean
) {
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
status = 'processing';
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(amountPence, squareCardId, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.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') {
await submitBookingPayment(
paymentType,
amountPence,
cardId,
undefined,
result.verificationToken ?? undefined,
confirmOverflowTip
);
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);
}
// Confirm the overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
// both pre-start and post-start overflows (B12).
+78
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NONCE_STALENESS_MS,
SAVED_CARD_VERIFICATION_MESSAGE,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
campaignDiscountPence,
canSaveCardsForRole,
@@ -12,8 +13,10 @@ import {
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
requestNewTwoFactorCode,
sanitizeDecimalInput,
shouldFallbackTo2FA,
submitPaymentWithRetry
} from './square';
import type * as SquareModule from './square';
@@ -219,6 +222,81 @@ describe('payment failure classification', () => {
expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0);
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
});
it('VERIFICATION_REQUIRED_MESSAGE is non-empty and mentions verification', () => {
expect(VERIFICATION_REQUIRED_MESSAGE.length).toBeGreaterThan(0);
expect(VERIFICATION_REQUIRED_MESSAGE.toLowerCase()).toContain('verification');
});
});
describe('isVerificationRequiredSignal', () => {
it('matches a 402 JSON body carrying the verification_required code', () => {
const body = JSON.stringify({
error: 'Saved card charge requires buyer verification',
code: 'verification_required'
});
expect(isVerificationRequiredSignal(402, body)).toBe(true);
});
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
expect(
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification')
).toBe(true);
});
it('matches the plain "verification required" phrasing', () => {
expect(isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')).toBe(
true
);
});
it('is false for a 402 body with a different code', () => {
expect(isVerificationRequiredSignal(402, JSON.stringify({ error: 'Declined', code: 'card_declined' }))).toBe(
false
);
});
it('is false for a 402 body with only error text and no code', () => {
expect(isVerificationRequiredSignal(402, 'Payment failed')).toBe(false);
});
it('is false for a non-JSON body that does not mention verification', () => {
expect(isVerificationRequiredSignal(402, 'Payment declined')).toBe(false);
});
it('is false for an empty body', () => {
expect(isVerificationRequiredSignal(402, '')).toBe(false);
});
it('is false for any non-402 status even with the code present', () => {
expect(isVerificationRequiredSignal(503, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(isVerificationRequiredSignal(400, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(isVerificationRequiredSignal(200, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
});
it('is false when the code is not an exact match (guards against prefix drift)', () => {
expect(
isVerificationRequiredSignal(402, JSON.stringify({ error: 'x', code: 'verification_required_extra' }))
).toBe(false);
});
});
describe('shouldFallbackTo2FA', () => {
it.each([
['sca-unavailable', true],
['verified', false],
['challenge-cancelled', false],
['sca-failed', false],
['', false]
])('outcome %s → %s', (outcome, expected) => {
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
});
});
describe('isTwoFactorVerificationGateFailure', () => {
+53
View File
@@ -125,6 +125,59 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
}
/**
* 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.
*/
const VERIFICATION_REQUIRED_CODE = 'verification_required';
/**
* True when a charge response is Square's SCA "verification required" signal:
* HTTP 402 with a JSON body `{"error": "...", "code": "verification_required"}`
* (the shape the backend now returns on a saved-card charge Square refuses for
* want of a verification token), OR the raw body text matching the known
* verification-required strings (`CARD_DECLINED_VERIFICATION_REQUIRED` and the
* plain-text "verification required" phrasing, for dev/mock parity where the
* backend mock may not emit the structured code). The caller runs the
* client-side 3DS challenge (tokenizeSavedCardWithVerification) and retries
* with the fresh token instead of surfacing a dead-end decline. Returns false
* for any non-402 status, non-matching text, or non-JSON body.
*/
export function isVerificationRequiredSignal(status: number, bodyText: string): boolean {
if (status !== PAYMENT_DEFINITIVE_STATUS) return false;
const trimmed = bodyText.trim();
if (!trimmed) return false;
if (/verification required|CARD_DECLINED_VERIFICATION_REQUIRED/i.test(trimmed)) return true;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
return parsed?.code === VERIFICATION_REQUIRED_CODE;
} catch {
// Not JSON — cannot be the structured verification-required body
return false;
}
}
/**
* 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).
*/
export function shouldFallbackTo2FA(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable';
}
/** User-facing guidance for a saved-card charge whose issuer requires Strong
* Customer Authentication: the buyer must approve the payment in their banking
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */
export const VERIFICATION_REQUIRED_MESSAGE =
'Your card issuer requires verification. Approve this payment in your banking app.';
/** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the
* buyer must pay with a new card or re-add their card. */
@@ -8,6 +8,10 @@ export type SavedCard = {
exp_year: number;
cardholder_name?: string;
is_default: boolean;
// Square's card-on-file id (`ccof:...`), returned by the payment-methods
// endpoints. Required to run the saved-card SCA challenge
// (tokenizeSavedCardWithVerification).
square_card_id: string;
};
function createSavedCardsStore() {
@@ -24,6 +24,13 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
* card is selected, or a new card is being saved for reuse.
* 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.
* - `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
@@ -36,6 +43,7 @@ export function useTwoFactorCodeForSavedCard(options: {
enabled: () => boolean;
gateActive: () => boolean;
mint?: () => ReturnType<typeof requestNewTwoFactorCode>;
scaAvailable?: () => boolean;
}) {
// Kept populated across retries so an invalid/expired code can be corrected
// without re-typing it.
@@ -48,8 +56,10 @@ export function useTwoFactorCodeForSavedCard(options: {
let requesting = $state(false);
// Show the code input whenever the pending charge hits the backend's 2FA
// gate: charging a saved card OR saving the new card for reuse.
const showInput = $derived(reveal || options.gateActive());
// gate AND SCA isn't available to authorise instead (2FA is the BACKUP, not
// the default), OR a failure has revealed it explicitly.
const scaAvailable = options.scaAvailable ?? (() => true);
const showInput = $derived(reveal || (options.gateActive() && !scaAvailable()));
const missing = $derived(showInput && options.enabled() && code.trim() === '');
async function requestNewCode() {