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 { 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<Booking | null>(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, '&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() {
if (!selectedBooking) return;
cancelling = true;
@@ -416,6 +504,8 @@
</div>
</div>
{#if selectedBooking.services && selectedBooking.services.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">
@@ -516,6 +606,19 @@
</div>
{/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">
<span class="text-sm text-gray-600">Amount Paid (Card/Cash)</span>
<span class="font-semibold text-green-700">
@@ -584,7 +687,7 @@
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
{#if payment.vat_amount}
<div>
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed(
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount.toFixed(
2
)}
</div>
@@ -696,6 +799,16 @@
{/if}
<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}
<Button
size="sm"
@@ -645,7 +645,7 @@
<div class="mt-2 text-xs text-gray-600">
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
<div>
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed(
VAT ({payment.vat_rate || 0}%): £{payment.vat_amount?.toFixed(
2
) || '0.00'}
</div>
@@ -230,8 +230,14 @@
});
if (res.ok) {
settings = await res.json();
toast.success('Business settings updated');
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');
}
showEditModal = false;
} else {
const errText = await res.text();
@@ -583,6 +583,14 @@
<span class="mx-1">|</span>
<span class="text-gray-500">Inv: {payment.invoice_number}</span>
{/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 class="mt-1 text-xs text-gray-400">
{new SvelteDate(payment.created_at).toLocaleDateString('en-GB', {
@@ -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<string | null>(null);
@@ -584,6 +589,7 @@
$effect(() => {
fetchServices();
ensureBusinessInfo();
});
// Track which months are currently being fetched (prevents duplicate requests)
@@ -1630,7 +1636,7 @@
</div>
<div class="flex justify-between text-sm font-semibold">
<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>
@@ -2131,7 +2137,7 @@
{:else}
<div class="flex justify-between font-semibold">
<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>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
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 { formatDuration } from '$lib/utils/format';
@@ -88,6 +89,9 @@
let summary = $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
let prevDataJson = $state('');
@@ -226,6 +230,7 @@
}
$effect(() => {
ensureBusinessInfo();
fetchCurrentAndNext();
// Recalculate time display every 15s (no data fetch)
@@ -433,16 +438,17 @@
day: 'numeric'
})}
</div>
{/if}
{/if}
<!-- Stats grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_payments_today.toFixed(2)}
</div>
<!-- Stats grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{summary.total_payments_today.toFixed(2)}
</div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div>
<div class="rounded-md border border-teal-100 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Tips</div>
@@ -569,12 +575,13 @@
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_payments_today.toFixed(2)}
</div>
</div>
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Payments Taken</div>
<div class="mt-1 text-lg font-bold text-gray-900">
£{weekSummary.total_payments_today.toFixed(2)}
</div>
{#if vatRegistered}<div class="text-[10px] text-gray-400">incl. VAT</div>{/if}
</div>
<div class="rounded-md border border-gray-200 bg-white p-3">
<div class="text-xs font-medium text-gray-500">Tips</div>