diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 236d312..3a97f6f 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -16,6 +16,7 @@ import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; import CardSelection from '$lib/components/payments/CardSelection.svelte'; import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte'; + import { canSaveCardsForRole, isNonceStale } from '$lib/square/square'; interface Props { open: boolean; bookingId: string; @@ -164,9 +165,7 @@ // on late retries and re-tokenized instead of rejected by Square. let tipTokenizedAt = $state(0); - const canSaveCards = $derived( - authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' - ); + const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role)); const isTipCardValid = $derived(tipCardSelectionValid); @@ -243,7 +242,7 @@ // verification token on retry (tokenization is one-shot; the backend // idempotency key dedups). The verification token is amount-bound, so // a changed tip amount forces a fresh tokenization. - if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) { + if (!tipNonce || isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)) { try { const tokenized = await tipCardSelection.tokenizeWithVerification( Math.round(tipAmount * 100), diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 716fc3e..5f538c1 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -38,9 +38,14 @@ import CardSelection from '$lib/components/payments/CardSelection.svelte'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import { POLICY } from '$lib/constants/policy'; + import { canSaveCardsForRole, isNonceStale } from '$lib/square/square'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection'; - import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots'; + import { + formatLocalDateTime, + getLondonTodayCalendarDate, + parseWallClockDate + } from '$lib/utils/timeSlots'; import { generateUUID } from '$lib/utils/uuid'; import type { @@ -127,9 +132,7 @@ // Payment flow state let depositPaid = $state(false); - const canSaveCards = $derived( - authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' - ); + const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role)); const depositCardFormValid = $derived(paymentCardSelectionValid); @@ -328,7 +331,7 @@ // created so the SCA verification amount matches the exact charge. // The verification token is amount-bound, so a changed deposit // amount forces a fresh tokenization. - if (!depositNonce || depositTokenAmount !== amountCents || Date.now() - depositTokenizedAt > 240_000) { + if (!depositNonce || isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)) { try { const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, { givenName: customerInfo.firstName || authStore.currentUser?.firstName, @@ -1409,12 +1412,9 @@ } function getDayWithOrdinal(date: CalendarDate): string { - const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString( - 'en-GB', - { - month: 'long' - } - ); + const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', { + month: 'long' + }); const day = date.day; if (day > 3 && day < 21) return monthName + ' ' + day + 'th'; switch (day % 10) { @@ -2452,7 +2452,7 @@ onComplete={() => { showPayEarlyModal = false; }} - canSaveCards={authStore.isAuthenticated} + {canSaveCards} /> {/if} {/if} diff --git a/frontend/src/lib/components/payments/TipPayment.svelte b/frontend/src/lib/components/payments/TipPayment.svelte index a61f8a3..c790ad8 100644 --- a/frontend/src/lib/components/payments/TipPayment.svelte +++ b/frontend/src/lib/components/payments/TipPayment.svelte @@ -11,6 +11,7 @@ import { Input } from '$lib/components/ui/input'; import * as Card from '$lib/components/ui/card'; import { onMount } from 'svelte'; + import { canSaveCardsForRole, isNonceStale } from '$lib/square/square'; // Shared tip-payment UI used by /tip and /pay-tip/[id]. The routes resolve // the booking (most-recent past booking vs. booking by URL id) and hand it @@ -77,9 +78,7 @@ // on late retries and re-tokenized instead of rejected by Square. let tipTokenizedAt = $state(0); - const canSaveCards = $derived( - authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' - ); + const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role)); const isCardValid = $derived(cardSelectionValid); @@ -211,7 +210,7 @@ // verification token on retry (tokenization is one-shot; the backend // idempotency key dedups). The verification token is amount-bound, so // a changed tip amount forces a fresh tokenization. - if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) { + if (!tipNonce || isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)) { try { const tokenized = await cardSelection.tokenizeWithVerification( Math.round(tipAmount * 100), diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index e4e0626..ec66b7f 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -12,6 +12,7 @@ import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { apiFetch } from '$lib/utils/api'; + import { isNonceStale } from '$lib/square/square'; const LOYALTY_DISCOUNT_RATE = 0.1; @@ -373,7 +374,7 @@ // is one-shot; the backend idempotency key dedups). The verification // token is amount-bound, so a changed amount forces a fresh // tokenization. - if (!newCardNonce || newCardTokenAmount !== amountCents || Date.now() - newCardTokenizedAt > 240_000) { + if (!newCardNonce || isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)) { try { const tokenized = await cardSelection.tokenizeWithVerification(amountCents, { givenName: authStore.currentUser?.firstName, diff --git a/frontend/src/lib/square/square.ts b/frontend/src/lib/square/square.ts index 73dcf19..9860a07 100644 --- a/frontend/src/lib/square/square.ts +++ b/frontend/src/lib/square/square.ts @@ -18,6 +18,32 @@ export interface SquareConfig { locationId: string; } +// Square card nonces are single-use, and the SCA verification token issued with +// them is amount-bound. A cached nonce older than this is treated as stale and +// forces a fresh tokenization before the charge attempt. +export const NONCE_STALENESS_MS = 240_000; + +/** + * True when a user's role is allowed to save cards for reuse. Only VERIFIED + * accounts (verified_email, admin) may save cards — guests, unverified accounts + * and affiliates must never see the save-card option. Single source of truth so + * the predicate can't drift between booking, account and tip surfaces. + */ +export function canSaveCardsForRole(role: string | undefined): boolean { + return role === 'verified_email' || role === 'admin'; +} + +/** True when a cached card nonce can no longer be reused: it was tokenized for a + * different amount than `amount`, or it is older than NONCE_STALENESS_MS. */ +export function isNonceStale( + nonceTokenizedAt: number, + nonceTokenizedFor: number, + amount: number, + now: number = Date.now() +): boolean { + return nonceTokenizedFor !== amount || now - nonceTokenizedAt > NONCE_STALENESS_MS; +} + /** True when the frontend runs in local-dev mock mode: VITE_SQUARE_ENVIRONMENT === 'mock' * AND the dev build (import.meta.env.DEV). The DEV gate makes the mock structurally * impossible in any production bundle — even if the env var is mis-set at build time. */ diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 1788ae5..62887e4 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -8,7 +8,7 @@ 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 { isSquareConfigured } from '$lib/square/square'; + import { canSaveCardsForRole, isNonceStale, isSquareConfigured } from '$lib/square/square'; import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe'; import { apiFetch } from '$lib/utils/api'; import UserBookingModal from '$lib/components/account/UserBookingModal.svelte'; @@ -68,9 +68,7 @@ // =============== Tab State =============== let activeTab = $state<'general' | 'history' | 'referral' | 'cards' | 'admin'>('general'); - const canSaveCards = $derived( - authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' - ); + const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role)); type Booking = { id: string; @@ -285,7 +283,7 @@ // New-card mode: tokenize once per attempt, reuse the nonce + SCA // verification token on retry (tokenization is one-shot; the backend // idempotency key dedups). - if (!buyNonce || buyTokenAmount !== buyAmount * 100 || Date.now() - buyTokenizedAt > 240_000) { + if (!buyNonce || isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)) { try { const tokenized = await buyCardSelection.tokenizeWithVerification(buyAmount * 100, { givenName: userData?.firstName, @@ -968,7 +966,9 @@ if (!aUnpaid && bUnpaid) return 1; // If both have same payment status, sort by Date DESC (newest first) - return parseWallClockDate(b.start_time).getTime() - parseWallClockDate(a.start_time).getTime(); + return ( + parseWallClockDate(b.start_time).getTime() - parseWallClockDate(a.start_time).getTime() + ); }); pastBookings = bookings; @@ -1894,8 +1894,8 @@ Saved Cards - Manage your saved payment methods — cards are stored securely with our - payment provider (Square). + Manage your saved payment methods — cards are stored securely with our payment + provider (Square). @@ -2237,7 +2237,9 @@ > {buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`} -

Secure payment powered by Square

+

+ Secure payment powered by Square +

{/if}