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}