feat: proactive saved-card SCA — challenge runs BEFORE the first charge, never a naked ccof attempt
Square's card.tokenize(verificationDetails, squareCardId) determines the SCA requirement UP FRONT and returns a fresh verification_token (or an explicit outcome), so the customer-initiated saved-card flow now runs it before the first charge attempt instead of the reactive 'attempt naked ccof -> 402 verification_required -> challenge + retry' round-trip. - UserPaymentModal/TipPayment/BookingFlow/account gift-card buy: call runSavedCardSCAProactively before charging; 'verified' carries the token on attempt #1; 'sca-unavailable' demotes to the 2FA gate (the only tokenless path); 'challenge-cancelled'/'sca-failed' never charge and keep the pending row retryable with the same cached idempotency key - The reactive re-challenge hook is removed; a defensive verification-required 402 (stale/consumed token) surfaces VERIFICATION_REQUIRED_MESSAGE and lets the user retry - Admin PaymentModal + till saved-card charges remain MERCHANT-INITIATED (customer_initiated=false, SCA-exempt, no liability shift) — unchanged - square.ts comments updated (saved-card charges now carry a token proactively; SAVED_CARD_VERIFICATION_MESSAGE is the defensive path) - Tests: 98 frontend tests (proactive decision coverage); build clean
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationOutcome,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
|
||||
@@ -479,6 +480,25 @@
|
||||
payKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
// Proactive saved-card (ccof) SCA: run the client-side challenge BEFORE
|
||||
// the first charge attempt so it carries a fresh verification_token —
|
||||
// a naked ccof is never sent to the backend. Only 'sca-unavailable'
|
||||
// proceeds token-less (the 2FA gate is the fallback); a cancelled/failed
|
||||
// challenge does NOT charge — the user taps Pay again to re-run it, and
|
||||
// the cached idempotency key above is never regenerated across the
|
||||
// challenge-then-charge.
|
||||
if (cardId && !verificationToken) {
|
||||
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
|
||||
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
|
||||
status = 'error';
|
||||
error =
|
||||
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
|
||||
}
|
||||
|
||||
await submitBookingPayment(
|
||||
paymentType,
|
||||
amountPence,
|
||||
@@ -552,17 +572,14 @@
|
||||
status = 'idle';
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// Defensive fallback: a `verification_required` 402 on a
|
||||
// saved-card charge should no longer happen — the first attempt
|
||||
// always carried a proactive verification_token or demoted to
|
||||
// 2FA (sca-unavailable). If it still occurs (e.g. a stale token
|
||||
// got consumed/expired between tokenize and charge), surface the
|
||||
// guidance and let the user retry — never re-run SCA silently
|
||||
// mid-flow. The catch below maps this signal to
|
||||
// VERIFICATION_REQUIRED_MESSAGE.
|
||||
const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
||||
(err as { bodyText?: string }).bodyText = errData;
|
||||
throw err;
|
||||
@@ -637,31 +654,26 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
|
||||
* attempt (never after a 402). Square's card.tokenize(verificationDetails,
|
||||
* squareCardId) determines UP FRONT whether buyer verification is required
|
||||
* and returns a fresh verification_token bound to the exact amount:
|
||||
* - 'verified' → the caller charges with the returned token (the first
|
||||
* attempt carries it — a naked ccof is never sent);
|
||||
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
|
||||
* to the only available gate and the caller proceeds WITHOUT a token;
|
||||
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge:
|
||||
* the pending row stays retryable and the user taps Pay again to re-run
|
||||
* the challenge.
|
||||
*/
|
||||
async function runSavedCardSCA(
|
||||
paymentType: string,
|
||||
async function runSavedCardSCAProactively(
|
||||
amountPence: number,
|
||||
cardId: string,
|
||||
confirmOverflowTip: boolean
|
||||
) {
|
||||
cardId: string
|
||||
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
|
||||
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;
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
@@ -672,31 +684,10 @@
|
||||
});
|
||||
} catch (_err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
status = 'error';
|
||||
error = _err instanceof Error ? _err.message : 'Card verification failed';
|
||||
toast.error(error);
|
||||
return;
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
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);
|
||||
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||
}
|
||||
|
||||
// Confirm the overpayment: resend the SAME rejected request with
|
||||
|
||||
Reference in New Issue
Block a user