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 type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
|
import { canSaveCardsForRole, isNonceStale } from '$lib/square/square';
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -164,9 +165,7 @@
|
|||||||
// on late retries and re-tokenized instead of rejected by Square.
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
let tipTokenizedAt = $state(0);
|
let tipTokenizedAt = $state(0);
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const isTipCardValid = $derived(tipCardSelectionValid);
|
const isTipCardValid = $derived(tipCardSelectionValid);
|
||||||
|
|
||||||
@@ -243,7 +242,7 @@
|
|||||||
// verification token on retry (tokenization is one-shot; the backend
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
// idempotency key dedups). The verification token is amount-bound, so
|
// idempotency key dedups). The verification token is amount-bound, so
|
||||||
// a changed tip amount forces a fresh tokenization.
|
// a changed tip amount forces a fresh tokenization.
|
||||||
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
|
if (!tipNonce || isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)) {
|
||||||
try {
|
try {
|
||||||
const tokenized = await tipCardSelection.tokenizeWithVerification(
|
const tokenized = await tipCardSelection.tokenizeWithVerification(
|
||||||
Math.round(tipAmount * 100),
|
Math.round(tipAmount * 100),
|
||||||
|
|||||||
@@ -38,9 +38,14 @@
|
|||||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { POLICY } from '$lib/constants/policy';
|
import { POLICY } from '$lib/constants/policy';
|
||||||
|
import { canSaveCardsForRole, isNonceStale } from '$lib/square/square';
|
||||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
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 { generateUUID } from '$lib/utils/uuid';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -127,9 +132,7 @@
|
|||||||
// Payment flow state
|
// Payment flow state
|
||||||
let depositPaid = $state(false);
|
let depositPaid = $state(false);
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||||
|
|
||||||
@@ -328,7 +331,7 @@
|
|||||||
// created so the SCA verification amount matches the exact charge.
|
// created so the SCA verification amount matches the exact charge.
|
||||||
// The verification token is amount-bound, so a changed deposit
|
// The verification token is amount-bound, so a changed deposit
|
||||||
// amount forces a fresh tokenization.
|
// amount forces a fresh tokenization.
|
||||||
if (!depositNonce || depositTokenAmount !== amountCents || Date.now() - depositTokenizedAt > 240_000) {
|
if (!depositNonce || isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)) {
|
||||||
try {
|
try {
|
||||||
const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, {
|
const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, {
|
||||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||||
@@ -1409,12 +1412,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDayWithOrdinal(date: CalendarDate): string {
|
function getDayWithOrdinal(date: CalendarDate): string {
|
||||||
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
|
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
|
||||||
'en-GB',
|
|
||||||
{
|
|
||||||
month: 'long'
|
month: 'long'
|
||||||
}
|
});
|
||||||
);
|
|
||||||
const day = date.day;
|
const day = date.day;
|
||||||
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
|
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
|
||||||
switch (day % 10) {
|
switch (day % 10) {
|
||||||
@@ -2452,7 +2452,7 @@
|
|||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
showPayEarlyModal = false;
|
showPayEarlyModal = false;
|
||||||
}}
|
}}
|
||||||
canSaveCards={authStore.isAuthenticated}
|
{canSaveCards}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { onMount } from 'svelte';
|
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
|
// 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
|
// 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.
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
let tipTokenizedAt = $state(0);
|
let tipTokenizedAt = $state(0);
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
|
||||||
);
|
|
||||||
|
|
||||||
const isCardValid = $derived(cardSelectionValid);
|
const isCardValid = $derived(cardSelectionValid);
|
||||||
|
|
||||||
@@ -211,7 +210,7 @@
|
|||||||
// verification token on retry (tokenization is one-shot; the backend
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
// idempotency key dedups). The verification token is amount-bound, so
|
// idempotency key dedups). The verification token is amount-bound, so
|
||||||
// a changed tip amount forces a fresh tokenization.
|
// a changed tip amount forces a fresh tokenization.
|
||||||
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
|
if (!tipNonce || isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)) {
|
||||||
try {
|
try {
|
||||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||||
Math.round(tipAmount * 100),
|
Math.round(tipAmount * 100),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
|
import { isNonceStale } from '$lib/square/square';
|
||||||
|
|
||||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||||
|
|
||||||
@@ -373,7 +374,7 @@
|
|||||||
// is one-shot; the backend idempotency key dedups). The verification
|
// is one-shot; the backend idempotency key dedups). The verification
|
||||||
// token is amount-bound, so a changed amount forces a fresh
|
// token is amount-bound, so a changed amount forces a fresh
|
||||||
// tokenization.
|
// tokenization.
|
||||||
if (!newCardNonce || newCardTokenAmount !== amountCents || Date.now() - newCardTokenizedAt > 240_000) {
|
if (!newCardNonce || isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)) {
|
||||||
try {
|
try {
|
||||||
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
|
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
|
||||||
givenName: authStore.currentUser?.firstName,
|
givenName: authStore.currentUser?.firstName,
|
||||||
|
|||||||
@@ -18,6 +18,32 @@ export interface SquareConfig {
|
|||||||
locationId: string;
|
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'
|
/** 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
|
* 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. */
|
* 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 CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.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 { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||||
@@ -68,9 +68,7 @@
|
|||||||
// =============== Tab State ===============
|
// =============== Tab State ===============
|
||||||
let activeTab = $state<'general' | 'history' | 'referral' | 'cards' | 'admin'>('general');
|
let activeTab = $state<'general' | 'history' | 'referral' | 'cards' | 'admin'>('general');
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
|
||||||
);
|
|
||||||
|
|
||||||
type Booking = {
|
type Booking = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -285,7 +283,7 @@
|
|||||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||||
// verification token on retry (tokenization is one-shot; the backend
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
// idempotency key dedups).
|
// idempotency key dedups).
|
||||||
if (!buyNonce || buyTokenAmount !== buyAmount * 100 || Date.now() - buyTokenizedAt > 240_000) {
|
if (!buyNonce || isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)) {
|
||||||
try {
|
try {
|
||||||
const tokenized = await buyCardSelection.tokenizeWithVerification(buyAmount * 100, {
|
const tokenized = await buyCardSelection.tokenizeWithVerification(buyAmount * 100, {
|
||||||
givenName: userData?.firstName,
|
givenName: userData?.firstName,
|
||||||
@@ -968,7 +966,9 @@
|
|||||||
if (!aUnpaid && bUnpaid) return 1;
|
if (!aUnpaid && bUnpaid) return 1;
|
||||||
|
|
||||||
// If both have same payment status, sort by Date DESC (newest first)
|
// 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;
|
pastBookings = bookings;
|
||||||
@@ -1894,8 +1894,8 @@
|
|||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Saved Cards</Card.Title>
|
<Card.Title>Saved Cards</Card.Title>
|
||||||
<Card.Description>
|
<Card.Description>
|
||||||
Manage your saved payment methods — cards are stored securely with our
|
Manage your saved payment methods — cards are stored securely with our payment
|
||||||
payment provider (Square).
|
provider (Square).
|
||||||
<PolicyPopover label="privacy policy" href="/privacy-policy" />
|
<PolicyPopover label="privacy policy" href="/privacy-policy" />
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
@@ -2237,7 +2237,9 @@
|
|||||||
>
|
>
|
||||||
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
||||||
</Button>
|
</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}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
Reference in New Issue
Block a user