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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user