diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index b684d0c..09c519f 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -2,6 +2,7 @@ import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import { POLICY } from '$lib/constants/policy'; import { authStore } from '$lib/stores/auth.svelte'; + import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte'; import { SvelteDate } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; import * as Modal from '$lib/components/ui/dialog'; @@ -11,7 +12,6 @@ import EditRequestModal from '$lib/components/account/EditRequestModal.svelte'; import { computeBalanceDue } from '$lib/utils/booking'; import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; - interface Props { open: boolean; bookingId: string; @@ -20,6 +20,7 @@ let { open = $bindable(), bookingId }: Props = $props(); let selectedBooking = $state(null); + let businessSettings = $derived(getBusinessInfo()); let loading = $state(false); let hasPendingEditRequest = $state(false); let pendingEditRequest = $state<{ @@ -77,6 +78,20 @@ let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0); + let totalVAT = $derived( + selectedBooking?.payments + ?.filter((p) => p.status === 'completed' && p.vat_amount) + .reduce((sum, p) => sum + (p.vat_amount ?? 0), 0) || 0 + ); + + let totalNet = $derived( + selectedBooking?.payments + ?.filter((p) => p.status === 'completed' && p.net_amount) + .reduce((sum, p) => sum + (p.net_amount ?? 0), 0) || 0 + ); + + let hasVAT = $derived(totalVAT > 0); + let depositOutstanding = $derived( selectedBooking?.deposit_required && !selectedBooking?.deposit_paid ); @@ -194,9 +209,9 @@ async function fetchBookingDetails() { if (!bookingId) return; - loading = true; + try { - const response = await fetch(`/api/bookings/${bookingId}`, { + const bookingResp = await fetch(`/api/bookings/${bookingId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -204,10 +219,13 @@ } }); - if (response.ok) { - const data = await response.json(); + if (bookingResp.ok) { + const data = await bookingResp.json(); selectedBooking = data as Booking; + // Ensure business info is loaded (cached by shared store) + ensureBusinessInfo(); + const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, { headers: { Authorization: `Bearer ${authStore.currentToken}` } }); @@ -215,7 +233,7 @@ hasPendingEditRequest = editData.edit_request != null; pendingEditRequest = editData.edit_request || null; } else { - const text = await response.text(); + const text = await bookingResp.text(); toast.error('Failed to load booking: ' + text); open = false; } @@ -242,6 +260,76 @@ } }); + function printReceipt() { + if (!selectedBooking) { toast.error('No booking data to print'); return; } + const pw = window.open('', '_blank'); + if (!pw) { toast.error('Please allow pop-ups to print the receipt'); return; } + + const biz = businessSettings; + const paidPayments = selectedBooking.payments?.filter((p) => p.status === 'completed' && p.payment_method !== 'discount') ?? []; + const discountPayments = selectedBooking.payments?.filter((p) => p.payment_method === 'discount' && p.status === 'completed') ?? []; + const refunds = selectedBooking.refunds?.filter((r) => r.status === 'completed') ?? []; + const grossTotal = paidPayments.reduce((s, p) => s + p.amount, 0); + + const esc = (str: string | null | undefined): string => { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + }; + + const fmt = (val: number | null | undefined, fallback = '\u2014'): string => { + return val != null ? '\u00a3' + val.toFixed(2) : fallback; + }; + + pw.document.write(`Receipt + +
+

${esc(biz?.business_name ?? 'Crussell Nail Art Studio')}

+

${esc(biz?.business_address ?? '')}

+ ${biz?.is_vat_registered && biz?.vat_registration_number ? `

VAT Reg: ${esc(biz.vat_registration_number)}

` : ''} + ${biz?.business_phone ? `

Tel: ${esc(biz.business_phone)}

` : ''} + ${biz?.business_email ? `

Email: ${esc(biz.business_email)}

` : ''} +
+

Receipt

+ + + + +
Booking Ref${esc(selectedBooking.id)}
Date${new Date(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
Status${selectedBooking.status.replace('_', ' ')}
+

Services

+ + + ${(selectedBooking.services ?? []).map(s => ``).join('')} +
ServicePrice
${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}\u00a3${(s.price ?? 0).toFixed(2)}
+

Payments

+ + + ${paidPayments.map(p => ``).join('')} + ${discountPayments.map(d => ``).join('')} + ${refunds.map(r => ``).join('')} + ${hasVAT ? `` : ''} + +
TypeMethodNetVATGross
${p.payment_type}${p.payment_method ?? '\u2014'}${fmt(p.net_amount)}${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}\u00a3${p.amount.toFixed(2)}
Discount\u2014-\u00a3${Math.abs(d.amount).toFixed(2)}\u2014\u2014
Refund\u2014-\u00a3${r.amount.toFixed(2)}\u2014\u2014
\u00a3${totalNet.toFixed(2)}\u00a3${totalVAT.toFixed(2)}\u00a3${(totalNet + totalVAT).toFixed(2)}
Total Paid\u00a3${grossTotal.toFixed(2)}
+${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20}% where shown above.

` : '

VAT is not applicable for this transaction.

'} + +`); + pw.document.close(); + pw.onload = () => pw.print(); + } + async function cancelBooking() { if (!selectedBooking) return; cancelling = true; @@ -416,6 +504,8 @@ + + {#if selectedBooking.services && selectedBooking.services.length > 0}

@@ -516,6 +606,19 @@

{/if} + {#if hasVAT} +
+
+ Net amount (excl. VAT) + £{totalNet.toFixed(2)} +
+
+ VAT ({businessSettings?.default_vat_rate ?? 20}%) + £{totalVAT.toFixed(2)} +
+
+ {/if} +
Amount Paid (Card/Cash) @@ -584,7 +687,7 @@
Net: £{payment.net_amount?.toFixed(2) || '0.00'}
{#if payment.vat_amount}
- VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed( + VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed( 2 )}
@@ -696,6 +799,16 @@ {/if}
+ {#if selectedBooking} + + {/if} {#if isCompleted}
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', { diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 8e58895..d356e7c 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -19,8 +19,10 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { authStore } from '$lib/stores/auth.svelte'; + import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte'; import { toast } from 'svelte-sonner'; import { SvelteDate } from 'svelte/reactivity'; + import { browser } from '$app/environment'; // Components import BookingActions from '$lib/components/booking/BookingActions.svelte'; @@ -94,6 +96,9 @@ newCardCVC.length >= 3) ); + // VAT registration status from public business info (via shared store) + let vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false); + // Email existence check (guest flow only) let emailChecking = $state(false); let emailSuggestion = $state(null); @@ -584,6 +589,7 @@ $effect(() => { fetchServices(); + ensureBusinessInfo(); }); // Track which months are currently being fetched (prevents duplicate requests) @@ -1630,7 +1636,7 @@
Total Cost: - £{getTotalPrice()} + £{getTotalPrice()}{#if vatRegistered} incl. VAT{/if}
@@ -2131,7 +2137,7 @@ {:else}
Total (estimated) - £{getTotalPrice()} + £{getTotalPrice()}{#if vatRegistered} incl. VAT{/if}
{/if} diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index 89115d8..86a5b47 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -1,5 +1,6 @@