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 @@
Secure payment powered by Square
++ Secure payment powered by Square +
{/if}