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:
@@ -53,6 +53,7 @@
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationOutcome,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
@@ -439,6 +440,25 @@
|
||||
depositKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
// Proactive saved-card (ccof) SCA: run the client-side challenge
|
||||
// BEFORE the first deposit charge attempt so it carries a fresh
|
||||
// verification_token — a naked ccof is never sent. Only
|
||||
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
|
||||
// fallback); a cancelled/failed challenge does NOT charge — the
|
||||
// deposit step stays retryable and the user taps Pay again, reusing
|
||||
// the cached idempotency key above (never regenerated across the
|
||||
// challenge-then-charge).
|
||||
if (selectedPaymentMethod && !verificationToken) {
|
||||
const proactive = await runDepositSCAProactively(amountPence);
|
||||
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
|
||||
toast.error(
|
||||
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
payment_type: 'deposit',
|
||||
amount: amountPence,
|
||||
@@ -535,18 +555,14 @@
|
||||
}
|
||||
|
||||
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 });
|
||||
// Defensive fallback: a `verification_required` 402 on a saved-card
|
||||
// deposit should no longer happen — the first attempt carried a proactive
|
||||
// verification_token or demoted to 2FA (sca-unavailable). If it still
|
||||
// occurs (e.g. a stale token was consumed/expired between tokenize and
|
||||
// charge), surface the guidance and let the user retry — never re-run SCA
|
||||
// silently mid-flow.
|
||||
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
|
||||
toast.warning(VERIFICATION_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||
@@ -640,53 +656,39 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first deposit
|
||||
* charge attempt (never after a 402). Square's 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
|
||||
* deposit step stays retryable and the user taps Pay again to re-run the
|
||||
* challenge.
|
||||
*/
|
||||
async function runDepositSCA(options: {
|
||||
body: Record<string, unknown>;
|
||||
amountPence: number;
|
||||
depositAmount: number;
|
||||
confirmOverflowTip: boolean;
|
||||
}) {
|
||||
async function runDepositSCAProactively(
|
||||
amountPence: number
|
||||
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
|
||||
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;
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, {
|
||||
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
|
||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||
email: customerInfo.email || authStore.currentUser?.email
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
||||
return;
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
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."
|
||||
);
|
||||
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||
}
|
||||
|
||||
let paymentAttempted = $state(false);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationOutcome,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
|
||||
@@ -298,14 +299,32 @@
|
||||
// rationale as the booking/account flows). Include the card so a
|
||||
// same-amount tip on a DIFFERENT card gets a fresh key instead of
|
||||
// deduping against the previous card's charge.
|
||||
const cardKey = selectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = generateUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
const cardKey = selectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = generateUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
}
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
// Proactive saved-card (ccof) SCA: run the client-side challenge
|
||||
// BEFORE the first tip charge attempt so it carries a fresh
|
||||
// verification_token — a naked ccof is never sent. Only
|
||||
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
|
||||
// fallback); a cancelled/failed challenge does NOT charge — the user
|
||||
// taps Pay Tip again to re-run it, and the cached idempotency key
|
||||
// above is never regenerated across the challenge-then-charge.
|
||||
if (selectedCardId && !verificationToken) {
|
||||
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
|
||||
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
|
||||
paymentState = 'error';
|
||||
toast.error(
|
||||
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
const body: Record<string, unknown> = {
|
||||
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
@@ -325,14 +344,13 @@
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errorText = 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 (same idempotency key).
|
||||
if (selectedCardId && isVerificationRequiredSignal(responseStatus, errorText)) {
|
||||
await runTipSCA(amountInPence, selectedCardId);
|
||||
return;
|
||||
}
|
||||
// Defensive fallback: a `verification_required` 402 on a
|
||||
// saved-card tip should no longer happen — the first attempt
|
||||
// carried a proactive verification_token or demoted to 2FA. If
|
||||
// it still occurs (e.g. a stale token was consumed between
|
||||
// tokenize and charge), surface the guidance and let the user
|
||||
// retry — never re-run SCA silently mid-flow (the catch below
|
||||
// maps the signal to VERIFICATION_REQUIRED_MESSAGE).
|
||||
const err = new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
(err as { bodyText?: string }).bodyText = errorText;
|
||||
throw err;
|
||||
@@ -391,22 +409,26 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first tip
|
||||
* charge attempt (never after a 402). Square's 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 Tip again to re-run
|
||||
* the challenge.
|
||||
*/
|
||||
async function runTipSCA(amountInPence: number, cardId: string) {
|
||||
async function runTipSCAProactively(
|
||||
amountInPence: number,
|
||||
cardId: string
|
||||
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: 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;
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
@@ -415,64 +437,12 @@
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
paymentState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
||||
return;
|
||||
return { outcome: 'sca-unavailable' };
|
||||
}
|
||||
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."
|
||||
);
|
||||
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||
}
|
||||
|
||||
function retryPayment() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,6 +297,18 @@ describe('shouldFallbackTo2FA', () => {
|
||||
])('outcome %s → %s', (outcome, expected) => {
|
||||
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
|
||||
});
|
||||
|
||||
it('demotes to the 2FA gate only on sca-unavailable', () => {
|
||||
expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps SCA primary after a successful verification', () => {
|
||||
expect(shouldFallbackTo2FA('verified')).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => {
|
||||
expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTwoFactorVerificationGateFailure', () => {
|
||||
|
||||
@@ -111,15 +111,23 @@ const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
/**
|
||||
* True when a definitive (402) charge failure on a SAVED CARD should be
|
||||
* surfaced as a card-issuer verification problem rather than a plain decline.
|
||||
* The backend sets customer_details.customer_initiated=true on saved-card
|
||||
* (ccof) charges and classifies issuer-verification rejections — Square's
|
||||
*
|
||||
* This is the DEFENSIVE/unexpected path: customer-initiated saved-card (ccof)
|
||||
* surfaces now run the client-side SCA challenge PROACTIVELY before the first
|
||||
* charge attempt (tokenizeSavedCardWithVerification) and carry a fresh
|
||||
* `verification_token` on the charge — or have demoted to the 2FA gate when
|
||||
* SCA is unavailable — so a naked ccof charge should never reach Square. A
|
||||
* 402 here therefore means the verification token was consumed/expired
|
||||
* between tokenize and charge (or a config drift), and the buyer should be
|
||||
* pointed at the retry affordance rather than silently re-challenged. The
|
||||
* backend sets customer_details.customer_initiated=true on saved-card (ccof)
|
||||
* charges and classifies issuer-verification rejections — Square's
|
||||
* CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the
|
||||
* response body is the generic "Payment failed" text with no distinguishing
|
||||
* code. Saved cards skip the client-side tokenizeWithVerification SCA step, so
|
||||
* a 402 on the saved-card path means the issuer still requires verification:
|
||||
* retrying the same saved card can never succeed, and the buyer must pay with
|
||||
* a freshly tokenized card or re-add theirs. New-card (cnon) charges carry
|
||||
* their own SCA verification token, so they are never classified this way.
|
||||
* code. Retrying the same saved card can never succeed, and the buyer must pay
|
||||
* with a freshly tokenized card, re-add theirs, or re-run the SCA challenge.
|
||||
* New-card (cnon) charges carry their own SCA verification token, so they are
|
||||
* never classified this way.
|
||||
*/
|
||||
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
|
||||
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
|
||||
|
||||
Reference in New Issue
Block a user