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';
|
} from '$lib/square/square';
|
||||||
import {
|
import {
|
||||||
tokenizeSavedCardWithVerification,
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationOutcome,
|
||||||
type SavedCardVerificationResult
|
type SavedCardVerificationResult
|
||||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||||
@@ -439,6 +440,25 @@
|
|||||||
depositKeyedCard = cardKey;
|
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> = {
|
const body: Record<string, unknown> = {
|
||||||
payment_type: 'deposit',
|
payment_type: 'deposit',
|
||||||
amount: amountPence,
|
amount: amountPence,
|
||||||
@@ -535,18 +555,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
// Saved-card (ccof) SCA: the backend returns 402 + `verification_required`
|
// Defensive fallback: a `verification_required` 402 on a saved-card
|
||||||
// when Square requires buyer verification and no verification_token was
|
// deposit should no longer happen — the first attempt carried a proactive
|
||||||
// supplied. Run the client-side 3DS challenge and retry with the fresh
|
// verification_token or demoted to 2FA (sca-unavailable). If it still
|
||||||
// token — the SAME body and idempotency key (never regenerated here). A
|
// occurs (e.g. a stale token was consumed/expired between tokenize and
|
||||||
// token-carrying retry is never re-intercepted (the backend skips the 2FA
|
// charge), surface the guidance and let the user retry — never re-run SCA
|
||||||
// gate when a verification_token is present).
|
// silently mid-flow.
|
||||||
if (
|
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
|
||||||
selectedPaymentMethod &&
|
toast.warning(VERIFICATION_REQUIRED_MESSAGE);
|
||||||
!body.verification_token &&
|
|
||||||
isVerificationRequiredSignal(response.status, text)
|
|
||||||
) {
|
|
||||||
await runDepositSCA({ body, amountPence, depositAmount, confirmOverflowTip });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
// 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
|
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first deposit
|
||||||
* with the verification-required signal. 'verified' retries the SAME deposit
|
* charge attempt (never after a 402). Square's tokenize(verificationDetails,
|
||||||
* body with the fresh verification_token and the SAME cached idempotency key
|
* squareCardId) determines UP FRONT whether buyer verification is required
|
||||||
* (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
|
* and returns a fresh verification_token bound to the exact amount:
|
||||||
* pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
|
* - 'verified' → the caller charges with the returned token (the first
|
||||||
* demotes 2FA from backup to the available gate.
|
* 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: {
|
async function runDepositSCAProactively(
|
||||||
body: Record<string, unknown>;
|
amountPence: number
|
||||||
amountPence: number;
|
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
|
||||||
depositAmount: number;
|
|
||||||
confirmOverflowTip: boolean;
|
|
||||||
}) {
|
|
||||||
const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id;
|
const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id;
|
||||||
if (!squareCardId) {
|
if (!squareCardId) {
|
||||||
lastSCAOutcome = 'sca-unavailable';
|
lastSCAOutcome = 'sca-unavailable';
|
||||||
twoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
toast.error(VERIFICATION_REQUIRED_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let result: SavedCardVerificationResult;
|
let result: SavedCardVerificationResult;
|
||||||
try {
|
try {
|
||||||
result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, {
|
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
|
||||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||||
email: customerInfo.email || authStore.currentUser?.email
|
email: customerInfo.email || authStore.currentUser?.email
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
lastSCAOutcome = 'sca-unavailable';
|
lastSCAOutcome = 'sca-unavailable';
|
||||||
twoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
lastSCAOutcome = result.outcome;
|
lastSCAOutcome = result.outcome;
|
||||||
if (result.outcome === 'verified') {
|
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||||
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);
|
let paymentAttempted = $state(false);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||||
import {
|
import {
|
||||||
tokenizeSavedCardWithVerification,
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationOutcome,
|
||||||
type SavedCardVerificationResult
|
type SavedCardVerificationResult
|
||||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
|
|
||||||
@@ -298,14 +299,32 @@
|
|||||||
// rationale as the booking/account flows). Include the card so a
|
// rationale as the booking/account flows). Include the card so a
|
||||||
// same-amount tip on a DIFFERENT card gets a fresh key instead of
|
// same-amount tip on a DIFFERENT card gets a fresh key instead of
|
||||||
// deduping against the previous card's charge.
|
// deduping against the previous card's charge.
|
||||||
const cardKey = selectedCardId || 'new-card';
|
const cardKey = selectedCardId || 'new-card';
|
||||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||||
tipIdempotencyKey = generateUUID();
|
tipIdempotencyKey = generateUUID();
|
||||||
tipKeyedAmount = tipAmount;
|
tipKeyedAmount = tipAmount;
|
||||||
tipKeyedCard = cardKey;
|
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);
|
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
|
||||||
const body: Record<string, unknown> = {
|
}
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
amount: amountInPence,
|
amount: amountInPence,
|
||||||
idempotency_key: tipIdempotencyKey,
|
idempotency_key: tipIdempotencyKey,
|
||||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||||
@@ -325,14 +344,13 @@
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
responseStatus = response.status;
|
responseStatus = response.status;
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
// Saved-card (ccof) SCA: the backend returns 402 +
|
// Defensive fallback: a `verification_required` 402 on a
|
||||||
// `verification_required` when Square requires buyer verification
|
// saved-card tip should no longer happen — the first attempt
|
||||||
// and no verification_token was supplied. Run the client-side 3DS
|
// carried a proactive verification_token or demoted to 2FA. If
|
||||||
// challenge and retry with the fresh token (same idempotency key).
|
// it still occurs (e.g. a stale token was consumed between
|
||||||
if (selectedCardId && isVerificationRequiredSignal(responseStatus, errorText)) {
|
// tokenize and charge), surface the guidance and let the user
|
||||||
await runTipSCA(amountInPence, selectedCardId);
|
// retry — never re-run SCA silently mid-flow (the catch below
|
||||||
return;
|
// maps the signal to VERIFICATION_REQUIRED_MESSAGE).
|
||||||
}
|
|
||||||
const err = new Error(extractErrorMessage(errorText) || 'Payment failed');
|
const err = new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||||
(err as { bodyText?: string }).bodyText = errorText;
|
(err as { bodyText?: string }).bodyText = errorText;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -391,22 +409,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved-card (ccof) SCA challenge, run when the tip charge came back 402
|
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first tip
|
||||||
* with the verification-required signal. 'verified' retries the SAME tip
|
* charge attempt (never after a 402). Square's tokenize(verificationDetails,
|
||||||
* with the fresh verification_token and the SAME cached idempotency key
|
* squareCardId) determines UP FRONT whether buyer verification is required
|
||||||
* (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
|
* and returns a fresh verification_token bound to the exact amount:
|
||||||
* pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
|
* - 'verified' → the caller charges with the returned token (the first
|
||||||
* demotes 2FA from backup to the available gate.
|
* 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;
|
const squareCardId = savedCards.find((c) => c.id === cardId)?.square_card_id;
|
||||||
paymentState = 'processing';
|
|
||||||
if (!squareCardId) {
|
if (!squareCardId) {
|
||||||
lastSCAOutcome = 'sca-unavailable';
|
lastSCAOutcome = 'sca-unavailable';
|
||||||
twoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
paymentState = 'error';
|
|
||||||
toast.error(VERIFICATION_REQUIRED_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let result: SavedCardVerificationResult;
|
let result: SavedCardVerificationResult;
|
||||||
try {
|
try {
|
||||||
@@ -415,64 +437,12 @@
|
|||||||
familyName: authStore.currentUser?.lastName,
|
familyName: authStore.currentUser?.lastName,
|
||||||
email: authStore.currentUser?.email
|
email: authStore.currentUser?.email
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
lastSCAOutcome = 'sca-unavailable';
|
lastSCAOutcome = 'sca-unavailable';
|
||||||
twoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
paymentState = 'error';
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
lastSCAOutcome = result.outcome;
|
lastSCAOutcome = result.outcome;
|
||||||
if (result.outcome === 'verified') {
|
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||||
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() {
|
function retryPayment() {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
import {
|
import {
|
||||||
tokenizeSavedCardWithVerification,
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationOutcome,
|
||||||
type SavedCardVerificationResult
|
type SavedCardVerificationResult
|
||||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
|
|
||||||
@@ -479,6 +480,25 @@
|
|||||||
payKeyedCard = cardKey;
|
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(
|
await submitBookingPayment(
|
||||||
paymentType,
|
paymentType,
|
||||||
amountPence,
|
amountPence,
|
||||||
@@ -552,17 +572,14 @@
|
|||||||
status = 'idle';
|
status = 'idle';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Saved-card (ccof) SCA: the backend returns 402 +
|
// Defensive fallback: a `verification_required` 402 on a
|
||||||
// `verification_required` when Square requires buyer verification
|
// saved-card charge should no longer happen — the first attempt
|
||||||
// and no verification_token was supplied. Run the client-side 3DS
|
// always carried a proactive verification_token or demoted to
|
||||||
// challenge and retry with the fresh token (same idempotency key,
|
// 2FA (sca-unavailable). If it still occurs (e.g. a stale token
|
||||||
// which stays cached) instead of surfacing a dead-end decline. A
|
// got consumed/expired between tokenize and charge), surface the
|
||||||
// token-carrying retry is never re-intercepted — the backend skips
|
// guidance and let the user retry — never re-run SCA silently
|
||||||
// the 2FA gate when a verification_token is present.
|
// mid-flow. The catch below maps this signal to
|
||||||
if (cardId && !verificationToken && isVerificationRequiredSignal(responseStatus, errData)) {
|
// VERIFICATION_REQUIRED_MESSAGE.
|
||||||
await runSavedCardSCA(paymentType, amountPence, cardId, confirmOverflowTip);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
||||||
(err as { bodyText?: string }).bodyText = errData;
|
(err as { bodyText?: string }).bodyText = errData;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -637,31 +654,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved-card (ccof) SCA challenge, run when the charge came back 402 with
|
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
|
||||||
* the verification-required signal. Shows the 3DS challenge (customer
|
* attempt (never after a 402). Square's card.tokenize(verificationDetails,
|
||||||
* approves in their banking app), then:
|
* squareCardId) determines UP FRONT whether buyer verification is required
|
||||||
* - 'verified' → retries the SAME charge with the fresh verification_token
|
* and returns a fresh verification_token bound to the exact amount:
|
||||||
* and the SAME cached idempotency key (never regenerated here);
|
* - 'verified' → the caller charges with the returned token (the first
|
||||||
* - 'challenge-cancelled' / 'sca-failed' → leaves the pending row retryable
|
* attempt carries it — a naked ccof is never sent);
|
||||||
* (the idempotency key stays cached) and reveals the 2FA-fallback input;
|
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
|
||||||
* - 'sca-unavailable' → demotes 2FA from backup to the available gate and
|
* to the only available gate and the caller proceeds WITHOUT a token;
|
||||||
* reveals the code input so the charge can be retried with a code.
|
* - '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(
|
async function runSavedCardSCAProactively(
|
||||||
paymentType: string,
|
|
||||||
amountPence: number,
|
amountPence: number,
|
||||||
cardId: string,
|
cardId: string
|
||||||
confirmOverflowTip: boolean
|
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
|
||||||
) {
|
|
||||||
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
|
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
|
||||||
status = 'processing';
|
|
||||||
if (!squareCardId) {
|
if (!squareCardId) {
|
||||||
lastSCAOutcome = 'sca-unavailable';
|
lastSCAOutcome = 'sca-unavailable';
|
||||||
twoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
status = 'error';
|
|
||||||
error = VERIFICATION_REQUIRED_MESSAGE;
|
|
||||||
toast.error(error);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let result: SavedCardVerificationResult;
|
let result: SavedCardVerificationResult;
|
||||||
try {
|
try {
|
||||||
@@ -672,31 +684,10 @@
|
|||||||
});
|
});
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
lastSCAOutcome = 'sca-unavailable';
|
lastSCAOutcome = 'sca-unavailable';
|
||||||
twoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
status = 'error';
|
|
||||||
error = _err instanceof Error ? _err.message : 'Card verification failed';
|
|
||||||
toast.error(error);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
lastSCAOutcome = result.outcome;
|
lastSCAOutcome = result.outcome;
|
||||||
if (result.outcome === 'verified') {
|
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||||
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 the overpayment: resend the SAME rejected request with
|
||||||
|
|||||||
@@ -297,6 +297,18 @@ describe('shouldFallbackTo2FA', () => {
|
|||||||
])('outcome %s → %s', (outcome, expected) => {
|
])('outcome %s → %s', (outcome, expected) => {
|
||||||
expect(shouldFallbackTo2FA(outcome)).toBe(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', () => {
|
describe('isTwoFactorVerificationGateFailure', () => {
|
||||||
|
|||||||
@@ -111,15 +111,23 @@ const PAYMENT_DEFINITIVE_STATUS = 402;
|
|||||||
/**
|
/**
|
||||||
* True when a definitive (402) charge failure on a SAVED CARD should be
|
* 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.
|
* 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
|
* CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the
|
||||||
* response body is the generic "Payment failed" text with no distinguishing
|
* response body is the generic "Payment failed" text with no distinguishing
|
||||||
* code. Saved cards skip the client-side tokenizeWithVerification SCA step, so
|
* code. Retrying the same saved card can never succeed, and the buyer must pay
|
||||||
* a 402 on the saved-card path means the issuer still requires verification:
|
* with a freshly tokenized card, re-add theirs, or re-run the SCA challenge.
|
||||||
* retrying the same saved card can never succeed, and the buyer must pay with
|
* New-card (cnon) charges carry their own SCA verification token, so they are
|
||||||
* a freshly tokenized card or re-add theirs. New-card (cnon) charges carry
|
* never classified this way.
|
||||||
* their own SCA verification token, so they are never classified this way.
|
|
||||||
*/
|
*/
|
||||||
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
|
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
|
||||||
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
|
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
import {
|
import {
|
||||||
tokenizeSavedCardWithVerification,
|
tokenizeSavedCardWithVerification,
|
||||||
|
type SavedCardVerificationOutcome,
|
||||||
type SavedCardVerificationResult
|
type SavedCardVerificationResult
|
||||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
@@ -479,6 +480,24 @@
|
|||||||
buyKeyedCard = cardKey;
|
buyKeyedCard = cardKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proactive saved-card (ccof) SCA: run the client-side challenge
|
||||||
|
// BEFORE the first buy 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 Buy 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 runBuySCAProactively();
|
||||||
|
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 res = await submitPaymentWithRetry(() =>
|
const res = await submitPaymentWithRetry(() =>
|
||||||
apiFetch('/api/user/giftcards/buy', {
|
apiFetch('/api/user/giftcards/buy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -515,18 +534,19 @@
|
|||||||
buyTwoFactor.reveal = false;
|
buyTwoFactor.reveal = false;
|
||||||
await fetchGiftCardBalance();
|
await fetchGiftCardBalance();
|
||||||
} else {
|
} else {
|
||||||
// Capture the status BEFORE consuming the body — the saved-card
|
// Capture the status BEFORE consuming the body — the
|
||||||
// SCA check needs it, and text() can only be read once.
|
// saved-card SCA classification below needs it, and text()
|
||||||
|
// can only be read once.
|
||||||
const status = res.status;
|
const status = res.status;
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
// Saved-card (ccof) SCA: the backend returns 402 +
|
// Defensive fallback: a `verification_required` 402 on a
|
||||||
// `verification_required` when Square requires buyer verification
|
// saved-card buy should no longer happen — the first attempt
|
||||||
// and no verification_token was supplied. Run the client-side 3DS
|
// carried a proactive verification_token or demoted to 2FA. If
|
||||||
// challenge and retry with the fresh token (same idempotency key).
|
// it still occurs (e.g. a stale token was consumed between
|
||||||
if (buySelectedCard && isVerificationRequiredSignal(status, errText)) {
|
// tokenize and charge), surface the guidance and let the user
|
||||||
await runBuySavedCardSCA();
|
// retry — never re-run SCA silently mid-flow (the
|
||||||
return;
|
// classification below maps the signal to
|
||||||
}
|
// VERIFICATION_REQUIRED_MESSAGE).
|
||||||
// A definitive 402 on the saved-card path means the issuer still
|
// A definitive 402 on the saved-card path means the issuer still
|
||||||
// requires verification. A structured verification-required signal
|
// requires verification. A structured verification-required signal
|
||||||
// surfaces the SCA-first guidance; the legacy saved-card check is
|
// surfaces the SCA-first guidance; the legacy saved-card check is
|
||||||
@@ -574,20 +594,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved-card (ccof) SCA challenge, run when the gift-card buy came back 402
|
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first
|
||||||
* with the verification-required signal. 'verified' retries the SAME buy
|
* gift-card buy attempt (never after a 402). Square's
|
||||||
* with the fresh verification_token and the SAME cached idempotency key
|
* tokenize(verificationDetails, squareCardId) determines UP FRONT whether
|
||||||
* (never regenerated here); 'challenge-cancelled' / 'sca-failed' keep the
|
* buyer verification is required and returns a fresh verification_token
|
||||||
* pending row retryable and reveal the 2FA-fallback input; 'sca-unavailable'
|
* bound to the exact amount:
|
||||||
* demotes 2FA from backup to the available gate.
|
* - '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 Buy again to re-run the
|
||||||
|
* challenge.
|
||||||
*/
|
*/
|
||||||
async function runBuySavedCardSCA() {
|
async function runBuySCAProactively(): Promise<{
|
||||||
|
outcome: SavedCardVerificationOutcome;
|
||||||
|
verificationToken?: string;
|
||||||
|
}> {
|
||||||
const squareCardId = savedCardsStore.cards.find((c) => c.id === buySelectedCard)?.square_card_id;
|
const squareCardId = savedCardsStore.cards.find((c) => c.id === buySelectedCard)?.square_card_id;
|
||||||
if (!squareCardId) {
|
if (!squareCardId) {
|
||||||
buyLastSCAOutcome = 'sca-unavailable';
|
buyLastSCAOutcome = 'sca-unavailable';
|
||||||
buyTwoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
toast.error(VERIFICATION_REQUIRED_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let result: SavedCardVerificationResult;
|
let result: SavedCardVerificationResult;
|
||||||
try {
|
try {
|
||||||
@@ -596,62 +623,12 @@
|
|||||||
familyName: userData?.lastName,
|
familyName: userData?.lastName,
|
||||||
email: userData?.email
|
email: userData?.email
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
buyLastSCAOutcome = 'sca-unavailable';
|
buyLastSCAOutcome = 'sca-unavailable';
|
||||||
buyTwoFactor.reveal = true;
|
return { outcome: 'sca-unavailable' };
|
||||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
buyLastSCAOutcome = result.outcome;
|
buyLastSCAOutcome = result.outcome;
|
||||||
if (result.outcome === 'verified') {
|
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
|
||||||
try {
|
|
||||||
const retry = await submitPaymentWithRetry(() =>
|
|
||||||
apiFetch('/api/user/giftcards/buy', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
amount: buyAmount * 100,
|
|
||||||
recipient_type: buyRecipientType,
|
|
||||||
recipient_email: buyRecipientEmail,
|
|
||||||
...(buySelectedCard ? { card_id: buySelectedCard } : {}),
|
|
||||||
verification_token: result.verificationToken,
|
|
||||||
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
|
||||||
idempotency_key: buyIdempotencyKey
|
|
||||||
})
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (retry.ok) {
|
|
||||||
const data = await retry.json();
|
|
||||||
toast.success('Gift card purchased successfully!');
|
|
||||||
purchaseResultCode = data.code;
|
|
||||||
buyDailyTotal += buyAmount;
|
|
||||||
buyIdempotencyKey = '';
|
|
||||||
buyKeyedAmount = 0;
|
|
||||||
buyKeyedCard = '';
|
|
||||||
buyNonce = '';
|
|
||||||
buyVerificationToken = '';
|
|
||||||
buyTokenAmount = 0;
|
|
||||||
buyTokenizedAt = 0;
|
|
||||||
buyTokenizedForSaveCard = false;
|
|
||||||
buyTwoFactor.setCode('');
|
|
||||||
buyTwoFactor.reveal = false;
|
|
||||||
await fetchGiftCardBalance();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const retryText = await retry.text();
|
|
||||||
toast.error(extractErrorMessage(retryText) || 'Failed to purchase gift card');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('gift card SCA retry error:', err);
|
|
||||||
toast.error('Network error');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buyTwoFactor.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."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAndPreserveCursor(
|
function formatAndPreserveCursor(
|
||||||
|
|||||||
Reference in New Issue
Block a user