Files
Crussell/frontend/src/lib/components/payments/TipPayment.svelte
T
popertots 965da86b64 Re-tokenize fresh after a definitive card charge failure in all nonce flows
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.
2026-08-22 00:34:49 +01:00

446 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}