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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 17:06:38 +01:00
co-authored by Sisyphus
parent 194aacf68c
commit d71373c7f2
10 changed files with 199 additions and 26 deletions
@@ -2,6 +2,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy'; import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
@@ -11,7 +12,6 @@
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte'; import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking'; import { computeBalanceDue } from '$lib/utils/booking';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
interface Props { interface Props {
open: boolean; open: boolean;
bookingId: string; bookingId: string;
@@ -20,6 +20,7 @@
let { open = $bindable(), bookingId }: Props = $props(); let { open = $bindable(), bookingId }: Props = $props();
let selectedBooking = $state<Booking | null>(null); let selectedBooking = $state<Booking | null>(null);
let businessSettings = $derived(getBusinessInfo());
let loading = $state(false); let loading = $state(false);
let hasPendingEditRequest = $state(false); let hasPendingEditRequest = $state(false);
let pendingEditRequest = $state<{ let pendingEditRequest = $state<{
@@ -77,6 +78,20 @@
let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0); 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( let depositOutstanding = $derived(
selectedBooking?.deposit_required && !selectedBooking?.deposit_paid selectedBooking?.deposit_required && !selectedBooking?.deposit_paid
); );
@@ -194,9 +209,9 @@
async function fetchBookingDetails() { async function fetchBookingDetails() {
if (!bookingId) return; if (!bookingId) return;
loading = true;
try { try {
const response = await fetch(`/api/bookings/${bookingId}`, { const bookingResp = await fetch(`/api/bookings/${bookingId}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -204,10 +219,13 @@
} }
}); });
if (response.ok) { if (bookingResp.ok) {
const data = await response.json(); const data = await bookingResp.json();
selectedBooking = data as Booking; selectedBooking = data as Booking;
// Ensure business info is loaded (cached by shared store)
ensureBusinessInfo();
const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, { const editResp = await fetch(`/api/bookings/${bookingId}/edit-request`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` } headers: { Authorization: `Bearer ${authStore.currentToken}` }
}); });
@@ -215,7 +233,7 @@
hasPendingEditRequest = editData.edit_request != null; hasPendingEditRequest = editData.edit_request != null;
pendingEditRequest = editData.edit_request || null; pendingEditRequest = editData.edit_request || null;
} else { } else {
const text = await response.text(); const text = await bookingResp.text();
toast.error('Failed to load booking: ' + text); toast.error('Failed to load booking: ' + text);
open = false; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
};
const fmt = (val: number | null | undefined, fallback = '\u2014'): string => {
return val != null ? '\u00a3' + val.toFixed(2) : fallback;
};
pw.document.write(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Receipt</title>
<style>
@page { margin: 12mm; }
* { color: #000 !important; background: transparent !important; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 12px; line-height: 1.5; max-width: 700px; margin: 0 auto; padding: 20px; }
h1 { font-size: 18px; margin: 0 0 2px 0; }
.header { border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 14px; }
.header p { margin: 1px 0; font-size: 11px; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { padding: 5px 8px; text-align: left; border-bottom: 1px solid #000; font-size: 11px; }
th { font-weight: 600; }
.total td { font-weight: 700; border-top: 2px solid #000; }
.warning { font-size: 10px; margin-top: 4px; }
.footer { margin-top: 20px; padding-top: 10px; border-top: 1px solid #000; font-size: 10px; text-align: center; }
@media print { body { padding: 0; } }
</style></head><body>
<div class="header">
<h1>${esc(biz?.business_name ?? 'Crussell Nail Art Studio')}</h1>
<p>${esc(biz?.business_address ?? '')}</p>
${biz?.is_vat_registered && biz?.vat_registration_number ? `<p>VAT Reg: ${esc(biz.vat_registration_number)}</p>` : ''}
${biz?.business_phone ? `<p>Tel: ${esc(biz.business_phone)}</p>` : ''}
${biz?.business_email ? `<p>Email: ${esc(biz.business_email)}</p>` : ''}
</div>
<h2>Receipt</h2>
<table>
<tr><td style="width:110px;font-weight:600">Booking Ref</td><td>${esc(selectedBooking.id)}</td></tr>
<tr><td style="font-weight:600">Date</td><td>${new Date(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
<tr><td style="font-weight:600">Status</td><td style="text-transform:capitalize">${selectedBooking.status.replace('_', ' ')}</td></tr>
</table>
<h2>Services</h2>
<table>
<tr><th>Service</th><th style="text-align:right">Price</th></tr>
${(selectedBooking.services ?? []).map(s => `<tr><td>${esc(s.service_name)}${s.duration_minutes ? ' (' + s.duration_minutes + ' min)' : ''}</td><td style="text-align:right">\u00a3${(s.price ?? 0).toFixed(2)}</td></tr>`).join('')}
</table>
<h2>Payments</h2>
<table>
<tr><th>Type</th><th>Method</th><th style="text-align:right">Net</th><th style="text-align:right">VAT</th><th style="text-align:right">Gross</th></tr>
${paidPayments.map(p => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
${discountPayments.map(d => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
${refunds.map(r => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
${hasVAT ? `<tr class="total"><td colspan="2"></td><td style="text-align:right">\u00a3${totalNet.toFixed(2)}</td><td style="text-align:right">\u00a3${totalVAT.toFixed(2)}</td><td style="text-align:right">\u00a3${(totalNet + totalVAT).toFixed(2)}</td></tr>` : ''}
<tr class="total"><td colspan="4" style="text-align:right">Total Paid</td><td style="text-align:right">\u00a3${grossTotal.toFixed(2)}</td></tr>
</table>
${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}% where shown above.</p>` : '<p class="warning">VAT is not applicable for this transaction.</p>'}
<div class="footer"><p>Thank you for visiting ${esc(biz?.business_name ?? 'Crussell')}.</p></div>
</body></html>`);
pw.document.close();
pw.onload = () => pw.print();
}
async function cancelBooking() { async function cancelBooking() {
if (!selectedBooking) return; if (!selectedBooking) return;
cancelling = true; cancelling = true;
@@ -416,6 +504,8 @@
</div> </div>
</div> </div>
{#if selectedBooking.services && selectedBooking.services.length > 0} {#if selectedBooking.services && selectedBooking.services.length > 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <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"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
@@ -516,6 +606,19 @@
</div> </div>
{/if} {/if}
{#if hasVAT}
<div class="border-t border-gray-200 pt-2 mt-2">
<div class="flex items-center justify-between text-xs text-gray-500">
<span>Net amount (excl. VAT)</span>
<span class="font-medium text-gray-700">£{totalNet.toFixed(2)}</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>VAT ({businessSettings?.default_vat_rate ?? 20}%)</span>
<span class="font-medium text-gray-700">£{totalVAT.toFixed(2)}</span>
</div>
</div>
{/if}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span> <span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700"> <span class="font-semibold text-green-700">
@@ -584,7 +687,7 @@
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div> <div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
{#if payment.vat_amount} {#if payment.vat_amount}
<div> <div>
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed( VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(
2 2
)} )}
</div> </div>
@@ -696,6 +799,16 @@
{/if} {/if}
<div class="flex gap-2"> <div class="flex gap-2">
{#if selectedBooking}
<Button
size="sm"
class="flex-1 hover:bg-gray-50"
variant="outline"
onclick={printReceipt}
>
Print Receipt
</Button>
{/if}
{#if isCompleted} {#if isCompleted}
<Button <Button
size="sm" size="sm"
@@ -645,7 +645,7 @@
<div class="mt-2 text-xs text-gray-600"> <div class="mt-2 text-xs text-gray-600">
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div> <div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
<div> <div>
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed( VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(
2 2
) || '0.00'} ) || '0.00'}
</div> </div>
@@ -230,8 +230,14 @@
}); });
if (res.ok) { if (res.ok) {
settings = await res.json(); const wasVatEnabled = settings?.is_vat_registered;
const updated = await res.json() as BusinessSettings;
settings = updated;
if (!wasVatEnabled && updated.is_vat_registered) {
toast.success('VAT enabled — past payments and till sales updated retroactively');
} else {
toast.success('Business settings updated'); toast.success('Business settings updated');
}
showEditModal = false; showEditModal = false;
} else { } else {
const errText = await res.text(); const errText = await res.text();
@@ -583,6 +583,14 @@
<span class="mx-1">|</span> <span class="mx-1">|</span>
<span class="text-gray-500">Inv: {payment.invoice_number}</span> <span class="text-gray-500">Inv: {payment.invoice_number}</span>
{/if} {/if}
{#if payment.is_vat_applicable && payment.net_amount != null}
<span class="mx-1">|</span>
<span class="text-gray-500">Net: £{payment.net_amount.toFixed(2)}</span>
<span class="mx-1">|</span>
<span class="text-gray-500">
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(2)}
</span>
{/if}
</div> </div>
<div class="mt-1 text-xs text-gray-400"> <div class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', { {new SvelteDate(payment.created_at).toLocaleDateString('en-GB', {
@@ -19,8 +19,10 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { browser } from '$app/environment';
// Components // Components
import BookingActions from '$lib/components/booking/BookingActions.svelte'; import BookingActions from '$lib/components/booking/BookingActions.svelte';
@@ -94,6 +96,9 @@
newCardCVC.length >= 3) 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) // Email existence check (guest flow only)
let emailChecking = $state(false); let emailChecking = $state(false);
let emailSuggestion = $state<string | null>(null); let emailSuggestion = $state<string | null>(null);
@@ -584,6 +589,7 @@
$effect(() => { $effect(() => {
fetchServices(); fetchServices();
ensureBusinessInfo();
}); });
// Track which months are currently being fetched (prevents duplicate requests) // Track which months are currently being fetched (prevents duplicate requests)
@@ -1630,7 +1636,7 @@
</div> </div>
<div class="flex justify-between text-sm font-semibold"> <div class="flex justify-between text-sm font-semibold">
<span>Total Cost:</span> <span>Total Cost:</span>
<span>£{getTotalPrice()}</span> <span>£{getTotalPrice()}{#if vatRegistered} <span class="text-xs font-normal text-gray-400">incl. VAT</span>{/if}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2131,7 +2137,7 @@
{:else} {:else}
<div class="flex justify-between font-semibold"> <div class="flex justify-between font-semibold">
<span>Total (estimated)</span> <span>Total (estimated)</span>
<span>£{getTotalPrice()}</span> <span>£{getTotalPrice()}{#if vatRegistered} <span class="text-xs font-normal text-gray-400">incl. VAT</span>{/if}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format'; import { formatDuration } from '$lib/utils/format';
@@ -88,6 +89,9 @@
let summary = $state<DailySummary | null>(null); let summary = $state<DailySummary | null>(null);
let weekSummary = $state<DailySummary | null>(null); let weekSummary = $state<DailySummary | null>(null);
// VAT registration status from public business info (via shared store)
let vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
// Data diffing — only update UI when payload actually changes // Data diffing — only update UI when payload actually changes
let prevDataJson = $state(''); let prevDataJson = $state('');
@@ -226,6 +230,7 @@
} }
$effect(() => { $effect(() => {
ensureBusinessInfo();
fetchCurrentAndNext(); fetchCurrentAndNext();
// Recalculate time display every 15s (no data fetch) // Recalculate time display every 15s (no data fetch)
@@ -442,6 +447,7 @@
<div class="mt-1 text-lg font-bold text-gray-900"> <div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_payments_today.toFixed(2)} £{summary.total_payments_today.toFixed(2)}
</div> </div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div> </div>
<div class="rounded-md border border-teal-100 bg-white p-3"> <div class="rounded-md border border-teal-100 bg-white p-3">
@@ -574,6 +580,7 @@
<div class="mt-1 text-lg font-bold text-gray-900"> <div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_payments_today.toFixed(2)} £{weekSummary.total_payments_today.toFixed(2)}
</div> </div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div> </div>
<div class="rounded-md border border-gray-200 bg-white p-3"> <div class="rounded-md border border-gray-200 bg-white p-3">
+6
View File
@@ -1859,6 +1859,12 @@
})()} })()}
</span> </span>
{/if} {/if}
{#if b.total_amount}
<span class="font-medium text-gray-700">
— £{b.total_amount.toFixed(2)}
</span>
{/if}
</div> </div>
</div> </div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button> <Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
@@ -32,6 +32,8 @@
paid_amount: number; paid_amount: number;
refunded_amount: number; refunded_amount: number;
remaining_amount: number; remaining_amount: number;
total_vat_amount: number;
total_net_amount: number;
payments: Array<{ payments: Array<{
id: string; id: string;
status: string; status: string;
@@ -357,6 +359,22 @@
</div> </div>
</div> </div>
</div> </div>
{#if paymentSummary && paymentSummary.total_vat_amount > 0}
<div class="mt-2 rounded-lg bg-gray-50 p-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Net amount</span>
<span class="font-medium">{formatPence(paymentSummary.total_net_amount)}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">VAT</span>
<span class="font-medium">{formatPence(paymentSummary.total_vat_amount)}</span>
</div>
<div class="mt-1 flex justify-between border-t border-gray-200 pt-1 text-sm font-semibold">
<span class="text-gray-800">Total paid</span>
<span class="text-gray-800">{formatPence(paymentSummary.paid_amount)}</span>
</div>
</div>
{/if}
{:else} {:else}
<Button class="w-full" onclick={() => toast.info('Payment integration coming soon')}> <Button class="w-full" onclick={() => toast.info('Payment integration coming soon')}>
Pay Now Pay Now
+3
View File
@@ -205,6 +205,9 @@
<li> <li>
<span class="font-medium">Deposit</span> may be required for frequent no-shows <span class="font-medium">Deposit</span> may be required for frequent no-shows
</li> </li>
<li>
<span class="font-medium">VAT</span> is included in all displayed prices where applicable
</li>
</ul> </ul>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
@@ -15,6 +16,7 @@
let loading = $state(false); let loading = $state(false);
let selectedBookingId = $state<string | null>(null); let selectedBookingId = $state<string | null>(null);
let showBookingModal = $state(false); let showBookingModal = $state(false);
let businessSettings = $derived(getBusinessInfo());
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
@@ -28,6 +30,7 @@
return; return;
} }
pageState = 'authorized'; pageState = 'authorized';
ensureBusinessInfo();
fetchBookings(); fetchBookings();
}); });
@@ -323,6 +326,9 @@
{#if booking.total_amount} {#if booking.total_amount}
<span class="text-lg font-bold text-gray-900"> <span class="text-lg font-bold text-gray-900">
£{booking.total_amount.toFixed(2)} £{booking.total_amount.toFixed(2)}
{#if businessSettings?.is_vat_registered}
<span class="text-xs font-normal text-gray-400"> incl. VAT</span>
{/if}
</span> </span>
{:else} {:else}
<span></span> <span></span>