diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 17667003..e2c14ec5 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -512,10 +512,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st Delsumma {formatCurrency(invoice.subtotal, invoice.currency)} -
- Moms ({invoice.vat_rate}%) - {formatCurrency(invoice.vat_amount, invoice.currency)} -
+ {(() => { + const vatByRate = new Map() + for (const item of invoice.items) { + const rate = item.vat_rate ?? 25 + const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100 + vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) + } + const entries = Array.from(vatByRate.entries()) + .filter(([, vat]) => vat > 0) + .sort(([a], [b]) => b - a) + + if (entries.length === 0) { + return ( +
+ Moms + {formatCurrency(0, invoice.currency)} +
+ ) + } + + return entries.map(([rate, vat]) => ( +
+ Moms {rate}% + {formatCurrency(vat, invoice.currency)} +
+ )) + })()}
Totalt diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index e5392c0b..f1d45320 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -16,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Separator } from '@/components/ui/separator' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' -import { getVatRules, getVatTreatmentLabel, getAvailableVatRates } from '@/lib/invoices/vat-rules' +import { getVatRules, getAvailableVatRates, getVatSummaryFromItems } from '@/lib/invoices/vat-rules' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' @@ -369,7 +369,7 @@ export default function NewInvoicePage() { {selectedCustomer && vatRules && (

- Momsbehandling: {getVatTreatmentLabel(vatRules.treatment)} + Momsbehandling: {getVatSummaryFromItems(watchItems).label}

{vatRules.reverseChargeText && (

@@ -664,10 +664,8 @@ export default function NewInvoicePage() { vat_rate: item.vat_rate ?? (vatRules?.rate || 25), }))} subtotal={subtotal} - vatRate={vatRules.rate} vatAmount={vatAmount} total={total} - vatTreatment={vatRules.treatment} yourReference={pendingData?.your_reference} ourReference={pendingData?.our_reference} notes={pendingData?.notes} diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 0dd8b28a..8f7d77df 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -55,25 +55,33 @@ export async function POST(request: Request) { const docType: InvoiceDocumentType = document_type || 'invoice' const isDeliveryNote = docType === 'delivery_note' - // Build items with line totals - const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({ - id: `preview-${index}`, - invoice_id: 'preview', - sort_order: index, - description: item.description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - line_total: Math.round(item.quantity * item.unit_price * 100) / 100, - vat_rate: item.vat_rate ?? vatRules.rate, - vat_amount: 0, - created_at: new Date().toISOString(), - })) + // Build items with line totals and per-item VAT + const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => { + const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100 + const rate = item.vat_rate ?? vatRules.rate + return { + id: `preview-${index}`, + invoice_id: 'preview', + sort_order: index, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: lineTotal, + vat_rate: rate, + vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100, + created_at: new Date().toISOString(), + } + }) const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0) - const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100 + const vatAmount = isDeliveryNote ? 0 : invoiceItems.reduce((sum, item) => sum + item.vat_amount, 0) const total = isDeliveryNote ? 0 : subtotal + vatAmount + // Derive vat_rate from items: single rate → that rate, mixed → null + const itemRates = new Set(invoiceItems.map((item) => item.vat_rate)) + const effectiveVatRate = isDeliveryNote ? 0 : (itemRates.size === 1 ? itemRates.values().next().value! : null) + // Construct a temporary Invoice-like object const previewInvoice = { id: 'preview', @@ -93,7 +101,7 @@ export async function POST(request: Request) { total, total_sek: null, vat_treatment: vatRules.treatment, - vat_rate: isDeliveryNote ? 0 : vatRules.rate, + vat_rate: effectiveVatRate, moms_ruta: vatRules.momsRuta, your_reference: your_reference || null, our_reference: our_reference || null, diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index e25733f9..243f2b59 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -2,9 +2,9 @@ import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' -import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' +import { getVatSummaryFromItems } from '@/lib/invoices/vat-rules' import { formatCurrency } from '@/lib/utils' -import type { Customer, Currency, VatTreatment } from '@/types' +import type { Customer, Currency } from '@/types' interface ReviewItem { description: string @@ -21,10 +21,8 @@ interface InvoiceReviewContentProps { currency: Currency items: ReviewItem[] subtotal: number - vatRate: number vatAmount: number total: number - vatTreatment: VatTreatment yourReference?: string ourReference?: string notes?: string @@ -37,10 +35,8 @@ export function InvoiceReviewContent({ currency, items, subtotal, - vatRate, vatAmount, total, - vatTreatment, yourReference, ourReference, notes, @@ -52,24 +48,20 @@ export function InvoiceReviewContent({ non_eu_business: 'Utanför EU', } - // Check if items have mixed VAT rates - const hasPerLineVat = items.some((item) => item.vat_rate !== undefined) - const uniqueRates = hasPerLineVat - ? new Set(items.map((item) => item.vat_rate ?? vatRate)) - : new Set([vatRate]) - const showVatColumn = hasPerLineVat && uniqueRates.size > 1 + // Derive VAT summary from items + const vatSummary = getVatSummaryFromItems(items) // Calculate per-rate VAT breakdown const vatByRate = new Map() - if (hasPerLineVat) { - for (const item of items) { - const rate = item.vat_rate ?? vatRate - const lineTotal = item.quantity * item.unit_price - const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 - vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) - } + for (const item of items) { + const rate = item.vat_rate ?? 25 + const lineTotal = item.quantity * item.unit_price + const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 + vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) } + const showVatColumn = vatByRate.size > 1 + return (

{/* Customer info */} @@ -85,7 +77,7 @@ export function InvoiceReviewContent({ {/* VAT treatment */} - {getVatTreatmentLabel(vatTreatment)} + {vatSummary.label} {/* Dates */} @@ -120,7 +112,7 @@ export function InvoiceReviewContent({ {item.unit} {formatCurrency(item.unit_price, currency)} {showVatColumn && ( - {item.vat_rate ?? vatRate}% + {item.vat_rate ?? 25}% )} {formatCurrency(item.quantity * item.unit_price, currency)} @@ -136,21 +128,19 @@ export function InvoiceReviewContent({ Delsumma {formatCurrency(subtotal, currency)}
- {vatByRate.size > 1 ? ( - // Per-rate breakdown - Array.from(vatByRate.entries()) - .filter(([, vat]) => vat > 0) - .sort(([a], [b]) => b - a) - .map(([rate, vat]) => ( -
- Moms {rate}% - {formatCurrency(vat, currency)} -
- )) - ) : ( + {Array.from(vatByRate.entries()) + .filter(([, vat]) => vat > 0) + .sort(([a], [b]) => b - a) + .map(([rate, vat]) => ( +
+ Moms {rate}% + {formatCurrency(vat, currency)} +
+ ))} + {Array.from(vatByRate.values()).every((vat) => vat === 0) && (
- Moms ({vatRate}%) - {formatCurrency(vatAmount, currency)} + Moms + {formatCurrency(0, currency)}
)} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index cc8f28ee..5ae6ecbc 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -440,7 +440,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )) ) : ( - Moms ({invoice.vat_rate ?? 25}%): + Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 25)}%): {formatCurrency(invoice.vat_amount, invoice.currency)} )} diff --git a/lib/invoices/vat-rules.ts b/lib/invoices/vat-rules.ts index 10af9069..1957649f 100644 --- a/lib/invoices/vat-rules.ts +++ b/lib/invoices/vat-rules.ts @@ -118,14 +118,14 @@ export function getVatRules( * Calculate VAT amount */ export function calculateVat(subtotal: number, vatRate: number): number { - return subtotal * (vatRate / 100) + return Math.round(subtotal * vatRate) / 100 } /** * Calculate total including VAT */ export function calculateTotal(subtotal: number, vatRate: number): number { - return subtotal + calculateVat(subtotal, vatRate) + return Math.round((subtotal + calculateVat(subtotal, vatRate)) * 100) / 100 } /** @@ -153,6 +153,36 @@ export function getVatTreatmentLabel(treatment: VatTreatment): string { return labels[treatment] } +/** + * Derive a display-friendly VAT summary from invoice line items. + * + * - If all items share a single rate → returns that rate's label and treatment + * - If items have mixed rates → returns "Blandade momssatser" with null rate/treatment + */ +export function getVatSummaryFromItems( + items: { vat_rate?: number | null }[] +): { label: string; treatment: VatTreatment | null; rate: number | null; isMixed: boolean } { + const rates = new Set(items.map((item) => item.vat_rate ?? 25)) + + if (rates.size === 1) { + const rate = rates.values().next().value! + const treatment = getVatTreatmentForRate(rate) + return { + label: getVatTreatmentLabel(treatment), + treatment, + rate, + isMixed: false, + } + } + + return { + label: 'Blandade momssatser', + treatment: null, + rate: null, + isMixed: true, + } +} + /** * Get moms ruta description */