feat: Square payment integration, booking flow redesign, and timezone/weekday fixes

- Add Square payment integration (mock + handlers + UI): terminal/online payments,
  refunds, tips, saved cards, webhooks. Build-tagged dev/prod clients.
- Redesign booking flow: Step 4 conditional (deposit only), Step 5 confirmation
  screen with booking ID, auto-submit on transition.
- Redesign schedule modal: 2x3 button grid with Pay Deposit/Pay Early logic.
- Add deposit warning banner at Step 1 for users with outstanding deposits.
- Fix weekday conversion bug: Go 0=Sunday vs DB 0=Monday mismatch in 6 locations.
- Fix timezone bug: UTC vs London time in closing hours validation.
- Fix frontend error parsing: plain text backend errors now displayed correctly.
- Fix crypto.randomUUID fallback for environments without Web Crypto.
- Add 7 new regression tests: closing hours, advance check, active booking limit,
  weekday conversion, UTC/London, deposit snapshot, exceptional hours.
- Fix 3 flaky tests: dynamic dates instead of fixed, no-show timing.
This commit is contained in:
2026-05-23 11:29:34 +01:00
parent bcd5ed2bd9
commit 2e1ab9d745
31 changed files with 6155 additions and 229 deletions
@@ -8,6 +8,7 @@
import * as Textarea from '$lib/components/ui/textarea';
import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
@@ -94,6 +95,25 @@
.reduce((sum, p) => sum + p.amount, 0) || 0
);
let depositOutstanding = $derived(
selectedBooking?.deposit_required && !selectedBooking?.deposit_paid
);
let canPayEarly = $derived(
selectedBooking &&
!depositOutstanding &&
totalPaid < selectedBooking.total_amount &&
['confirmed', 'pending'].includes(selectedBooking.status)
);
let showPaymentModal = $state(false);
function handlePaymentComplete() {
toast.success('Payment completed');
showPaymentModal = false;
fetchBookingDetails();
}
let isRescheduleValid = $derived(
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
);
@@ -847,12 +867,37 @@
Add to Calendar
</Button>
{/if}
<Button size="sm" class="flex-1" onclick={() => (open = false)}>Close</Button>
{#if depositOutstanding}
<Button
size="sm"
class="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onclick={() => (showPaymentModal = true)}
>
Pay Deposit
</Button>
{:else if canPayEarly}
<Button
size="sm"
class="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white"
onclick={() => (showPaymentModal = true)}
>
Pay Early
</Button>
{/if}
</div>
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
</div>
</Modal.Content>
</Modal.Root>
{#if showPaymentModal && selectedBooking}
<PaymentModal
booking={selectedBooking}
onClose={() => (showPaymentModal = false)}
onComplete={handlePaymentComplete}
/>
{/if}
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
<Modal.Content class="max-w-sm">
<Modal.Header>
@@ -39,6 +39,13 @@
let availableServices = $state<Service[]>([]);
let loadingServices = $state(false);
// Refund dialog state
let showRefundModal = $state(false);
let refundPaymentId = $state('');
let refundAmount = $state('');
let refundReason = $state('');
let refundLoading = $state(false);
$effect(() => {
if (open && bookingId) {
fetchBooking();
@@ -331,6 +338,55 @@
saving = false;
}
}
function openRefundModal(paymentId: string, amountPence: number) {
refundPaymentId = paymentId;
refundAmount = (amountPence / 100).toFixed(2);
refundReason = '';
showRefundModal = true;
}
async function processRefund() {
if (!refundAmount || !refundReason.trim()) {
toast.error('Please enter a refund amount and reason');
return;
}
refundLoading = true;
try {
const amountPence = Math.round(parseFloat(refundAmount) * 100);
if (isNaN(amountPence) || amountPence <= 0) {
toast.error('Invalid refund amount');
return;
}
const response = await fetch(`/api/admin/payments/${refundPaymentId}/refund`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
amount: amountPence,
reason: refundReason.trim()
})
});
if (response.ok) {
toast.success('Refund processed');
showRefundModal = false;
fetchBooking();
} else {
const text = await response.text();
toast.error('Failed to process refund: ' + text);
}
} catch (err) {
console.error('Error processing refund:', err);
toast.error('Network error processing refund');
} finally {
refundLoading = false;
}
}
</script>
<Modal.Root bind:open>
@@ -473,6 +529,57 @@
{/if}
</div>
<!-- Payments -->
{#if booking?.payments && booking.payments.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Payments ({booking.payments.length})
</h3>
<div class="space-y-2">
{#each booking.payments as payment (payment.id)}
<div class="flex items-center justify-between rounded-md border border-gray-300 bg-white p-3">
<div class="flex-1">
<div class="font-medium">
{payment.payment_type === 'deposit' ? 'Deposit' : payment.payment_type === 'full' ? 'Full Payment' : payment.payment_type}
{#if payment.payment_method}
<span class="text-gray-500"> via {payment.payment_method}</span>
{/if}
</div>
<div class="mt-1 text-sm text-gray-600">
<span
class:text-green-600={payment.status === 'completed'}
class:text-amber-600={payment.status === 'pending'}
class:text-red-600={payment.status === 'failed' || payment.status === 'refunded'}
>
{payment.status}
</span>
<span class="mx-1">|</span>
£{(payment.amount / 100).toFixed(2)}
{#if payment.invoice_number}
<span class="mx-1">|</span>
<span class="text-gray-500">Inv: {payment.invoice_number}</span>
{/if}
</div>
<div class="mt-1 text-xs text-gray-400">
{new Date(payment.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</div>
</div>
{#if payment.status === 'completed'}
<Button
size="sm"
variant="outline"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => openRefundModal(payment.id, payment.amount)}
>
Refund
</Button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Notes -->
<div>
<label for="edit-notes" class="mb-2 block text-sm font-medium">Notes</label>
@@ -665,6 +772,59 @@
</Modal.Content>
</Modal.Root>
<!-- Refund Dialog -->
<Modal.Root open={showRefundModal} onOpenChange={(v) => (showRefundModal = v)}>
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto">
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Process Refund</Modal.Title>
<Modal.Description>
Enter the refund amount and reason.
</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div>
<label for="refund-amount" class="mb-1 block text-xs text-gray-600">
Refund Amount
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">£</span>
</div>
<Input
id="refund-amount"
type="text"
inputmode="decimal"
bind:value={refundAmount}
class="no-spin w-full pl-7"
placeholder="0.00"
/>
</div>
</div>
<div>
<label for="refund-reason" class="mb-1 block text-xs text-gray-600">
Reason (required)
</label>
<Textarea
id="refund-reason"
bind:value={refundReason}
placeholder="Enter reason for refund..."
rows={3}
class="w-full"
/>
</div>
</div>
<Modal.Footer class="flex items-center justify-end gap-2">
<Button variant="outline" onclick={() => (showRefundModal = false)}>Cancel</Button>
<Button onclick={processRefund} disabled={refundLoading || !refundAmount || !refundReason.trim()}>
{refundLoading ? 'Processing...' : 'Confirm Refund'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>
<style>
:global(input[type='number']) {
-moz-appearance: textfield;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
<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';
interface Props {
booking: Booking;
onClose: () => void;
onComplete: (payment: PaymentResult) => void;
}
let { booking, onClose, onComplete }: Props = $props();
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
type PaymentResult = {
checkout_id: string;
status: string;
card_brand?: string;
last4?: string;
amount: number;
};
let status = $state<PaymentStatus>('idle');
let checkoutId = $state<string | null>(null);
let paymentResult = $state<PaymentResult | null>(null);
let error = $state<string | null>(null);
let amount = $derived(booking.total_amount);
let overrideAmount = $state<string>('');
let tipEnabled = $state(false);
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Calculate total with tip
let totalWithTip = $derived(tipEnabled ? amount * 1.1 : amount);
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(value);
}
async function handleConfirmPayment() {
const finalAmount = overrideAmount ? parseFloat(overrideAmount) : totalWithTip;
if (isNaN(finalAmount) || finalAmount <= 0) {
toast.error('Please enter a valid amount');
return;
}
status = 'processing';
error = null;
try {
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
amount: Math.round(finalAmount * 100),
payment_type: 'full',
tip_enabled: tipEnabled
})
});
if (!response.ok) {
const errData = await response.text();
throw new Error(errData || 'Failed to initiate payment');
}
const data = await response.json();
checkoutId = data.checkout_id;
status = 'polling';
startPolling();
} catch (err) {
status = 'error';
error = err instanceof Error ? err.message : 'Failed to initiate payment';
toast.error(error ?? 'Unknown error');
}
}
function startPolling() {
if (!checkoutId) return;
pollingInterval = setInterval(async () => {
try {
const response = await fetch(
`/api/admin/payments/${checkoutId}/status?booking_id=${booking.id}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
}
);
if (!response.ok) {
throw new Error('Failed to check payment status');
}
const data = await response.json();
if (data.status === 'COMPLETED') {
stopPolling();
status = 'success';
paymentResult = {
checkout_id: checkoutId!,
status: data.status,
card_brand: data.card_brand,
last4: data.last4,
amount: data.amount
};
toast.success('Payment successful');
onComplete(paymentResult);
} else if (data.status === 'FAILED') {
stopPolling();
status = 'error';
const errorMsg = data.error_message || 'Payment failed';
error = errorMsg;
toast.error(errorMsg as string);
}
// PENDING - continue polling
} catch (err) {
stopPolling();
status = 'error';
const errorMsg = 'Failed to check payment status';
error = errorMsg;
toast.error(errorMsg);
}
}, 2000);
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
function handleRetry() {
status = 'idle';
checkoutId = null;
error = null;
}
function handleClose() {
stopPolling();
onClose();
}
// Cleanup on unmount
$effect(() => {
return () => {
stopPolling();
};
});
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</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.price ? formatCurrency(service.price) : '-'}
</span>
</div>
{/each}
</div>
</div>
<!-- Total Amount -->
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(amount)}</span>
</div>
<!-- Price Override -->
<div class="space-y-2">
<label for="override-amount" class="text-sm font-medium text-gray-700">
Override Amount (optional)
</label>
<Input
id="override-amount"
type="number"
step="0.01"
placeholder="Leave empty to use total"
bind:value={overrideAmount}
disabled={status === 'processing'}
/>
</div>
<!-- Tip Toggle -->
<div class="flex items-center gap-3">
<Checkbox
id="tip-enabled"
bind:checked={tipEnabled}
disabled={status === 'processing'}
/>
<label for="tip-enabled" class="text-sm text-gray-700">
Add 10% tip ({formatCurrency(amount * 0.1)})
</label>
</div>
{#if tipEnabled}
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
<span class="text-sm font-medium text-green-800">Total with Tip</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</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>
<Button variant="outline" onclick={handleRetry} class="w-full">
Try Again
</Button>
{/if}
<!-- Actions -->
<div class="flex gap-3">
<Button variant="outline" onclick={handleClose} class="flex-1" disabled={status === 'processing'}>
Cancel
</Button>
<Button
onclick={handleConfirmPayment}
class="flex-1 bg-green-600 hover:bg-green-700"
loading={status === 'processing'}
disabled={status === 'processing'}
>
Confirm Payment
</Button>
</div>
</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">Waiting for customer to tap card...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
<Button variant="outline" 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>
{#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.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>
@@ -6,6 +6,7 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Skeleton } from '$lib/components/ui/skeleton';
import PaymentModal from '$lib/components/payments/PaymentModal.svelte';
interface Props {
openBookingModal: (bookingId: string) => void;
@@ -44,6 +45,7 @@
let loading = $state(true);
let timeRemaining = $state(0); // minutes remaining in current appointment
let isInProgress = $state(false);
let showPaymentModal = $state(false);
// Calculate time remaining and free time
let interval: ReturnType<typeof setInterval> | null = null;
@@ -163,7 +165,12 @@
}
function handleTakePayment() {
toast.info('Take payment - Coming soon');
showPaymentModal = true;
}
function handlePaymentComplete(payment: unknown) {
toast.success('Payment completed');
showPaymentModal = false;
}
function handleCancel() {
@@ -425,3 +432,7 @@
</Card.Content>
{/if}
</Card.Root>
{#if showPaymentModal}
<PaymentModal booking={activeAppointment} onClose={() => showPaymentModal = false} onComplete={handlePaymentComplete} />
{/if}