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:
2026-08-22 00:34:50 +01:00
parent 9a75ebc794
commit 0fdb2f02cd
21 changed files with 1404 additions and 315 deletions
+401 -9
View File
@@ -3,6 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NONCE_STALENESS_MS,
SAVED_CARD_VERIFICATION_MESSAGE,
SCA_FALLBACK_CONSENT_VERSION,
SCA_REFUSAL_MESSAGE_ONLINE,
SCA_REFUSAL_MESSAGE_TILL,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
campaignDiscountPence,
@@ -17,8 +20,10 @@ import {
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
sanitizeDecimalInput,
shouldFallbackTo2FA,
submitPaymentWithRetry
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
type SavedCardVerificationOutcome
} from './square';
import type * as SquareModule from './square';
@@ -306,7 +311,7 @@ describe('isVerificationRequiredSignal', () => {
});
});
describe('shouldFallbackTo2FA', () => {
describe('shouldShowSCARefusal', () => {
it.each([
['sca-unavailable', true],
['verified', false],
@@ -314,19 +319,59 @@ describe('shouldFallbackTo2FA', () => {
['sca-failed', false],
['', false]
])('outcome %s → %s', (outcome, expected) => {
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
expect(shouldShowSCARefusal(outcome)).toBe(expected);
});
it('demotes to the 2FA gate only on sca-unavailable', () => {
expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true);
it('refuses the charge only on sca-unavailable (C6 SCA-only posture)', () => {
expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
});
it('keeps SCA primary after a successful verification', () => {
expect(shouldFallbackTo2FA('verified')).toBe(false);
expect(shouldShowSCARefusal('verified')).toBe(false);
});
it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => {
expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false);
expect(shouldShowSCARefusal('challenge-cancelled')).toBe(false);
});
});
describe('C6 SCA-unavailable refusal (no 2FA fallback for saved-card charges)', () => {
it('online refusal tells the customer the payment failed and can be retried', () => {
expect(SCA_REFUSAL_MESSAGE_ONLINE.length).toBeGreaterThan(0);
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('not');
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('did not go through');
// Online payments are refused as failed — never "pay online later" (the
// customer IS online; the deposit/gift-card purchase simply fails).
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('pay online later');
// The refusal must NOT offer the verification-code fallback.
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('verification code');
});
it('till refusal is the only surface that invites paying online later', () => {
expect(SCA_REFUSAL_MESSAGE_TILL.length).toBeGreaterThan(0);
// Only the in-person till surface may suggest paying online later.
expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).toContain('pay online later');
// It still must NOT offer the verification-code fallback.
expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).not.toContain('verification code');
});
it('the refusal path never sends consent_accepted on its own', () => {
// A refused charge (sca-unavailable) shows the refusal and proceeds
// nowhere — the versioned consent payload exists only for the explicit
// opt-in path (TWO_FACTOR_FALLBACK deployments) via
// scaFallbackConsentFields(true), which the refusal never reaches: the
// charge surfaces always call it with consent unaccepted, so it yields
// an empty object and no consent_accepted/consent_version is sent.
expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
expect(scaFallbackConsentFields(false)).toEqual({});
});
it('SCA_FALLBACK_CONSENT_VERSION stays versioned for the explicit opt-in path', () => {
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
expect(scaFallbackConsentFields(true)).toEqual({
consent_version: SCA_FALLBACK_CONSENT_VERSION,
consent_accepted: true
});
});
});
@@ -363,7 +408,7 @@ describe('parseTokenizeVerificationResult', () => {
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
});
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => {
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (C6 refusal)', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
@@ -669,6 +714,23 @@ describe('adminRequestNewTwoFactorCode', () => {
});
});
describe('SCA-unavailable fallback consent (C6)', () => {
it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => {
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
});
it('scaFallbackConsentFields returns the versioned payload when accepted', () => {
expect(scaFallbackConsentFields(true)).toEqual({
consent_version: SCA_FALLBACK_CONSENT_VERSION,
consent_accepted: true
});
});
it('scaFallbackConsentFields returns an empty object when not accepted', () => {
expect(scaFallbackConsentFields(false)).toEqual({});
});
});
describe('depositChargePence', () => {
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
expect(depositChargePence(2000, 500)).toBe(1500);
@@ -686,3 +748,333 @@ describe('depositChargePence', () => {
expect(depositChargePence(2000, 0)).toBe(2000);
});
});
// The mock card form's saved-card SCA helper drives every outcome of
// runSavedCardSCAProactively. tokenizeSavedCardWithVerification dynamically
// imports MockCardForm when isSquareMock() is true; the vi.hoisted mock lets
// the tests choose the tokenize result per scenario, so the outcome mapping
// (verified / challenge-cancelled / sca-unavailable / sca-failed) and the
// onOutcome wiring are exercised through the real shared implementation.
const mockSCATokenize = vi.hoisted(() => ({ fn: vi.fn() }));
vi.mock('$lib/components/payments/MockCardForm.svelte', () => ({
tokenizeSavedCard: mockSCATokenize.fn
}));
describe('runSavedCardSCAProactively', () => {
async function loadSquareInMockMode(): Promise<typeof SquareModule> {
vi.resetModules();
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', 'mock');
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', '');
vi.stubEnv('VITE_SQUARE_LOCATION_ID', '');
vi.stubEnv('DEV', true);
return await import('./square');
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
mockSCATokenize.fn.mockClear();
});
it('maps a verified tokenize result and records it via onOutcome', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: 'verify_mock_ok',
outcome: 'verified'
});
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toBe('verify_mock_ok');
expect(outcomes).toEqual(['verified']);
});
it('maps a challenge-cancelled tokenize result (retryable via SCA, never 2FA)', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: null,
outcome: 'challenge-cancelled'
});
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('challenge-cancelled');
expect(res.verificationToken).toBeUndefined();
expect(outcomes).toEqual(['challenge-cancelled']);
});
it('maps an sca-unavailable tokenize result (C6 refusal)', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: null,
outcome: 'sca-unavailable'
});
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: () => {}
});
expect(res.outcome).toBe('sca-unavailable');
expect(res.verificationToken).toBeUndefined();
});
it('maps an sca-failed tokenize result', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: null,
outcome: 'sca-failed'
});
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: () => {}
});
expect(res.outcome).toBe('sca-failed');
});
it('demotes to sca-unavailable when no saved card is resolved', async () => {
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: '',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('sca-unavailable');
expect(outcomes).toEqual(['sca-unavailable']);
expect(mockSCATokenize.fn).not.toHaveBeenCalled();
});
it('falls back to the deterministic dev token when the mock tokenizer throws', async () => {
// tokenizeSavedCardWithVerification swallows a throwing MockCardForm
// tokenizer (dev-mock convenience) and returns the deterministic fake
// token — so the shared runner reports verified, never a dead-end.
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const outcomes: SavedCardVerificationOutcome[] = [];
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:test-card',
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toMatch(/^verify_mock_/);
expect(outcomes).toEqual(['verified']);
});
});
// The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs:
// this vitest config has no svelte plugin, so `$state` / `$derived` are
// provided as plain-value globals (no reactivity — each composable instance is
// a state snapshot). That still exercises the full imperative surface: the
// consent accept/decline flags, the code setter, the mint selection and the
// requestNewCode toast flow for every outcome.
const toastMocks = vi.hoisted(() => ({
toast: { success: vi.fn(), error: vi.fn() }
}));
vi.mock('svelte-sonner', () => toastMocks);
vi.mock('$lib/square/square', () => ({
requestNewTwoFactorCode: vi.fn(async () => ({
status: 200,
ok: true,
message: 'sent'
}))
}));
describe('useTwoFactorCodeForSavedCard', () => {
async function loadComposable() {
vi.stubGlobal('$state', (v: unknown) => v);
vi.stubGlobal('$derived', (v: unknown) => v);
const mod = (await import('../stores/twoFactorCode.svelte')) as {
useTwoFactorCodeForSavedCard: (options: {
enabled: () => boolean;
gateActive: () => boolean;
mint?: () => Promise<{ status: number; ok: boolean; message: string }>;
scaAvailable?: () => boolean;
}) => {
code: string;
setCode: (v: string) => void;
reveal: boolean;
showInput: boolean;
missing: boolean;
requesting: boolean;
requestNewCode: () => Promise<void>;
consentAccepted: boolean;
acceptConsent: () => void;
declineConsent: () => void;
};
};
return mod;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('starts in a clean state with the 2FA input hidden', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
expect(comp.showInput).toBe(false);
expect(comp.missing).toBe(false);
expect(comp.reveal).toBe(false);
expect(comp.consentAccepted).toBe(false);
expect(comp.code).toBe('');
expect(comp.requesting).toBe(false);
});
it('still exposes the 2FA code state for account/admin uses (C6)', async () => {
// The composable keeps the full code-state API even though charge
// surfaces no longer auto-trigger it: code, setCode, reveal, missing,
// requestNewCode and the consent flags all remain available.
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
comp.setCode('123456');
expect(comp.code).toBe('123456');
comp.reveal = true;
expect(comp.reveal).toBe(true);
expect(comp.consentAccepted).toBe(false);
comp.acceptConsent();
expect(comp.consentAccepted).toBe(true);
});
it('an sca-unavailable outcome alone never surfaces the 2FA input (C6 refusal)', async () => {
// C6: even when SCA is reported unavailable, the code input must NOT
// appear without the explicit opt-in consent (or a backend reveal) —
// the charge surfaces refuse instead, so no verification-code fallback
// is ever offered off an SCA outcome on its own.
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
scaAvailable: () => false
});
expect(comp.consentAccepted).toBe(false);
expect(comp.showInput).toBe(false);
expect(comp.missing).toBe(false);
});
it('acceptConsent reveals the input; declineConsent hides it again', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
comp.acceptConsent();
expect(comp.reveal).toBe(true);
expect(comp.consentAccepted).toBe(true);
comp.declineConsent();
expect(comp.reveal).toBe(false);
expect(comp.consentAccepted).toBe(false);
});
it('setCode populates the code the caller later submits', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
comp.setCode('123456');
expect(comp.code).toBe('123456');
});
it('requestNewCode clears the code and toasts the server message on success', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const mint = vi.fn(async () => ({
status: 200,
ok: true,
message: 'A new verification code has been sent.'
}));
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
comp.setCode('654321');
await comp.requestNewCode();
expect(mint).toHaveBeenCalledTimes(1);
expect(comp.code).toBe('');
expect(toastMocks.toast.success).toHaveBeenCalledWith('A new verification code has been sent.');
});
it('requestNewCode toasts the mint-cooldown error on 429', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const mint = vi.fn(async () => ({
status: 429,
ok: false,
message: 'Too many requests. Wait before requesting a new code.'
}));
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
await comp.requestNewCode();
expect(toastMocks.toast.error).toHaveBeenCalledWith(
'Too many requests. Wait before requesting a new code.'
);
});
it('requestNewCode toasts the delivery-unavailable error on 503', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const mint = vi.fn(async () => ({
status: 503,
ok: false,
message: 'Verification codes are unavailable right now. Try again later.'
}));
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
await comp.requestNewCode();
expect(toastMocks.toast.error).toHaveBeenCalledWith(
'Verification codes are unavailable right now. Try again later.'
);
});
it('requestNewCode defaults to the session mint when none is supplied', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true
});
await comp.requestNewCode();
expect(toastMocks.toast.success).toHaveBeenCalled();
});
it('requestNewCode guards against concurrent mints', async () => {
const { useTwoFactorCodeForSavedCard } = await loadComposable();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const mint = vi.fn(async () => {
await gate;
return { status: 200, ok: true, message: 'slow' };
});
const comp = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => true,
mint
});
const first = comp.requestNewCode();
const second = comp.requestNewCode();
release();
await Promise.all([first, second]);
expect(mint).toHaveBeenCalledTimes(1);
});
});