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:
@@ -11,12 +11,19 @@
|
||||
import {
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode
|
||||
requestNewTwoFactorCode,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -87,6 +94,9 @@
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
// Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
|
||||
// challenge (tokenizeSavedCardWithVerification).
|
||||
square_card_id?: string;
|
||||
};
|
||||
|
||||
let customerQuery = $state('');
|
||||
@@ -129,9 +139,17 @@
|
||||
// irrelevant to the backend gate, so `enabled` is always true.
|
||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||
let customerTwoFactorEnabled = $state(false);
|
||||
// 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('');
|
||||
// True while the saved-card 3DS challenge is open and the CUSTOMER must
|
||||
// approve it in their banking app — drives the "waiting for approval" panel.
|
||||
let awaitingSCA = $state(false);
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||
mint: () =>
|
||||
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
|
||||
});
|
||||
@@ -384,6 +402,16 @@
|
||||
if (!res.ok) {
|
||||
responseStatus = res.status;
|
||||
const errText = await res.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 the SAME sale line with the fresh token and
|
||||
// its SAME cached idempotency key. runTillSavedCardSCA throws to
|
||||
// stop the whole sale on any non-verified outcome.
|
||||
if (paymentMethod === 'saved_card' && isVerificationRequiredSignal(responseStatus, errText)) {
|
||||
await runTillSavedCardSCA(body);
|
||||
continue;
|
||||
}
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -405,11 +433,75 @@
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
processing = false;
|
||||
}
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
|
||||
* with the verification-required signal. The CUSTOMER approves the 3DS
|
||||
* challenge in their banking app; the operator's screen shows the waiting
|
||||
* state. 'verified' retries the SAME sale line with the fresh verification_token
|
||||
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
|
||||
* demotes 2FA from backup to the available gate; 'challenge-cancelled' /
|
||||
* 'sca-failed' keep the pending row retryable (the idempotency key stays
|
||||
* cached). Throws to stop the whole sale on any non-verified outcome.
|
||||
*/
|
||||
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
|
||||
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
|
||||
// The till body carries the amount in POUNDS (the backend multiplies by
|
||||
// 100); the SCA challenge binds to pence, so convert for the challenge.
|
||||
const amountPence = Math.round((Number(body.amount) || 0) * 100);
|
||||
awaitingSCA = true;
|
||||
try {
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
|
||||
email: selectedCustomer?.email
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
throw err;
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
if (result.outcome === 'verified') {
|
||||
const retry = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
verification_token: result.verificationToken
|
||||
})
|
||||
})
|
||||
);
|
||||
if (!retry.ok) {
|
||||
const errText = await retry.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
twoFactor.reveal = true;
|
||||
if (result.outcome === 'sca-unavailable') {
|
||||
throw new Error(
|
||||
`${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
|
||||
);
|
||||
} finally {
|
||||
awaitingSCA = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border bg-card">
|
||||
@@ -793,10 +885,9 @@
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">
|
||||
This card may require bank app confirmation to complete. Ensure the customer has
|
||||
their phone ready.
|
||||
</p>
|
||||
<p class="text-xs text-amber-800">
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -836,18 +927,32 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
{#if awaitingSCA}
|
||||
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="mt-3 text-sm font-medium text-gray-700">
|
||||
Waiting for customer to approve in their banking app…
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
The customer may need to approve this payment in their banking app
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
{/if}
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Gift card sales are processed through the till; retail items require manual recording for
|
||||
|
||||
@@ -46,8 +46,15 @@
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry
|
||||
isVerificationRequiredSignal,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
@@ -107,6 +114,9 @@
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
is_default?: boolean;
|
||||
// Square's card-on-file id (`ccof:...`), needed to run the saved-card
|
||||
// SCA challenge (tokenizeSavedCardWithVerification).
|
||||
square_card_id?: string;
|
||||
}>
|
||||
>([]);
|
||||
let paymentMethodsLoading = $state(false);
|
||||
@@ -158,10 +168,15 @@
|
||||
// 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 && (selectedPaymentMethod !== '' || depositSaveCard)
|
||||
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
|
||||
});
|
||||
|
||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||
@@ -520,6 +535,20 @@
|
||||
}
|
||||
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
// 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 deposit can be retried with a fresh code.
|
||||
@@ -610,6 +639,56 @@
|
||||
depositTokenizedForSaveCard = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function runDepositSCA(options: {
|
||||
body: Record<string, unknown>;
|
||||
amountPence: number;
|
||||
depositAmount: number;
|
||||
confirmOverflowTip: boolean;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(options.amountPence, squareCardId, {
|
||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||
email: customerInfo.email || authStore.currentUser?.email
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
||||
return;
|
||||
}
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
let paymentAttempted = $state(false);
|
||||
|
||||
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
is_default?: boolean;
|
||||
// Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
|
||||
// challenge (tokenizeSavedCardWithVerification). Optional so surfaces that
|
||||
// haven't populated it from the API can still render the list.
|
||||
square_card_id?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -104,7 +108,7 @@
|
||||
{#if twoFactorEnabled}
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
A verification code is required to use a saved card — you'll be asked for it at checkout.
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
<!-- DEV-ONLY mock card form — enabled only by VITE_SQUARE_ENVIRONMENT === 'mock'
|
||||
(never in production). The PAN lives only in local component state; only a
|
||||
cnon: token is ever returned. -->
|
||||
<script module lang="ts">
|
||||
/**
|
||||
* DEV-ONLY saved-card SCA challenge. SquareCardInput.tokenizeSavedCardWithVerification
|
||||
* dynamically imports this module and calls this export when the mock form is
|
||||
* active, so the saved-card (ccof) 3DS/SCA path runs end to end. The token is
|
||||
* deterministic and stateless — it encodes the card prefix, the bound amount and
|
||||
* the encoded outcome (`_ok`) — and the backend dev mock
|
||||
* (square_dev.go parseVerifyToken) parses the same shape back, so local-dev
|
||||
* mirrors production without shared state.
|
||||
*/
|
||||
export function tokenizeSavedCard(
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
_contact?: { givenName?: string; familyName?: string; email?: string }
|
||||
): Promise<{
|
||||
verificationToken: string | null;
|
||||
outcome: 'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
|
||||
}> {
|
||||
const stripped = squareCardId.startsWith('ccof:') ? squareCardId.slice(5) : squareCardId;
|
||||
const prefix = stripped.slice(0, 4) || 'test';
|
||||
return Promise.resolve({
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_ok`,
|
||||
outcome: 'verified'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import CardBrandIcon from './CardBrandIcon.svelte';
|
||||
|
||||
@@ -15,14 +42,46 @@
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
onReady = () => {}
|
||||
}: { disabled?: boolean; onReady?: (ready: boolean) => void } = $props();
|
||||
onReady = () => {},
|
||||
// Dev toggles for the mock 3DS/SCA challenge simulation:
|
||||
// simulateChallenge waits for the "Approve in banking app" button before
|
||||
// resolving; challengeResult is the deterministic outcome encoded into the
|
||||
// verification token (verify_mock_<prefix>_<amount>_ok|_deny).
|
||||
simulateChallenge = false,
|
||||
challengeResult = 'approve'
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onReady?: (ready: boolean) => void;
|
||||
simulateChallenge?: boolean;
|
||||
challengeResult?: 'approve' | 'deny';
|
||||
} = $props();
|
||||
|
||||
let cardNumber = $state('');
|
||||
let expiry = $state('');
|
||||
let cvc = $state('');
|
||||
let cardholderName = $state('');
|
||||
|
||||
// Mock challenge state: while awaitingChallenge is true, the form shows the
|
||||
// "Approve in banking app" panel and the in-flight tokenize call waits.
|
||||
let awaitingChallenge = $state(false);
|
||||
let challengeResolver: ((outcome: 'approve' | 'deny') => void) | null = null;
|
||||
|
||||
async function runChallenge(): Promise<'approve' | 'deny'> {
|
||||
if (!simulateChallenge) return challengeResult ?? 'approve';
|
||||
awaitingChallenge = true;
|
||||
return await new Promise<'approve' | 'deny'>((resolve) => {
|
||||
challengeResolver = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/** Dev toggle: resolves a simulated banking-app challenge with the configured outcome. */
|
||||
export function resolveMockChallenge(): void {
|
||||
const resolver = challengeResolver;
|
||||
challengeResolver = null;
|
||||
awaitingChallenge = false;
|
||||
resolver?.(challengeResult ?? 'approve');
|
||||
}
|
||||
|
||||
let cardNumberTouched = $state(false);
|
||||
let expiryTouched = $state(false);
|
||||
let cvcTouched = $state(false);
|
||||
@@ -169,8 +228,12 @@
|
||||
/**
|
||||
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
|
||||
* exercises the full SCA path (card nonce + verification token) end to end.
|
||||
* The fake verification token is deterministic and the backend mock accepts
|
||||
* it alongside the cnon: nonce.
|
||||
* The fake verification token is deterministic — it encodes the card prefix,
|
||||
* the bound amount and the challenge outcome (verify_mock_<prefix>_<amount>_ok|_deny)
|
||||
* — and the backend dev mock parses it back (square_dev.go
|
||||
* parseVerifyToken), so the frontend and backend agree on the outcome without
|
||||
* shared state. The outcome is challengeResult (default approve), optionally
|
||||
* gated behind the mock "Approve in banking app" panel via simulateChallenge.
|
||||
*
|
||||
* @param amount The amount that WILL be charged, in pence — same pence input
|
||||
* contract as the real form. The real form serializes this to
|
||||
@@ -190,13 +253,36 @@
|
||||
if (!complete) {
|
||||
throw new Error('Card details are incomplete');
|
||||
}
|
||||
const outcome = await runChallenge();
|
||||
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
||||
const prefix = digits.slice(0, 4) || 'test';
|
||||
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
|
||||
return Promise.resolve({
|
||||
nonce: token,
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-ONLY saved-card SCA verification token for a ccof charge — the
|
||||
* deterministic counterpart of Square's buyer-verification flow for a saved
|
||||
* card. SquareCardInput.tokenizeSavedCardWithVerification delegates to the
|
||||
* module-level tokenizeSavedCard for the same flow; this instance method is
|
||||
* available for direct use and honours the challenge simulation toggles. The
|
||||
* token encodes the card prefix (first 4 chars after `ccof:`), the bound
|
||||
* amount (pence) and the challenge outcome:
|
||||
* verify_mock_<prefix>_<amount>_ok|_deny.
|
||||
*
|
||||
* @param amount The amount the saved-card charge will be for, in pence.
|
||||
* @param ccofToken The saved card's ccof: token (the charge source).
|
||||
*/
|
||||
export async function verifySavedCard(amount: number, ccofToken: string): Promise<string> {
|
||||
const outcome = await runChallenge();
|
||||
const stripped = ccofToken.startsWith('ccof:') ? ccofToken.slice(5) : ccofToken;
|
||||
const prefix = stripped.slice(0, 4) || 'test';
|
||||
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
|
||||
return `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -282,4 +368,21 @@
|
||||
class={inputClasses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if awaitingChallenge}
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
|
||||
<p class="text-sm font-medium text-amber-800">Waiting for approval in the banking app…</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome
|
||||
({challengeResult === 'deny' ? 'deny' : 'approve'}).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resolveMockChallenge}
|
||||
class="mt-2 rounded-md bg-amber-600 px-3 py-1 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
Approve in banking app
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,19 @@
|
||||
campaignDiscountPence,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
@@ -44,6 +51,7 @@
|
||||
| 'gift-confirming'
|
||||
| 'saved-card-selecting'
|
||||
| 'saved-card-processing'
|
||||
| 'saved-card-waiting-sca'
|
||||
| 'success'
|
||||
| 'error';
|
||||
|
||||
@@ -78,6 +86,10 @@
|
||||
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
|
||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||
let customerTwoFactorEnabled = $state(false);
|
||||
// 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 stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
@@ -92,6 +104,7 @@
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
|
||||
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
|
||||
mint: () => {
|
||||
const customerID = booking.user_id ?? booking.user?.id;
|
||||
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
||||
@@ -739,6 +752,7 @@
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
square_card_id?: string;
|
||||
}>
|
||||
>([]);
|
||||
let loadingSavedCards = $state(false);
|
||||
@@ -829,7 +843,18 @@
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errData = await response.text();
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card 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 (the CUSTOMER approves in their banking app) and retry
|
||||
// with the fresh token + the SAME cached idempotency key.
|
||||
if (isVerificationRequiredSignal(responseStatus, errData)) {
|
||||
await runSavedCardSCA(chargeAmount);
|
||||
return;
|
||||
}
|
||||
const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
|
||||
(err as { bodyText?: string }).bodyText = errData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -854,13 +879,17 @@
|
||||
onComplete(paymentResult);
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
// 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.
|
||||
// A definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification. A structured verification-required signal
|
||||
// surfaces the SCA-first guidance; the legacy saved-card check is the
|
||||
// fallback for generic 402s (the SCA flow above intercepts the
|
||||
// structured ones, so this is the belt-and-braces path).
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
if (isSavedCardVerificationRequired(responseStatus, true))
|
||||
msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||
if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) {
|
||||
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.
|
||||
@@ -884,6 +913,101 @@
|
||||
fetchSavedCards();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Saved-card (ccof) SCA challenge, run when the charge came back 402 with
|
||||
* the verification-required signal. The CUSTOMER approves the 3DS challenge
|
||||
* in their banking app; the operator's screen shows the waiting state.
|
||||
* - '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.
|
||||
*/
|
||||
async function runSavedCardSCA(chargeAmount: number) {
|
||||
if (!selectedSavedCardId) return;
|
||||
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
|
||||
status = 'saved-card-waiting-sca';
|
||||
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(chargeAmount, squareCardId, {
|
||||
givenName: booking.user?.first_name,
|
||||
familyName: booking.user?.last_name,
|
||||
email: booking.user?.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') {
|
||||
try {
|
||||
const retry = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: chargeAmount,
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
verification_token: result.verificationToken,
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
})
|
||||
);
|
||||
if (!retry.ok) {
|
||||
const retryText = await retry.text();
|
||||
const err = new Error(
|
||||
extractErrorMessage(retryText) || 'Failed to process saved card payment'
|
||||
);
|
||||
(err as { bodyText?: string }).bodyText = retryText;
|
||||
throw err;
|
||||
}
|
||||
const data = await retry.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.payment_id || data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
card_brand: data.card_brand,
|
||||
last4: data.card_last4,
|
||||
amount: data.amount
|
||||
};
|
||||
savedCardIdempotencyKey = '';
|
||||
savedCardKeyedAmount = 0;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
toast.success('Saved card payment successful');
|
||||
onComplete(paymentResult);
|
||||
return;
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
toast.error(error);
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
@@ -1526,8 +1650,7 @@
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">
|
||||
This card may require bank app confirmation to complete. Ensure the customer has their
|
||||
phone ready.
|
||||
Your card issuer will ask you to approve this payment in your banking app.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1563,12 +1686,19 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'saved-card-processing'}
|
||||
{:else if status === 'saved-card-processing' || status === 'saved-card-waiting-sca'}
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<div
|
||||
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
|
||||
></div>
|
||||
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
|
||||
{#if status === 'saved-card-waiting-sca'}
|
||||
<p class="text-lg font-medium text-gray-700">
|
||||
Waiting for customer to approve in their banking app…
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">The customer may need to approve this payment in their banking app</p>
|
||||
{:else}
|
||||
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if status === 'error' && error}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,164 @@
|
||||
<script module lang="ts">
|
||||
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
|
||||
|
||||
/**
|
||||
* Billing contact passed to Square's tokenize() verificationDetails for
|
||||
* Strong Customer Authentication (SCA). Only fields we already hold are
|
||||
* included; omit the object entirely when nothing is available.
|
||||
*/
|
||||
export interface SquareVerificationContact {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** Result of a tokenize-with-verification call. */
|
||||
export interface TokenizeWithVerificationResult {
|
||||
nonce: string;
|
||||
verificationToken: string | null;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export type SavedCardVerificationOutcome =
|
||||
| 'verified'
|
||||
| 'challenge-cancelled'
|
||||
| 'sca-unavailable'
|
||||
| 'sca-failed';
|
||||
|
||||
/** Result of tokenizeSavedCardWithVerification. */
|
||||
export interface SavedCardVerificationResult {
|
||||
verificationToken: string | null;
|
||||
outcome: SavedCardVerificationOutcome;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` verification details shape. */
|
||||
interface SquareVerificationDetails {
|
||||
amount: string;
|
||||
billingContact?: SquareVerificationContact;
|
||||
intent: string;
|
||||
currencyCode: string;
|
||||
customerInitiated: boolean;
|
||||
sellerKeyedIn: boolean;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` result shape (verification path). */
|
||||
interface SquareTokenizeResult {
|
||||
status: string;
|
||||
token?: string;
|
||||
verificationResult?: { token?: string };
|
||||
errors?: Array<{ message?: string; code?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the SCA challenge for a SAVED card (ccof) whose charge Square refused
|
||||
* with a "verification required" signal. Square's card-on-file flow binds
|
||||
* buyer verification to the exact charge amount, so the challenge must use
|
||||
* the same major-units amount as the pending charge.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function tokenizeSavedCardWithVerification(
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
contact?: SquareVerificationContact
|
||||
): Promise<SavedCardVerificationResult> {
|
||||
if (isSquareMock()) {
|
||||
// DEV-ONLY mock: the mock agent extends MockCardForm with a saved-card
|
||||
// SCA method. Use it when present (so the mock exercises the same
|
||||
// challenge path), otherwise fall back to a deterministic fake token
|
||||
// the backend dev mock accepts.
|
||||
try {
|
||||
const mockModule = (await import('./MockCardForm.svelte')) as {
|
||||
tokenizeSavedCard?: (
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
contact?: SquareVerificationContact
|
||||
) => Promise<SavedCardVerificationResult>;
|
||||
default?: {
|
||||
tokenizeSavedCard?: (
|
||||
amount: number,
|
||||
squareCardId: string,
|
||||
contact?: SquareVerificationContact
|
||||
) => Promise<SavedCardVerificationResult>;
|
||||
};
|
||||
};
|
||||
const mockTokenize = mockModule.tokenizeSavedCard ?? mockModule.default?.tokenizeSavedCard;
|
||||
if (mockTokenize) {
|
||||
return await mockTokenize(amount, squareCardId, contact);
|
||||
}
|
||||
} catch {
|
||||
// Dynamic import failure → fall through to the deterministic token.
|
||||
}
|
||||
const prefix = squareCardId.replace(/^ccof:/, '').slice(0, 4) || 'test';
|
||||
return {
|
||||
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`,
|
||||
outcome: 'verified'
|
||||
};
|
||||
}
|
||||
|
||||
const payments = (await getSquarePayments()) as {
|
||||
card: () => Promise<{
|
||||
tokenize: (
|
||||
verificationDetails: SquareVerificationDetails,
|
||||
cardId: string
|
||||
) => Promise<SquareTokenizeResult>;
|
||||
}>;
|
||||
};
|
||||
const card = await payments.card();
|
||||
|
||||
// Same verification-details shape as tokenizeWithVerification: a
|
||||
// MAJOR-units decimal amount string (W3C valid-decimal-monetary-value)
|
||||
// bound to the exact pending charge, intent CHARGE (the card is already
|
||||
// stored — nothing new to save), GBP, customer-initiated, not seller-keyed.
|
||||
const verificationDetails: SquareVerificationDetails = {
|
||||
amount: (amount / 100).toFixed(2),
|
||||
intent: 'CHARGE',
|
||||
currencyCode: 'GBP',
|
||||
customerInitiated: true,
|
||||
sellerKeyedIn: false
|
||||
};
|
||||
if (contact && (contact.givenName || contact.familyName || contact.email)) {
|
||||
verificationDetails.billingContact = contact;
|
||||
}
|
||||
|
||||
let result: SquareTokenizeResult;
|
||||
try {
|
||||
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.
|
||||
console.error('Saved-card SCA tokenization failed:', err);
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
|
||||
if (result.status === 'OK' && result.verificationResult?.token) {
|
||||
return { verificationToken: result.verificationResult.token, outcome: 'verified' };
|
||||
}
|
||||
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
|
||||
const errorText =
|
||||
codes.join(' ') +
|
||||
' ' +
|
||||
(result.errors ?? [])
|
||||
.map((e) => e.message ?? '')
|
||||
.join(' ');
|
||||
// VERIFICATION_CHALLENGE / cancel-coded errors mean the challenge was
|
||||
// shown but not completed — the buyer can retry, so this is retryable.
|
||||
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
|
||||
return { verificationToken: null, outcome: 'challenge-cancelled' };
|
||||
}
|
||||
// The card/issuer cannot complete buyer verification at all — SCA is not
|
||||
// available for this charge, so the surface falls back to the 2FA gate.
|
||||
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
|
||||
return { verificationToken: null, outcome: 'sca-unavailable' };
|
||||
}
|
||||
return { verificationToken: null, outcome: 'sca-failed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
|
||||
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
|
||||
@@ -43,34 +203,6 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||
import type MockCardForm from './MockCardForm.svelte';
|
||||
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
|
||||
|
||||
/**
|
||||
* Billing contact passed to Square's tokenize() verificationDetails for
|
||||
* Strong Customer Authentication (SCA). Only fields we already hold are
|
||||
* included; omit the object entirely when nothing is available.
|
||||
*/
|
||||
export interface SquareVerificationContact {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** Result of a tokenize-with-verification call. */
|
||||
export interface TokenizeWithVerificationResult {
|
||||
nonce: string;
|
||||
verificationToken: string | null;
|
||||
}
|
||||
|
||||
/** Square Web Payments `card.tokenize()` verification details shape. */
|
||||
interface SquareVerificationDetails {
|
||||
amount: string;
|
||||
billingContact?: SquareVerificationContact;
|
||||
intent: string;
|
||||
currencyCode: string;
|
||||
customerInitiated: boolean;
|
||||
sellerKeyedIn: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Disable the form while a payment is processing. */
|
||||
|
||||
@@ -18,11 +18,18 @@
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
tokenizeSavedCardWithVerification,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/components/payments/SquareCardInput.svelte';
|
||||
|
||||
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
|
||||
// booking-modal tip dialog. The routes resolve the booking (most-recent past
|
||||
@@ -113,9 +120,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)
|
||||
});
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
@@ -313,7 +325,17 @@
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
// 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;
|
||||
}
|
||||
const err = new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
(err as { bodyText?: string }).bodyText = errorText;
|
||||
throw err;
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
@@ -332,12 +354,19 @@
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
// 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.
|
||||
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
|
||||
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
// A definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification. A structured verification-required signal
|
||||
// surfaces the SCA-first guidance; the legacy saved-card check is
|
||||
// the fallback for generic 402s.
|
||||
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
|
||||
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
|
||||
if (
|
||||
scaVerificationRequired ||
|
||||
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
|
||||
) {
|
||||
errorMessage = 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
|
||||
@@ -361,6 +390,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function runTipSCA(amountInPence: number, cardId: 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;
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
try {
|
||||
result = await tokenizeSavedCardWithVerification(amountInPence, squareCardId, {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.reveal = true;
|
||||
paymentState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : 'Card verification failed');
|
||||
return;
|
||||
}
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
function retryPayment() {
|
||||
paymentState = 'idle';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<!--
|
||||
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
|
||||
charges. The backend's requireTwoFactorForCardAccess gate requires the CARD
|
||||
OWNER's current one-time code on every saved-card charge in an enforced
|
||||
environment; this input collects it so the charge body carries
|
||||
charges. With Square 3DS SCA now the PRIMARY authorisation for saved-card
|
||||
(ccof) charges, this input is the BACKUP path: it shows only when the charge
|
||||
hits the backend's requireTwoFactorForCardAccess gate and SCA couldn't
|
||||
authorise (sca-unavailable), or a charge 403/SCA-failure has revealed it. The
|
||||
backend requires the CARD OWNER's current one-time code on every 2FA-gated
|
||||
saved-card charge; this input collects it so the charge body carries
|
||||
`verification_code`. Shared by the customer booking, tip, admin booking and
|
||||
till saved-card surfaces so the field name, hint copy and the
|
||||
enabled/not-enabled presentation can't drift between them.
|
||||
@@ -47,8 +50,7 @@
|
||||
class="mt-1 font-mono tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
A current 2FA verification code is required for this saved-card charge. Ask the customer
|
||||
for their code, or retrieve it from the server log.
|
||||
Your bank doesn't support in-app approval — enter the code sent to you / your phone.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -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