fix: frontend payment surfaces — SCA wire shapes (explicit token precedence), mock token parity, infinite-loop guard, money display, delete-account re-auth, admin progress UI, mobile touch targets

- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent f9e8385d5a
commit 01e7cc575d
34 changed files with 1154 additions and 665 deletions
+133 -3
View File
@@ -17,6 +17,7 @@ import {
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
newCardTokenizeResult,
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
sanitizeDecimalInput,
@@ -427,6 +428,37 @@ describe('parseTokenizeVerificationResult', () => {
});
});
describe('new-card tokenizeWithVerification contract (dev/mock parity with the real SDK)', () => {
// The real Square Web Payments SDK returns `{ nonce, verificationToken:
// null }` from a NEW-card tokenizeWithVerification — the SCA-verified cnon
// nonce IS the charge source, and a separate verificationToken exists only
// on the saved-card-on-file SCA flow. The dev mock must mirror this exactly:
// the charge surfaces send `new_card_token: newCardToken ?? verificationToken`
// and gate `save_card: X && !verificationToken`, so an always-non-null mock
// verificationToken silently suppressed "Save this card for next time" on
// new-card flows in dev. MockCardForm.tokenizeWithVerification builds its
// result through newCardTokenizeResult (the shared single source), so these
// tests pin the contract the mock and the real form both honour.
it('returns the cnon nonce as the charge source and a NULL verificationToken', () => {
expect(newCardTokenizeResult('cnon:test-card')).toEqual({
nonce: 'cnon:test-card',
verificationToken: null
});
});
it('keeps the cnon: nonce prefix the backend dev mock accepts as a charge source', () => {
expect(newCardTokenizeResult('cnon:visa').nonce).toMatch(/^cnon:/);
});
it('pins the save_card gate: the nonce wins the charge source and the save stays live', () => {
const { nonce, verificationToken } = newCardTokenizeResult('cnon:test-card');
// `new_card_token: newCardToken ?? verificationToken` → the nonce is used.
expect(nonce ?? verificationToken).toBe('cnon:test-card');
// `save_card: X && !verificationToken` → the save is honoured (regression pin).
expect(verificationToken).toBeNull();
});
});
describe('isTwoFactorVerificationGateFailure', () => {
it.each([
[403, 'A two-factor verification code is required to use this saved card', true],
@@ -778,7 +810,7 @@ describe('runSavedCardSCAProactively', () => {
it('maps a verified tokenize result and records it via onOutcome', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: 'verify_mock_ok',
verificationToken: 'cnon:sca-test_5000_ok',
outcome: 'verified'
});
const mod = await loadSquareInMockMode();
@@ -789,7 +821,7 @@ describe('runSavedCardSCAProactively', () => {
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toBe('verify_mock_ok');
expect(res.verificationToken).toBe('cnon:sca-test_5000_ok');
expect(outcomes).toEqual(['verified']);
});
@@ -865,9 +897,107 @@ describe('runSavedCardSCAProactively', () => {
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toMatch(/^verify_mock_/);
expect(res.verificationToken).toMatch(/^cnon:sca-/);
expect(outcomes).toEqual(['verified']);
});
it('mints saved-card SCA tokens the backend mock accepts (cnon:sca- prefix)', async () => {
// The backend dev mock's saved-card SCA gate (square_dev.go
// isSCATokenizeResultSource) only accepts a GENUINE tokenize-result —
// a cnon: source carrying the `cnon:sca-` marker. The dev frontend's
// deterministic token must match that prefix so the mock charge lands
// in dev exactly as a real Square tokenize-result would.
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:mock_123',
onOutcome: () => {}
});
expect(res.verificationToken).toMatch(/^cnon:sca-mock_5000_ok$/);
});
});
describe('dev mock token-shape parity with the backend gate', () => {
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();
});
// The backend dev mock's isSCATokenizeResultSource accepts a cnon: source
// ONLY when it carries the `cnon:sca-` marker (or the legacy verify_mock_
// transition shape). The frontend's deterministic fallback token must stay
// inside that accepted set or dev saved-card charges fail at the mock.
it('the deterministic token satisfies the backend isSCATokenizeResultSource predicate', async () => {
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 2500,
squareCardId: 'ccof:1234567890',
onOutcome: () => {}
});
expect(res.outcome).toBe('verified');
// Predicate parity — the same test the backend mock runs:
// isSCATokenizeResultSource = cnon:sca-* || verify_mock_*.
expect(res.verificationToken).toMatch(/^cnon:sca-/);
});
it('derives the token prefix from the first 4 chars of the ccof card id', async () => {
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 2500,
squareCardId: 'ccof:1234567890',
onOutcome: () => {}
});
expect(res.verificationToken).toBe('cnon:sca-1234_2500_ok');
});
it('falls back to the test prefix when the card id yields no 4-char prefix', async () => {
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 2500,
squareCardId: 'ccof:',
onOutcome: () => {}
});
expect(res.verificationToken).toBe('cnon:sca-test_2500_ok');
});
});
describe('backend 402 error-body parity (errors.go writeVerificationRequiredResponse)', () => {
it('VERIFICATION_REQUIRED_MESSAGE matches the backend response text verbatim', () => {
// backend/handlers/payments/errors.go writes:
// {"error": "Your card issuer requires verification. Approve this
// payment in your banking app.", "code": "verification_required"}
// The frontend must key the SCA challenge on the byte-identical copy.
expect(VERIFICATION_REQUIRED_MESSAGE).toBe(
'Your card issuer requires verification. Approve this payment in your banking app.'
);
});
it('isVerificationRequiredSignal detects the exact structured 402 body the backend writes', () => {
const backendBody = JSON.stringify({
error: 'Your card issuer requires verification. Approve this payment in your banking app.',
code: 'verification_required'
});
expect(isVerificationRequiredSignal(402, backendBody)).toBe(true);
});
it('isVerificationRequiredSignal rejects a backend body carrying a different code', () => {
const body = JSON.stringify({ error: 'Payment failed', code: 'card_declined' });
expect(isVerificationRequiredSignal(402, body)).toBe(false);
});
});
// The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs: