From d71373c7f24d9ec02939955d5d970ce508d1a050 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 22 Jun 2026 17:06:38 +0100 Subject: [PATCH] feat(frontend): add VAT breakdown, print receipt, and VAT indicators Add print receipt feature to UserBookingModal with full VAT breakdown. Fix VAT rate display (remove double *100). Show VAT breakdown (net + VAT) on booking confirmed page. Add VAT info to EditBookingModal. Show incl. VAT label on BookingFlow total, schedule, and today dashboard. Add VAT note to prices page. Show booking total amount on account page. Improve VAT enable toast in BusinessSettings. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../account/UserBookingModal.svelte | 127 +++++++++++++++++- .../lib/components/admin/BookingModal.svelte | 2 +- .../components/admin/BusinessSettings.svelte | 10 +- .../components/admin/EditBookingModal.svelte | 8 ++ .../lib/components/booking/BookingFlow.svelte | 10 +- .../today/CurrentAppointment.svelte | 35 +++-- frontend/src/routes/account/+page.svelte | 6 + .../booking-confirmed/[id]/+page.svelte | 18 +++ frontend/src/routes/prices/+page.svelte | 3 + frontend/src/routes/schedule/+page.svelte | 6 + 10 files changed, 199 insertions(+), 26 deletions(-) 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 @@