diff --git a/frontend/src/lib/square/square.test.ts b/frontend/src/lib/square/square.test.ts index a4cacff..b12e3c2 100644 --- a/frontend/src/lib/square/square.test.ts +++ b/frontend/src/lib/square/square.test.ts @@ -11,7 +11,6 @@ import { isSavedCardVerificationRequired, isTwoFactorVerificationGateFailure, requestNewTwoFactorCode, - requires2FACodeForSavedCard, sanitizeDecimalInput, submitPaymentWithRetry } from './square'; @@ -220,31 +219,6 @@ describe('payment failure classification', () => { }); }); -describe('requires2FACodeForSavedCard', () => { - it('is true when the session user has twoFactorRequired set', () => { - expect(requires2FACodeForSavedCard({ twoFactorRequired: true })).toBe(true); - }); - - it('is false when twoFactorRequired is unset or false', () => { - expect(requires2FACodeForSavedCard({ twoFactorRequired: false })).toBe(false); - expect(requires2FACodeForSavedCard({})).toBe(false); - }); - - it('is false for a null/undefined session user', () => { - expect(requires2FACodeForSavedCard(null)).toBe(false); - expect(requires2FACodeForSavedCard(undefined)).toBe(false); - }); - - it('does not depend on the 2FA setup flag (B6/B10: the code is required per charge)', () => { - expect(requires2FACodeForSavedCard({ twoFactorRequired: true, twoFactorEnabled: true })).toBe( - true - ); - expect(requires2FACodeForSavedCard({ twoFactorRequired: true, twoFactorEnabled: false })).toBe( - true - ); - }); -}); - describe('isTwoFactorVerificationGateFailure', () => { it.each([ [403, 'A two-factor verification code is required to use this saved card', true], diff --git a/frontend/src/lib/square/square.ts b/frontend/src/lib/square/square.ts index 12aaa90..2d43a53 100644 --- a/frontend/src/lib/square/square.ts +++ b/frontend/src/lib/square/square.ts @@ -118,22 +118,6 @@ export function isSavedCardVerificationRequired(status: number, usedSavedCard: b export const SAVED_CARD_VERIFICATION_MESSAGE = 'Your card issuer requires verification. Please pay with a new card or re-add your card.'; -/** - * B6/B10: whether charging a saved card requires the customer's current 2FA - * verification code. True when the session user has `twoFactorRequired` set — - * the profile exposes the backend's `twoFactorEnforced()` posture, so this is - * true for every session user in an enforced environment. Charge surfaces show - * the verification-code input whenever this is set; the backend additionally - * 403s saved-card charges when the code is missing, so surfaces must also - * reveal the input on that error. Single source of truth so the booking, - * account, tip and admin surfaces can't drift on the gate condition. - */ -export function requires2FACodeForSavedCard( - sessionUser: { twoFactorRequired?: boolean } | null | undefined -): boolean { - return !!sessionUser?.twoFactorRequired; -} - /** * True when a saved-card charge error is a 2FA verification-gate rejection * (backend/handlers/payments/twofa.go): 403 when no code was supplied or the diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index 6b270b3..734c3ab 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -78,7 +78,10 @@ class AuthStore { // yet enabled). The backend keys on the CARD OWNER — which the frontend // usually cannot know — so a 403 for a missing code must also surface the // input. Single source of truth so the predicate can't drift between the - // booking, account, tip and admin payment surfaces. + // booking, account, tip and admin payment surfaces. This getter is THE + // single source of truth for the 2FA gate — the former square.ts helper + // (requires2FACodeForSavedCard) was dead code and has been removed, so any + // surface gating saved-card charges must read this getter. get savedCardChargeRequires2FACode() { return !!this.user?.twoFactorRequired; } diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index f0b1cc0..a220d84 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -8,11 +8,14 @@ import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte'; import CardSelection from '$lib/components/payments/CardSelection.svelte'; import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte'; + import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import { canSaveCardsForRole, isNonceStale, isSavedCardVerificationRequired, isSquareConfigured, + isTwoFactorVerificationGateFailure, + requestNewTwoFactorCode, SAVED_CARD_VERIFICATION_MESSAGE, submitPaymentWithRetry } from '$lib/square/square'; @@ -246,6 +249,53 @@ // with the wrong intent. let buyTokenizedForSaveCard = $state(false); + // B6/B10: gift-card buys charge a saved card (or save a new card for reuse) + // whenever the backend enforces the 2FA gate — the CARD OWNER's current + // verification code must be carried on the charge. Mirrors the customer + // surface (UserPaymentModal). Kept populated across retries so an + // invalid/expired code can be corrected without re-typing it. + let buyTwoFactorCode = $state(''); + // Set true when a charge 403s for a missing code — reveals the input even + // if the session profile's 2FA flag is stale, making the failure + // recoverable. + let buyReveal2FACodeInput = $state(false); + const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); + const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); + // Show the code input whenever the pending charge hits the backend's 2FA + // gate: charging a saved card OR saving the new card for reuse. + const buyShow2FACodeInput = $derived( + buyReveal2FACodeInput || + (buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard)) + ); + const buyMissing2FACode = $derived( + buyShow2FACodeInput && buyTwoFactorEnabled && buyTwoFactorCode.trim() === '' + ); + + // POST /api/user/2fa/code mint state for the "Request a new code" button + // (session user = card owner, so a minted code authorizes their charge). + let buyRequesting2FACode = $state(false); + async function handleBuyRequestNew2FACode() { + if (buyRequesting2FACode) return; + buyRequesting2FACode = true; + try { + const result = await requestNewTwoFactorCode(); + if (result.ok) { + buyTwoFactorCode = ''; + toast.success(result.message); + } else if (result.status === 429) { + toast.error(result.message || 'Too many requests. Wait before requesting a new code.'); + } else if (result.status === 503) { + toast.error( + result.message || 'Verification codes are unavailable right now. Try again later.' + ); + } else { + toast.error(result.message); + } + } finally { + buyRequesting2FACode = false; + } + } + // Client-side mirror of the £500/day online purchase cap. The backend is // authoritative — this counter only reflects confirmed purchases made in // this session, so a user is told they've hit the cap instead of being @@ -456,12 +506,13 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - amount: buyAmount * 100, // cents + amount: buyAmount * 100, // pence recipient_type: buyRecipientType, recipient_email: buyRecipientEmail, ...(cardId ? { card_id: cardId } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}), ...(verificationToken ? { verification_token: verificationToken } : {}), + ...(buyShow2FACodeInput ? { verification_code: buyTwoFactorCode } : {}), idempotency_key: buyIdempotencyKey }) }) @@ -482,6 +533,8 @@ buyTokenAmount = 0; buyTokenizedAt = 0; buyTokenizedForSaveCard = false; + buyTwoFactorCode = ''; + buyReveal2FACodeInput = false; await fetchGiftCardBalance(); } else { // Capture the status BEFORE consuming the body — the saved-card @@ -493,11 +546,16 @@ // requires verification — surface the fix instead of the generic // backend text. const verificationRequired = isSavedCardVerificationRequired(status, !!buySelectedCard); - toast.error( - verificationRequired - ? SAVED_CARD_VERIFICATION_MESSAGE - : extractErrorMessage(errText) || 'Failed to purchase gift card' - ); + // 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. + const buyErrMsg = verificationRequired + ? SAVED_CARD_VERIFICATION_MESSAGE + : extractErrorMessage(errText) || 'Failed to purchase gift card'; + if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) { + buyReveal2FACodeInput = true; + } + toast.error(buyErrMsg); // A definitive charge failure (e.g. declined card) consumes the // nonce + SCA verification token — clear the cached pair so the // next retry re-tokenizes fresh. The idempotency key stays for @@ -2572,12 +2630,32 @@ bind:saveCard={buySaveCard} onValidityChange={(v) => (buyCardSelectionValid = v)} /> + + + {#if buyShow2FACodeInput && buyTwoFactorEnabled} + + {/if}