Fix frontend payment flows: BookingFlow fetch loop, shared TipPayment, card icons, terms route

Fix the P0 infinite refetch in BookingFlow (payment-methods fetched once via a guard flag, was looping on empty saved-card arrays and DoS-ing the rate limiter). Extract the shared TipPayment component so tip and pay-tip routes no longer drift; reconcile formatTimeRange override_duration_minutes and subtotal/tipsPaid. CardBrandIcon gains the correct Square enum keys (DISCOVER_DINERS, CHINA_UNIONPAY). PaymentModal reads card_last4. Login links resolve to the new /terms and /privacy-policy routes. Add frontend/.env.example.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 738f6b6a51
commit e5c6458ec7
9 changed files with 749 additions and 757 deletions
@@ -95,6 +95,12 @@
}>
>([]);
let paymentMethodsLoading = $state(false);
// Once-per-payment-step fetch guard. Without it, the effect below re-runs on
// every state change and re-assigns a FRESH paymentMethods array (even an
// empty one), which re-triggers the effect → infinite refetch loop when the
// user has zero saved cards. Set synchronously BEFORE the await so re-entry
// is impossible even while the request is in flight.
let paymentMethodsFetched = $state(false);
let selectedPaymentMethod = $state('');
let paymentCardSelection = $state<CardSelection | null>(null);
let paymentCardSelectionValid = $state(false);
@@ -448,9 +454,22 @@
}
});
// Fetch saved cards when the deposit payment step is shown.
// Fetch saved cards when the deposit payment step is shown. The
// paymentMethodsFetched guard makes this run exactly ONCE per mount: the
// effect body re-runs on unrelated state changes, but the guard short-
// circuits before fetchPaymentMethods() can read/write any tracked state,
// so no fetch can be triggered by the empty-array assignment (the root
// cause of the infinite refetch loop for users with zero saved cards).
// Once-per-mount is acceptable — a user who navigates away and back keeps
// the already-loaded card list.
$effect(() => {
if (currentStep === finalStep && depositRequired && authStore.isAuthenticated) {
if (
currentStep === finalStep &&
depositRequired &&
authStore.isAuthenticated &&
!paymentMethodsFetched
) {
paymentMethodsFetched = true;
fetchPaymentMethods();
}
});
@@ -1,6 +1,12 @@
<script lang="ts">
let { brand = '' }: { brand?: string } = $props();
// Square's card_brand enum uses DISCOVER_DINERS and CHINA_UNIONPAY — the
// historical DINERS_CLUB / UNIONPAY / CHINA_UNION_PAY keys are kept as
// aliases in case anything else in the app passes those spellings.
const dinersClubSvg = `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#004A98"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="9">DC</text></svg>`;
const unionPaySvg = `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`;
const brandSvgs: Record<string, string> = {
VISA: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#1A1F71"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="11">VISA</text></svg>`,
@@ -10,15 +16,16 @@
DISCOVER: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#000"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">DISCOVER</text></svg>`,
DINERS_CLUB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#004A98"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="9">DC</text></svg>`,
DINERS_CLUB: dinersClubSvg,
DISCOVER_DINERS: dinersClubSvg,
JCB: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#0D4A2E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="10">JCB</text></svg>`,
SQUARE_GIFT_CARD: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#E8F5E9"/><rect x="1" y="1" width="58" height="22" rx="2" stroke="#4CAF50" stroke-width="0.5" stroke-dasharray="2 1"/><text x="30" y="16" text-anchor="middle" fill="#2E7D32" font-family="Arial, sans-serif" font-weight="bold" font-size="7">GIFT</text></svg>`,
CHINA_UNION_PAY: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`,
UNIONPAY: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#D7001E"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="7.5">UNION</text></svg>`,
CHINA_UNION_PAY: unionPaySvg,
UNIONPAY: unionPaySvg,
CHINA_UNIONPAY: unionPaySvg,
INTERAC: `<svg viewBox="0 0 60 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="60" height="24" rx="3" fill="#074CA1"/><text x="30" y="16" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-weight="bold" font-size="8">INTERAC</text></svg>`,
@@ -332,7 +332,10 @@
checkout_id: checkoutId!,
status: data.status,
card_brand: data.card_brand,
last4: data.last4,
// The API serializes the last-four as card_last4 (see
// PaymentStatusResponse); the success screen reads
// paymentResult.last4, so keep the local field name.
last4: data.card_last4,
amount: data.amount
};
toast.success('Payment successful');
@@ -0,0 +1,437 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
import { onMount } from 'svelte';
// 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
// here; everything else — tip selection, CardSelection wiring, nonce + SCA
// verification caching, idempotency-key derivation, submitTip, success and
// error handling — lives in ONE place so the two pages can't diverge.
type BookingService = {
service_id: string;
booking_id: string;
service_name: string;
price: number;
duration_minutes: number;
override_price?: number;
override_duration_minutes?: number;
};
type Payment = {
id: string;
payment_type: string;
payment_method: string;
status: string;
amount: number;
created_at: string;
};
type Booking = {
id: string;
start_time: string;
services: BookingService[];
total_amount: number;
duration_minutes: number;
payments?: Payment[];
};
const { booking }: { booking: Booking } = $props();
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Cached idempotency key: generated once per payment attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on
// success. Reset when the tip amount changes so an amount change after a
// failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0);
// Card selection — delegated to CardSelection.svelte (saved-card list,
// "Use a new card" toggle, SquareCardInput tokenization, consent checkbox).
let savedCards = $state<SavedCard[]>([]);
let cardSelection = $state<CardSelection | null>(null);
let selectedCardId = $state('');
let cardSelectionValid = $state(false);
let saveCard = $state(false);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let tipNonce = $state('');
// Cached SCA verification token paired with tipNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the tip invalidates the cached pair.
let tipVerificationToken = $state('');
let tipTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// 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 isCardValid = $derived(cardSelectionValid);
let selectedTip = $state<number | null>(null);
let customTip = $state('');
const tipAmount = $derived(
selectedTip !== null ? selectedTip : customTip ? parseFloat(customTip) || 0 : 0
);
// Sum of completed tip payments already made against this booking. Both
// routes' responses carry `payments` (the booking-detail endpoint), so this
// is computed identically everywhere.
const tipsPaid = $derived(
booking.payments
?.filter((p) => p.status === 'completed' && p.payment_type === 'tip')
.reduce((sum, p) => sum + p.amount, 0) ?? 0
);
const subtotal = $derived(booking.total_amount ?? 0);
const tipPercentages = $derived.by(() => {
if (subtotal <= 0) return [];
return [
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
];
});
function formatDate(dateStr: string): string {
const date = new SvelteDate(dateStr);
return date.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
// Honours per-service override_duration_minutes when computing the end time,
// falling back to the booking's duration_minutes (reconciled from the
// pay-tip route, which is the correct behaviour when durations are edited).
function formatTimeRange(
startStr: string,
services: BookingService[],
fallbackDuration: number
): string {
const start = new SvelteDate(startStr);
const totalMinutes =
services?.reduce(
(sum, s) => sum + (s.override_duration_minutes ?? s.duration_minutes ?? 0),
0
) ??
fallbackDuration ??
0;
const end = new SvelteDate(start.getTime() + totalMinutes * 60000);
const formatOpt: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
hour12: true
};
return `${start.toLocaleTimeString('en-GB', formatOpt)} ${end.toLocaleTimeString('en-GB', formatOpt)}`;
}
function formatPrice(pounds: number): string {
return ${pounds.toFixed(2)}`;
}
function selectTip(amount: number) {
selectedTip = amount;
customTip = '';
}
function handleCustomTipInput(e: Event) {
const input = e.target as HTMLInputElement;
const cleaned = input.value.replace(/[^0-9.]/g, '');
const firstDot = cleaned.indexOf('.');
let sanitized: string;
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
sanitized = integerPart + '.' + decimalPart;
} else {
sanitized = cleaned;
}
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
customTip = sanitized;
}
selectedTip = null;
}
async function loadSavedCards() {
if (savedCardsStore.loaded) {
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
return;
}
try {
await savedCardsStore.fetch();
savedCards = savedCardsStore.cards;
if (savedCards.length > 0 && !selectedCardId) {
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
}
} catch {
// ignore — user can enter new card
}
}
onMount(() => {
loadSavedCards();
});
async function submitTip() {
if (tipAmount <= 0) {
toast.error('Please select a tip amount');
return;
}
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
// saved card — nothing to tokenize
} else if (cardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// 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) {
try {
const tokenized = await cardSelection.tokenizeWithVerification(
Math.round(tipAmount * 100),
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
}
);
tipNonce = tokenized.nonce;
tipVerificationToken = tokenized.verificationToken ?? '';
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = tipNonce;
verificationToken = tipVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
}
paymentState = 'processing';
try {
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount;
}
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
};
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
}
paymentState = 'success';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
toast.success('Thank you for your tip!');
} catch (err) {
paymentState = 'error';
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
toast.error(errorMessage);
}
}
function retryPayment() {
paymentState = 'idle';
}
</script>
{#if paymentState === 'success'}
<Card.Root>
<Card.Content class="py-8 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100"
>
<svg
class="h-8 w-8 text-green-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900">Thank you!</h2>
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
</Card.Content>
</Card.Root>
{:else}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Your Appointment</Card.Title>
</Card.Header>
<Card.Content class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-500">Date</span>
<span class="font-medium">{formatDate(booking.start_time)}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Time</span>
<span class="font-medium"
>{formatTimeRange(
booking.start_time,
booking.services,
booking.duration_minutes ?? 0
)}</span
>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Subtotal</span>
<span class="font-medium">{formatPrice(subtotal)}</span>
</div>
{#if tipsPaid > 0}
<div class="flex justify-between">
<span class="text-sm text-gray-500">Tips</span>
<span class="font-medium">{formatPrice(tipsPaid)}</span>
</div>
{/if}
{#if booking.services && booking.services.length > 0}
<div class="border-t pt-3">
<div class="text-sm text-gray-500">Services</div>
<div class="mt-2 space-y-1">
{#each booking.services as service (service.service_id || service.booking_id)}
<div class="flex justify-between text-sm">
<span class="text-gray-700">{service.service_name}</span>
<span class="text-gray-500"
>{formatPrice(service.override_price ?? service.price)}</span
>
</div>
{/each}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Choose Tip Amount</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-3 gap-3">
{#each tipPercentages as tip (tip.pct)}
<button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTip ===
tip.amount
? 'bg-fuchsia-100'
: ''}"
onclick={() => selectTip(tip.amount)}
type="button"
>
<div>{formatPrice(tip.amount)}</div>
<div class="text-xs font-normal text-gray-500">{tip.pct}%</div>
</button>
{/each}
</div>
<div>
<label for="custom-tip" class="text-sm font-medium text-gray-700"
>Or enter custom amount</label
>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="custom-tip"
type="text"
inputmode="decimal"
step="0.01"
min="0"
placeholder="0.00"
class="pl-7"
value={customTip}
oninput={handleCustomTipInput}
/>
</div>
</div>
</Card.Content>
</Card.Root>
<!-- Payment Method -->
<Card.Root class="mb-6">
<Card.Content class="space-y-4">
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
<CardSelection
bind:this={cardSelection}
cards={savedCards}
{canSaveCards}
bind:selectedCardId
bind:saveCard
onValidityChange={(v) => (cardSelectionValid = v)}
/>
</div>
</Card.Content>
</Card.Root>
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
</div>
{/if}
<Button
class="w-full"
size="lg"
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
loading={paymentState === 'processing'}
onclick={submitTip}
>
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
{/if}