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