feat: frontend SCA-only posture — tokenize-result as charge source (C1), C6 refusal dialog, no 2FA fallback
- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine 'sca-unavailable' now drives the REFUSAL path (the customer is told the payment cannot complete and to pay online later), never the 2FA code fallback (PSR 2017 SCA is non-waivable; merchant liability is not cured by consent). SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1' + scaFallbackConsentFields() carry the versioned consent on the explicit opt-in path only (shipped surfaces send none). SquareTokenizeResult docs updated: tokenize-result token is the charge source, tokenless OK proceeds token-less under the backend's SCA-only gate. - C1 wire contract on every saved-card surface (booking, tip, gift-card buy, till, account): the proactive SCA tokenize-result is sent as new_card_token (the charge SOURCE alongside the saved-card ref), never the legacy verification_token; 402 verification-required now means the tokenize-result was consumed/expired between tokenize and charge. - New ScaFallbackConsentDialog surfaces the refusal notice; the code input (useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via a backend gate rejection (defensive/opt-in). - Till (M10): proactive saved-card SCA runs per sale line BEFORE the first charge; sca-unavailable aborts the whole sale before any charge. - Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization; 402 verification-required on save surfaces SCA-first guidance instead of a generic failure.
This commit is contained in:
@@ -123,19 +123,19 @@ const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
* 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. 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.
|
||||
* tokenize-result token as `new_card_token` on the charge — or refuse when SCA
|
||||
* is unavailable (C6) — so a naked ccof charge should never
|
||||
* reach Square. A 402 here therefore means the SCA tokenize-result 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. 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;
|
||||
@@ -143,11 +143,12 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* charge requires Strong Customer Authentication and no SCA tokenize-result
|
||||
* token (`new_card_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';
|
||||
|
||||
@@ -179,18 +180,22 @@ export function isVerificationRequiredSignal(status: number, bodyText: string):
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* unavailable (no 3DS challenge could be run). The C6 legal verdict (PSR 2017
|
||||
* SCA is non-waivable; the merchant is liable regardless of consent) made the
|
||||
* homegrown 2FA code gate unlawful as an SCA fallback for saved-card charges,
|
||||
* so this now drives the REFUSAL path: the charge cannot complete and the
|
||||
* customer is told to pay online later — never offered the verification-code
|
||||
* fallback. 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 {
|
||||
export function shouldShowSCARefusal(scaOutcome: string): boolean {
|
||||
return scaOutcome === 'sca-unavailable';
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* retryable failure, or refuse the charge (SCA-only posture). */
|
||||
export type SavedCardVerificationOutcome =
|
||||
'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
|
||||
|
||||
@@ -220,12 +225,13 @@ export interface SquareTokenizeResult {
|
||||
* - `status === 'OK'` means buyer verification either completed or was NOT
|
||||
* required by the issuer — the charge may proceed. The verification-aware
|
||||
* token (when present) is the `token` field; a tokenless OK means no SCA was
|
||||
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square
|
||||
* risk rules are the fallback), never a dead-end.
|
||||
* demanded, so the charge proceeds token-less and the backend's SCA-only
|
||||
* verification-required gate is the arbiter, never a silent 2FA fallback.
|
||||
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
|
||||
* shown but not completed — the buyer can retry, so this is retryable.
|
||||
* - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run — SCA
|
||||
* is unavailable and the surface falls back to the 2FA gate.
|
||||
* is unavailable and the surface refuses the charge (C6, see
|
||||
* shouldShowSCARefusal).
|
||||
* - anything else is a hard SCA failure.
|
||||
*/
|
||||
export function parseTokenizeVerificationResult(
|
||||
@@ -276,7 +282,7 @@ interface SquareVerificationDetails {
|
||||
* 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.
|
||||
* 'sca-unavailable' → no challenge could run, refuse the charge (C6).
|
||||
*/
|
||||
export async function tokenizeSavedCardWithVerification(
|
||||
amount: number,
|
||||
@@ -347,7 +353,7 @@ export async function tokenizeSavedCardWithVerification(
|
||||
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.
|
||||
// run — SCA is unavailable for this charge and the surface refuses.
|
||||
console.error('Saved-card SCA tokenization failed:', err);
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
@@ -357,7 +363,7 @@ export async function tokenizeSavedCardWithVerification(
|
||||
// in the current SDK — never a nested verificationResult, which only
|
||||
// exists on the deprecated verifyBuyer() flow), tokenless when the issuer
|
||||
// demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback.
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED → sca-unavailable (C6 refusal).
|
||||
return parseTokenizeVerificationResult(result);
|
||||
}
|
||||
|
||||
@@ -370,7 +376,7 @@ export interface RunSavedCardSCAOptions {
|
||||
/** Billing contact passed to Square's verificationDetails (optional). */
|
||||
buyer?: SquareVerificationContact;
|
||||
/** Records the challenge outcome on the calling surface — every surface
|
||||
* keeps its own `lastSCAOutcome` state to drive SCA-vs-2FA fallback. */
|
||||
* keeps its own `lastSCAOutcome` state to drive the refusal path. */
|
||||
onOutcome: (outcome: SavedCardVerificationOutcome) => void;
|
||||
}
|
||||
|
||||
@@ -386,8 +392,9 @@ export interface RunSavedCardSCAOptions {
|
||||
* everything downstream of the resolved squareCardId.
|
||||
*
|
||||
* Returns the outcome plus the verification token ('verified' → retry the
|
||||
* SAME charge with it). On 'sca-unavailable' the caller falls back to the 2FA
|
||||
* gate; 'challenge-cancelled'/'sca-failed' are retryable without a token.
|
||||
* SAME charge with it). On 'sca-unavailable' the caller shows the refusal
|
||||
* notice (C6 — no 2FA fallback); 'challenge-cancelled'/'sca-failed' are
|
||||
* retryable without a token.
|
||||
*/
|
||||
export async function runSavedCardSCAProactively(
|
||||
options: RunSavedCardSCAOptions
|
||||
@@ -416,15 +423,59 @@ export const VERIFICATION_REQUIRED_MESSAGE =
|
||||
|
||||
/** User-facing message for a saved-card SCA challenge that was cancelled or did
|
||||
* not complete. Retryable via SCA — deliberately does NOT promise the 2FA code
|
||||
* input, which the customer surfaces only surface on 'sca-unavailable'. */
|
||||
* input, which the customer surfaces no longer offer (C6 SCA-only posture). */
|
||||
export const CARD_VERIFICATION_RETRY_MESSAGE =
|
||||
"Card verification was cancelled or didn't complete. Please try again.";
|
||||
|
||||
/** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the
|
||||
* issuer's SCA challenge genuinely cannot run — the 2FA code input is the
|
||||
* only available authorisation and is surfaced as the fallback gate. */
|
||||
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE =
|
||||
"In-app approval isn't available for this card — enter the verification code instead.";
|
||||
/**
|
||||
* User-facing refusal shown when a saved-card charge hits genuine
|
||||
* `sca-unavailable` (the issuer's in-app SCA challenge cannot run). C6 legal
|
||||
* verdict: the homegrown 2FA code gate cannot legally substitute for SCA (PSR
|
||||
* 2017 SCA is non-waivable and the merchant stays liable regardless of
|
||||
* consent), so the customer is told the payment cannot complete and is invited
|
||||
* to pay online later — never offered a verification-code fallback.
|
||||
*/
|
||||
export const SCA_REFUSAL_MESSAGE_ONLINE =
|
||||
"We couldn't complete secure authentication with your bank. To keep your payment protected, it can't be processed right now, so this deposit, payment or purchase did not go through. You can try again later.";
|
||||
|
||||
/**
|
||||
* In-person till variant: the customer is physically at the salon, so the
|
||||
* natural fallback is to pay online later from home.
|
||||
*/
|
||||
export const SCA_REFUSAL_MESSAGE_TILL =
|
||||
"We couldn't complete secure authentication with your bank. To keep your payment protected, it can't be processed at the till right now, so you can pay online later instead.";
|
||||
|
||||
/**
|
||||
* Version of the SCA-unavailable 2FA-fallback informational notice. Bump when
|
||||
* the notice's wording or the consent payload changes so a charge can never
|
||||
* claim consent under an outdated notice. Under the C6 SCA-only posture this
|
||||
* notice is shown ONLY on the explicit opt-in path (a deployment that enables
|
||||
* the backend `TWO_FACTOR_FALLBACK`); the shipped surfaces refuse instead and
|
||||
* never reach it. The notice is an INFORMATION notice (approved as such by
|
||||
* legal review) — it tells the customer the fallback is less secure than
|
||||
* their bank's authentication and that their refund/chargeback rights are
|
||||
* unaffected; it is NOT a liability waiver and must never be framed as one.
|
||||
*/
|
||||
export const SCA_FALLBACK_CONSENT_VERSION = 'v1';
|
||||
|
||||
/**
|
||||
* Consent payload carried on a 2FA-fallback charge once the customer accepted
|
||||
* the SCA-unavailable notice — the EXPLICIT OPT-IN path only (a deployment
|
||||
* that enables the backend `TWO_FACTOR_FALLBACK`). The frontend cannot detect
|
||||
* the deployment posture (no build-time env, no /api/config endpoint), so the
|
||||
* shipped charge surfaces default to the C6 refusal and never produce
|
||||
* consent_accepted on their own: they call this with `false`, which returns
|
||||
* `{}` and sends no consent fields. The backend ignores unknown fields until
|
||||
* it adds audit capture, so sending these is forward-compatible.
|
||||
* `consent_accepted` is true exactly when the customer chose to continue with
|
||||
* the verification code (never when they chose "Cancel and pay later" — no
|
||||
* charge is sent then).
|
||||
*/
|
||||
export function scaFallbackConsentFields(consentAccepted: boolean): Record<string, unknown> {
|
||||
return consentAccepted
|
||||
? { consent_version: SCA_FALLBACK_CONSENT_VERSION, consent_accepted: true }
|
||||
: {};
|
||||
}
|
||||
|
||||
/** User-facing guidance for a saved-card charge the issuer requires
|
||||
* verification to complete. Retrying the same saved card is pointless — the
|
||||
|
||||
Reference in New Issue
Block a user