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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user