A cnon: nonce and its SCA verification token are consumed by a definitive charge failure (e.g. declined card) and can never succeed again, but TipPayment, UserBookingModal, UserPaymentModal and the account-page Buy-a-Gift-Card cached them and resubmitted the dead nonce on every retry — a non-retryable failure loop. The nonce/verification-token/amount/timestamp cache is now cleared in each error branch so retries re-tokenize fresh, while the idempotency key is kept for network-timeout dedup.
446 lines
14 KiB
Svelte
446 lines
14 KiB
Svelte
<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);
|
||
// A definitive charge failure (e.g. declined card) consumes the nonce
|
||
// and SCA verification token — they can never succeed again. Clear the
|
||
// cached pair so the next retry re-tokenizes fresh. The idempotency
|
||
// key is kept: it's still correct for network-timeout dedup.
|
||
tipNonce = '';
|
||
tipVerificationToken = '';
|
||
tipTokenAmount = 0;
|
||
tipTokenizedAt = 0;
|
||
}
|
||
}
|
||
|
||
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}
|