fix: improve VAT rounding and add per-line VAT display support

Fix calculateVat/calculateTotal to use proper monetary rounding. Add
getVatSummaryFromItems helper for deriving VAT labels from mixed-rate items.
Update invoice preview, review, and detail pages to show per-rate VAT
breakdown instead of a single aggregated rate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-23 12:58:21 +01:00
co-authored by Claude Opus 4.6
parent 3626f14e45
commit 3c036059ab
6 changed files with 111 additions and 62 deletions
+27 -4
View File
@@ -512,10 +512,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms ({invoice.vat_rate}%)</span>
<span>{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
</div>
{(() => {
const vatByRate = new Map<number, number>()
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 (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, invoice.currency)}</span>
</div>
)
}
return entries.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(vat, invoice.currency)}</span>
</div>
))
})()}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
+2 -4
View File
@@ -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 && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<p className="text-sm">
<strong>Momsbehandling:</strong> {getVatTreatmentLabel(vatRules.treatment)}
<strong>Momsbehandling:</strong> {getVatSummaryFromItems(watchItems).label}
</p>
{vatRules.reverseChargeText && (
<p className="text-xs text-muted-foreground mt-1">
@@ -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}
+24 -16
View File
@@ -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,
+25 -35
View File
@@ -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<number, number>()
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 (
<div className="space-y-4">
{/* Customer info */}
@@ -85,7 +77,7 @@ export function InvoiceReviewContent({
{/* VAT treatment */}
<Badge className="text-sm px-3 py-1">
{getVatTreatmentLabel(vatTreatment)}
{vatSummary.label}
</Badge>
{/* Dates */}
@@ -120,7 +112,7 @@ export function InvoiceReviewContent({
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? vatRate}%</td>
<td className="py-2 text-right">{item.vat_rate ?? 25}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
@@ -136,21 +128,19 @@ export function InvoiceReviewContent({
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
{vatByRate.size > 1 ? (
// Per-rate breakdown
Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))
) : (
{Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))}
{Array.from(vatByRate.values()).every((vat) => vat === 0) && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms ({vatRate}%)</span>
<span>{formatCurrency(vatAmount, currency)}</span>
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, currency)}</span>
</div>
)}
<Separator />
+1 -1
View File
@@ -440,7 +440,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
))
) : (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? 25}%):</Text>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 25)}%):</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
</View>
)}
+32 -2
View File
@@ -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
*/