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:
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 @@
|
||||
<Card.Header>
|
||||
<Card.Title>Saved Cards</Card.Title>
|
||||
<Card.Description>
|
||||
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).
|
||||
<PolicyPopover label="privacy policy" href="/privacy-policy" />
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
@@ -2237,7 +2237,9 @@
|
||||
>
|
||||
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
||||
</Button>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Square
|
||||
</p>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
Reference in New Issue
Block a user