Frontend: verified-only save-card gating + shared nonce-staleness helper

canSaveCardsForRole(role) in square.ts is the single source of truth for the
save-card product rule (verified_email, admin — never affiliate). All four
predicate sites (account page, UserBookingModal, BookingFlow, TipPayment) were
wrong before, excluding admin and including affiliate. The worst gap was
BookingFlow passing canSaveCards={authStore.isAuthenticated} to the Pay-Early
modal, which let unverified users save cards — it now passes the derived value.

isNonceStale() + NONCE_STALENESS_MS replace the 240s staleness check duplicated
five times, keeping the amount-bound re-tokenization semantics identical.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent d9c2c5ac2c
commit 05bb142cfd
6 changed files with 57 additions and 30 deletions
@@ -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),
@@ -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}
@@ -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),
@@ -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,