feat: user payment modal and tip route for online payments

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-25 18:05:14 +01:00
co-authored by Sisyphus
parent 97c9d1643f
commit 3ccc017716
2 changed files with 1170 additions and 0 deletions
@@ -0,0 +1,756 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import type { UserSavedCard } from '$lib/types';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
booking: Booking;
onClose: () => void;
onComplete: () => void;
canSaveCards?: boolean;
}
let { booking, onClose, onComplete, canSaveCards = true }: Props = $props();
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
let paymentResult = $state<{
id: string;
amount: number;
card_brand?: string;
card_last4?: string;
payment_type: string;
} | null>(null);
// Card selection state
let paymentMethods = $state<UserSavedCard[]>([]);
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let showNewCardForm = $state(false);
let showCardList = $state(false);
// Auto-select first saved card when methods load
$effect(() => {
if (paymentMethods.length > 0 && !selectedPaymentMethod && !showNewCardForm) {
const defaultCard = paymentMethods.find((m) => m.is_default) ?? paymentMethods[0];
selectedPaymentMethod = defaultCard.id;
}
});
// New card form fields
let newCardNumber = $state('');
let newCardExpiry = $state('');
let newCardCVC = $state('');
let saveCardForFuture = $state(false);
function formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 16);
const groups = digits.match(/.{1,4}/g);
return groups ? groups.join(' ') : digits;
}
function handleCardNumberInput(e: Event) {
const input = e.target as HTMLInputElement;
newCardNumber = formatCardNumber(input.value);
}
function formatExpiryDate(value: string): string {
const digits = value.replace(/\D/g, '').substring(0, 4);
if (digits.length >= 3) {
return digits.substring(0, 2) + '/' + digits.substring(2);
}
return digits;
}
function handleExpiryInput(e: Event) {
const input = e.target as HTMLInputElement;
newCardExpiry = formatExpiryDate(input.value);
}
function handleCvcInput(e: Event) {
const input = e.target as HTMLInputElement;
newCardCVC = input.value.replace(/\D/g, '').substring(0, 4);
}
function parseExpiryParts(value: string): { month: number; year: number } | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return null;
const [monthStr, yearStr] = value.split('/');
const month = parseInt(monthStr, 10);
const year = 2000 + parseInt(yearStr, 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
let expiryParts = $derived(parseExpiryParts(newCardExpiry));
let isExpiryInPast = $derived(
expiryParts !== null && (() => {
const expiryDate = new Date(expiryParts.year, expiryParts.month);
return expiryDate < new Date();
})()
);
let hasInvalidMonth = $derived(
/^\d{2}\/\d{2}$/.test(newCardExpiry) && expiryParts === null
);
let cardFormValid = $derived(
newCardNumber.replace(/\s/g, '').length >= 13 &&
expiryParts !== null &&
newCardCVC.length >= 3 &&
!isExpiryInPast
);
let cardSelected = $derived(
(selectedPaymentMethod !== null && paymentMethods.length > 0) ||
(showNewCardForm && cardFormValid) ||
(paymentMethods.length === 0 && cardFormValid)
);
let cardValidationError = $derived(
!cardSelected ? (
selectedPaymentMethod === null && paymentMethods.length > 0 && !showNewCardForm
? 'Please select a card'
: newCardNumber.replace(/\s/g, '').length < 13 && newCardNumber.length > 0
? 'Card number too short'
: hasInvalidMonth
? 'Invalid expiry month'
: isExpiryInPast
? 'Expiry date in the past'
: !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
: newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: paymentMethods.length === 0 && !showNewCardForm && newCardNumber.length === 0
? 'Please enter card details'
: 'Please complete all card fields'
) : null
);
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
let lastValidPartialAmount = $state<string>('');
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Derived values
let depositOutstanding = $derived(
booking.deposit_required && !booking.deposit_paid
);
let totalPaid = $derived(
booking.payments
?.filter((p) => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0) || 0
);
let amountRemaining = $derived(booking.total_amount - totalPaid);
let isScenarioA = $derived(depositOutstanding);
let isScenarioB = $derived(
!depositOutstanding && totalPaid < booking.total_amount
);
let partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
let partialAmountValid = $derived(
paymentType === 'partial' &&
partialAmount !== '' &&
!isNaN(partialAmountNum) &&
partialAmountNum > 0 &&
partialAmountNum <= amountRemaining &&
/^\d+(\.\d{0,2})?$/.test(partialAmount)
);
let partialValidationError = $derived(
paymentType === 'partial' && !partialAmountValid ? (
partialAmount === ''
? 'Enter an amount'
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
? 'Invalid amount format'
: partialAmountNum <= 0
? 'Amount must be greater than 0'
: partialAmountNum > amountRemaining
? 'Amount exceeds balance'
: 'Invalid amount'
) : null
);
let payButtonDisabled = $derived(
status === 'processing' || !cardSelected || (paymentType === 'partial' && !partialAmountValid)
);
let payButtonError = $derived(
status === 'processing' ? null : (
!cardSelected ? cardValidationError : (paymentType === 'partial' ? partialValidationError : null)
)
);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
function generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
return [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
async function fetchPaymentMethods() {
if (!authStore.isAuthenticated) return;
paymentMethodsLoading = true;
try {
const response = await fetch('/api/user/payment-methods', {
headers: {
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (response.ok) {
paymentMethods = await response.json();
}
} catch (err) {
console.error('Failed to fetch payment methods:', err);
} finally {
paymentMethodsLoading = false;
}
}
function formatCardExpiry(month: number, year: number): string {
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
}
function sanitizeAmountInput(value: string): string {
// Remove all non-numeric chars except .
const cleaned = value.replace(/[^0-9.]/g, '');
// Keep only the first .
const firstDot = cleaned.indexOf('.');
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
return integerPart + '.' + decimalPart;
}
return cleaned;
}
function handlePartialAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeAmountInput(input.value);
// Only update if the sanitized value passes the regex (max 2 decimal places)
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
partialAmount = sanitized;
lastValidPartialAmount = sanitized;
} else {
// Reject input with more than 2 decimal places — revert to last valid
partialAmount = lastValidPartialAmount;
input.value = lastValidPartialAmount;
}
}
async function makePayment(paymentType: string, amountCents: number) {
status = 'processing';
error = null;
let cardId: string | undefined;
let newCardToken: string | undefined;
let saveCard = false;
if (selectedPaymentMethod) {
cardId = selectedPaymentMethod;
} else if (newCardNumber) {
newCardToken = newCardNumber;
saveCard = saveCardForFuture;
} else {
status = 'error';
error = 'Please select or enter card details';
toast.error('Please select or enter card details');
return;
}
const idempotencyKey = generateIdempotencyKey();
try {
const response = await fetch(`/api/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: amountCents,
payment_type: paymentType,
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: idempotencyKey
})
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to initiate payment');
}
const data = await response.json();
// Payment is synchronous (completed immediately)
status = 'success';
paymentResult = {
id: data.id,
amount: data.amount,
card_brand: data.card_brand,
card_last4: data.card_last4,
payment_type: data.payment_type
};
toast.success('Payment successful');
onComplete();
} catch (err) {
status = 'error';
const msg = err instanceof Error ? err.message : 'Payment declined';
error = msg;
toast.error(`${msg}. Please try again or use another card.`);
}
}
function handlePayDeposit() {
const depositCents = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100);
makePayment('deposit', depositCents);
}
function handlePayFull() {
const fullCents = Math.round(booking.amount_due * 100);
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
makePayment(paymentType, fullCents);
}
function handlePayPartial() {
if (!partialAmountValid) {
toast.error('Please enter a valid amount');
return;
}
makePayment('partial', Math.round(partialAmountNum * 100));
}
function handleRetry() {
status = 'idle';
error = null;
}
function handleClose() {
stopPolling();
onClose();
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
// Fetch payment methods on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
fetchPaymentMethods();
}
});
// Cleanup on unmount
$effect(() => {
return () => {
stopPolling();
};
});
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="max-w-md max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id}
<div class="mt-1 text-sm text-gray-500">Booking ID: {booking.id}</div>
{/if}
</Dialog.Header>
{#if status === 'idle' || status === 'processing' || status === 'error'}
<div class="space-y-4">
<!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
<div class="space-y-2">
{#each booking.services ?? [] as service, index (index)}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<span class="font-medium">
{service.override_price ? formatCurrency(Math.round(service.override_price * 100)) : service.price ? formatCurrency(Math.round(service.price * 100)) : '-'}
</span>
</div>
{/each}
</div>
</div>
<!-- Financial Summary -->
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(Math.round(amountRemaining * 100))}
</span>
</div>
</div>
<!-- Card Selection (only when idle) -->
{#if status === 'idle' && authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0 && !showNewCardForm}
<!-- Saved card selected -->
<div class="space-y-3">
{#if selectedPaymentMethod}
{#each paymentMethods as method (method.id)}
{#if method.id === selectedPaymentMethod}
<div class="flex items-center justify-between rounded-lg border border-input bg-fuchsia-100 p-3">
<div class="flex items-center gap-3">
<div class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium">
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last_4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.exp_month, method.exp_year)}
</span>
</div>
</div>
<span class="text-xs font-medium text-foreground">Selected</span>
</div>
{/if}
{/each}
{/if}
{#if canSaveCards}
<Button
variant="ghost"
size="sm"
class="text-sm text-gray-600"
onclick={() => (showCardList = !showCardList)}
>
{showCardList ? 'Hide other cards' : 'Use a different card'}
</Button>
{/if}
{#if showCardList}
<div class="space-y-2">
{#if canSaveCards}
<div
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer hover:border-gray-300 transition-colors"
onclick={() => {
showNewCardForm = true;
showCardList = false;
selectedPaymentMethod = null;
}}
>
<div class="text-sm font-medium text-gray-700">Enter card details</div>
<svg class="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
{/if}
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer {selectedPaymentMethod === method.id ? 'border-input bg-fuchsia-100' : 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
showCardList = false;
}}
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium">
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last_4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.exp_month, method.exp_year)}
</span>
</div>
</div>
{#if selectedPaymentMethod === method.id}
<span class="text-xs font-medium text-foreground">Selected</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- No saved cards or can't save cards - show new card form -->
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700">Card Details</h4>
<div class="space-y-3">
<div>
<label for="cardNumber" class="text-sm font-medium text-gray-700">Card Number</label>
<Input
id="cardNumber"
type="text"
inputmode="numeric"
value={newCardNumber}
oninput={handleCardNumberInput}
placeholder="1234 5678 9012 3456"
maxlength={19}
class="mt-1"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="cardExpiry" class="text-sm font-medium text-gray-700">Expiry (MM/YY)</label>
<Input
id="cardExpiry"
type="text"
inputmode="numeric"
value={newCardExpiry}
oninput={handleExpiryInput}
placeholder="MM/YY"
maxlength={5}
class="mt-1"
/>
</div>
<div>
<label for="cardCVC" class="text-sm font-medium text-gray-700">CVC</label>
<Input
id="cardCVC"
type="text"
inputmode="numeric"
value={newCardCVC}
oninput={handleCvcInput}
placeholder="123"
maxlength={4}
class="mt-1"
/>
</div>
</div>
{#if canSaveCards}
<div class="flex items-center gap-2">
<Checkbox id="saveCard" bind:checked={saveCardForFuture} />
<label for="saveCard" class="text-sm text-gray-700">
Save card for next time
</label>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Scenario A: Deposit needed -->
{#if isScenarioA}
<div class="space-y-3">
<!-- Payment type radio buttons -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'deposit' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'deposit')}
>
Pay Deposit
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'full' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')}
>
Pay in Full
</button>
</div>
<!-- Pay button -->
{#if payButtonError}
<p class="text-sm text-red-600">{payButtonError}</p>
{/if}
<Button
onclick={() => paymentType === 'deposit' ? handlePayDeposit() : handlePayFull()}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(booking.deposit_amount ? Math.round(booking.deposit_amount * 100) : Math.round(booking.total_amount * 0.2 * 100))})
{:else}
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
{/if}
</Button>
</div>
{/if}
<!-- Scenario B: Partial or full payment -->
{#if isScenarioB}
<div class="space-y-3">
<!-- Payment type radio buttons -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'full' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')}
>
Pay in Full
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType === 'partial' ? 'border-input bg-fuchsia-100 text-foreground' : 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'partial')}
>
Pay Part
</button>
</div>
<!-- Partial amount input (only when partial selected) -->
{#if paymentType === 'partial'}
<div>
<label for="partial-amount" class="text-sm font-medium text-gray-700">
Partial Payment Amount
</label>
<div class="relative mt-1">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">£</span>
<Input
id="partial-amount"
type="text"
inputmode="decimal"
step="0.01"
placeholder="0.00"
value={partialAmount}
oninput={handlePartialAmountInput}
class="pl-7"
disabled={status === 'processing'}
/>
</div>
</div>
{/if}
<!-- Pay button -->
{#if payButtonError}
<p class="text-sm text-red-600">{payButtonError}</p>
{/if}
<Button
onclick={() => paymentType === 'partial' ? handlePayPartial() : handlePayFull()}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid ? formatCurrency(Math.round(partialAmountNum * 100)) : 'Part'}
{:else}
Pay {formatCurrency(Math.round(booking.amount_due * 100))}
{/if}
</Button>
</div>
{/if}
{#if status === 'error' && error}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<!-- Close button -->
<Button variant="ghost" onclick={handleClose} class="w-full" disabled={status === 'processing'}>
Close
</Button>
</div>
{:else if status === 'polling'}
<!-- Polling State -->
<div class="flex flex-col items-center justify-center py-8">
<div class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"></div>
<p class="text-lg font-medium text-gray-700">Processing payment...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
<Button variant="ghost" onclick={handleClose} class="mt-6">
Cancel
</Button>
</div>
{:else if status === 'success' && paymentResult}
<!-- Success State -->
<div class="space-y-4">
<div class="flex flex-col items-center justify-center py-4">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
</div>
<!-- Receipt -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Amount</span>
<span class="font-semibold text-gray-900">
{formatCurrency(paymentResult.amount)}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Type</span>
<span class="font-medium text-gray-900 capitalize">
{paymentResult.payment_type}
</span>
</div>
{#if paymentResult.card_brand}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Card</span>
<span class="font-medium text-gray-900">
{paymentResult.card_brand} ****{paymentResult.card_last4}
</span>
</div>
{/if}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span class="font-medium text-green-600">Completed</span>
</div>
</div>
</div>
<Button onclick={handleClose} class="w-full">
Done
</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>