import { Document, Page, Text, View, Image, Link, StyleSheet, } from '@react-pdf/renderer' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' import { generateOcrReference } from '@/lib/bankgiro/luhn' import { BUNDLED_INVOICE_FONT_FAMILIES, INVOICE_LOGO_MAX_HEIGHT_PT, INVOICE_LOGO_MAX_WIDTH_PT, STANDARD_PDF_FONT_FAMILIES, } from '@/lib/invoices/branding-constants' import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts' import { getAmountToPay } from '@/lib/invoices/rounding' import { isTextLikeLine } from '@/lib/invoices/display' type PdfLang = 'sv' | 'en' // Customer-facing labels. Statutory chapter references (ML 17 kap 24§, ML 3 kap.) // stay intact in both locales: they identify the law, not the language. const LABELS = { sv: { // Document titles titleInvoice: 'FAKTURA', titleCreditNote: 'KREDITFAKTURA', titleProforma: 'PROFORMAFAKTURA', titleDeliveryNote: 'FÖLJESEDEL', titlePreview: 'FÖRHANDSGRANSKNING', // Status banners cancelledTitle: 'MAKULERAD: inte en giltig faktura', cancelledWithNumber: (n: string) => `Faktura ${n} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`, cancelledNoNumber: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.', draftTitle: 'UTKAST: inte en giltig faktura', draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.', draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.', // Credit note reference creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`, // Sections invoiceInfoHeading: 'Fakturainformation', billedToHeading: 'Faktureras till', itemsHeading: 'Specifikation', // Invoice details invoiceDate: 'Fakturadatum:', dueDate: 'Förfallodatum:', deliveryDate: 'Leveransdatum:', yourReference: 'Er referens:', ourReference: 'Vår referens:', // Customer box custNo: 'Kundnr:', orgNo: 'Org.nr:', vat: 'VAT:', // Table columns colDescription: 'Beskrivning', colQty: 'Antal', colUnit: 'Enhet', colUnitPrice: 'à-pris', colVat: 'Moms', colTotal: 'Summa', // Totals subtotal: 'Delsumma:', net: (rate: number) => `Netto ${rate}%:`, vatRow: (rate: number) => `Moms ${rate}%:`, rounding: 'Öresavrundning:', deductionRow: 'Skattereduktion ROT/RUT:', deductionInfoHeading: 'Underlag för skattereduktion', deductionPersonnummer: 'Personnummer:', deductionHousingDesignation: 'Fastighetsbeteckning:', deductionApartmentNumber: 'Lägenhetsnummer:', deductionWorkType: 'Arbete:', deductionLaborHours: 'Arbetstimmar:', deductionNotice: 'Köparen ansöker om utbetalning hos Skatteverket via fakturamodellen. Säljaren begär utbetalning för den del köparen inte betalat.', toCredit: 'Att kreditera:', toPay: 'Att betala:', vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`, totalInSek: 'Totalt i SEK:', // Proforma / exempt proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.', exemptNotice: 'Undantag från skatteplikt, ML 3 kap.', notVatRegisteredNotice: 'Företaget är inte momsregistrerat. Mervärdesskatt redovisas ej.', // Payment paymentHeading: 'Betalningsinformation', bank: 'Bank:', account: 'Kontonummer:', bankgiro: 'Bankgiro:', plusgiro: 'Plusgiro:', swish: 'Swish:', iban: 'IBAN:', bic: 'BIC/SWIFT:', ocr: 'OCR/Referens:', paymentReference: 'Betalningsreferens:', invoiceNumber: 'Fakturanummer:', swishQrCaption: 'Skanna för att betala med Swish', payOnline: 'Betala online:', paymentLinkQrCaption: 'Skanna för att betala online', // Footer orgNoLong: 'Org.nr:', vatRegNo: 'Momsreg.nr:', fSkatt: 'Godkänd för F-skatt', }, en: { titleInvoice: 'INVOICE', titleCreditNote: 'CREDIT NOTE', titleProforma: 'PROFORMA INVOICE', titleDeliveryNote: 'DELIVERY NOTE', titlePreview: 'PREVIEW', cancelledTitle: 'VOID: not a valid invoice', cancelledWithNumber: (n: string) => `Invoice ${n} has been voided. The number is retained in the sequence to keep the numbering unbroken (ML 17 kap 24§, Swedish VAT Act), but this document is not a valid invoice.`, cancelledNoNumber: 'This draft has been voided and is not a valid invoice.', draftTitle: 'DRAFT: not a valid invoice', draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.', draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.', creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`, invoiceInfoHeading: 'Invoice information', billedToHeading: 'Billed to', itemsHeading: 'Items', invoiceDate: 'Invoice date:', dueDate: 'Due date:', deliveryDate: 'Delivery date:', yourReference: 'Your reference:', ourReference: 'Our reference:', custNo: 'Customer no.:', orgNo: 'Reg. no.:', vat: 'VAT:', colDescription: 'Description', colQty: 'Qty', colUnit: 'Unit', colUnitPrice: 'Unit price', colVat: 'VAT', colTotal: 'Amount', subtotal: 'Subtotal:', net: (rate: number) => `Net ${rate}%:`, vatRow: (rate: number) => `VAT ${rate}%:`, rounding: 'Rounding:', deductionRow: 'ROT/RUT tax reduction:', deductionInfoHeading: 'Tax reduction details', deductionPersonnummer: 'Personnummer:', deductionHousingDesignation: 'Property designation:', deductionApartmentNumber: 'Apartment number:', deductionWorkType: 'Service type:', deductionLaborHours: 'Labor hours:', deductionNotice: 'The customer claims the deduction via fakturamodellen at Skatteverket. The seller requests payment from the agency for the portion not paid by the customer.', toCredit: 'To credit:', toPay: 'Total due:', vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`, totalInSek: 'Total in SEK:', proformaNotice: 'This is a proforma invoice and is not a request for payment.', exemptNotice: 'Exempt from VAT (ML 3 kap., Swedish VAT Act).', notVatRegisteredNotice: 'The seller is not VAT-registered. No VAT is charged on this invoice.', paymentHeading: 'Payment information', bank: 'Bank:', account: 'Account number:', bankgiro: 'Bankgiro:', plusgiro: 'Plusgiro:', swish: 'Swish:', iban: 'IBAN:', bic: 'BIC/SWIFT:', ocr: 'Reference:', paymentReference: 'Payment reference:', invoiceNumber: 'Invoice number:', swishQrCaption: 'Scan to pay with Swish', payOnline: 'Pay online:', paymentLinkQrCaption: 'Scan to pay online', orgNoLong: 'Reg. no.:', vatRegNo: 'VAT reg. no.:', // Statutory Swedish phrase: kept verbatim in both locales. Peppol SE-R-005 // and Skatteverket's F-skatt notation expect "Godkänd för F-skatt"; an // English translation has no legal standing. fSkatt: 'Godkänd för F-skatt', }, } as const // Swish on invoices (the number row + the payment QR). When true, the Swish row // and QR render on the invoice PDF and the settings "Visa Swish" toggle is live. export const SHOW_SWISH_ON_INVOICE = true // Labor-only disclaimer for the ROT/RUT block. Kept Swedish-only in both // locales: references Skatteverket's fakturamodell directly, which is a // statutory Swedish concept and has no formal English equivalent. const DEDUCTION_LABOR_ONLY_NOTICE = 'Endast arbetskostnad har inkluderats i underlaget för ROT/RUT-avdrag enligt Skatteverkets fakturamodell.' // Resolved branding values used by the stylesheet. Keeping the resolved shape // distinct from the prop shape lets us validate the font allowlist in one // place (createStyles below) and gives the rest of the component a fully // non-null object to work with. export interface InvoiceBranding { /** Primary color: used for the document title and other strong text. * Default '#1a1a1a' (the existing hardcoded value). */ primaryColor?: string /** Accent color: used for muted labels and section headings. * Default '#666666' (the existing hardcoded value). */ accentColor?: string /** Registered react-pdf font family. Default 'Helvetica'. */ fontFamily?: string /** Optional banner text rendered above the document title. */ headerText?: string | null /** Optional footer text rendered above the statutory company footer line. */ footerText?: string | null } interface ResolvedBranding { primaryColor: string accentColor: string fontFamily: string } const ALLOWED_FONTS = new Set([ ...STANDARD_PDF_FONT_FAMILIES, ...BUNDLED_INVOICE_FONT_FAMILIES, ]) /** * Extract the InvoicePDF branding shape from a CompanySettings row. Tolerates * legacy rows where the branding columns are still null/undefined: returns * undefined fields that resolveBranding() then maps to the legacy defaults. * * Use this at every InvoicePDF call site that has access to a CompanySettings: * keeping the extraction logic in one place means a future schema rename or * new branding field only needs to land here. */ export function brandingFromCompanySettings( company: CompanySettings | (Partial & Record), ): InvoiceBranding { return { primaryColor: (company as CompanySettings).invoice_primary_color ?? undefined, accentColor: (company as CompanySettings).invoice_accent_color ?? undefined, fontFamily: (company as CompanySettings).invoice_font_family ?? undefined, headerText: (company as CompanySettings).invoice_header_text ?? null, footerText: (company as CompanySettings).invoice_footer_text ?? null, } } const DEFAULT_BRANDING: ResolvedBranding = { primaryColor: '#1a1a1a', accentColor: '#666666', fontFamily: 'Helvetica', } function resolveBranding(branding: InvoiceBranding | undefined): ResolvedBranding { if (!branding) return DEFAULT_BRANDING const fontFamily = branding.fontFamily && (ALLOWED_FONTS.has(branding.fontFamily) || branding.fontFamily.startsWith(CUSTOM_INVOICE_FONT_RENDER_PREFIX)) ? branding.fontFamily : DEFAULT_BRANDING.fontFamily return { primaryColor: branding.primaryColor || DEFAULT_BRANDING.primaryColor, accentColor: branding.accentColor || DEFAULT_BRANDING.accentColor, fontFamily, } } // Create styles. Calling without args yields the original (pre-branding) // stylesheet: required so the default code path is byte-equivalent to the // previous hardcoded version. function createStyles(branding?: InvoiceBranding) { const b = resolveBranding(branding) return StyleSheet.create({ page: { padding: 40, fontSize: 10, fontFamily: b.fontFamily, }, header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 30, }, title: { fontSize: 24, fontWeight: 'bold', color: b.primaryColor, }, companyInfo: { textAlign: 'left', }, companyName: { fontSize: 14, fontWeight: 'bold', marginBottom: 4, }, section: { marginBottom: 20, }, sectionTitle: { fontSize: 11, fontWeight: 'bold', marginBottom: 8, color: b.accentColor, textTransform: 'uppercase', letterSpacing: 0.5, }, row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4, }, label: { color: b.accentColor, }, value: { fontWeight: 'bold', }, customerBox: { backgroundColor: '#f5f5f5', padding: 15, borderRadius: 4, marginBottom: 20, }, customerName: { fontSize: 12, fontWeight: 'bold', marginBottom: 4, }, table: { marginTop: 10, }, tableHeader: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 8, marginBottom: 8, }, tableRow: { flexDirection: 'row', paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: '#eee', }, colDescription: { flex: 3.5, }, colQty: { flex: 1, textAlign: 'right', }, colUnit: { flex: 1, textAlign: 'center', }, colPrice: { flex: 1.5, textAlign: 'right', }, colVat: { flex: 1, textAlign: 'right', }, colTotal: { flex: 1.5, textAlign: 'right', }, tableHeaderText: { fontWeight: 'bold', color: b.accentColor, fontSize: 9, textTransform: 'uppercase', }, totalsSection: { marginTop: 20, paddingTop: 15, borderTopWidth: 2, borderTopColor: '#ddd', }, totalRow: { flexDirection: 'row', justifyContent: 'flex-end', marginBottom: 4, }, totalLabel: { width: 120, textAlign: 'right', paddingRight: 15, color: b.accentColor, }, totalValue: { width: 100, textAlign: 'right', }, grandTotal: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#333', }, grandTotalLabel: { width: 120, textAlign: 'right', paddingRight: 15, fontSize: 14, fontWeight: 'bold', }, grandTotalValue: { width: 100, textAlign: 'right', fontSize: 14, fontWeight: 'bold', }, paymentSection: { marginTop: 30, padding: 15, backgroundColor: '#f8f9fa', borderRadius: 4, position: 'relative', }, paymentTitle: { fontSize: 11, fontWeight: 'bold', marginBottom: 10, color: '#333', }, paymentRow: { flexDirection: 'row', marginBottom: 4, }, paymentLabel: { width: 100, color: b.accentColor, }, paymentValue: { flex: 1, }, reverseChargeBox: { marginTop: 20, padding: 12, backgroundColor: '#fff3cd', borderRadius: 4, borderWidth: 1, borderColor: '#ffc107', }, reverseChargeText: { fontSize: 9, color: '#856404', }, notesBox: { marginTop: 20, padding: 12, backgroundColor: '#e8f4fd', borderRadius: 4, }, notesText: { fontSize: 9, color: '#0c5460', }, creditNoteBox: { marginBottom: 20, padding: 12, backgroundColor: '#f8d7da', borderRadius: 4, borderWidth: 1, borderColor: '#f5c6cb', }, creditNoteText: { fontSize: 10, color: '#721c24', }, creditNoteTitle: { color: '#721c24', }, draftBanner: { marginBottom: 16, padding: 10, backgroundColor: '#fff3cd', borderWidth: 2, borderColor: '#856404', borderRadius: 4, }, draftBannerTitle: { fontSize: 14, fontWeight: 'bold', color: '#856404', textAlign: 'center', marginBottom: 2, }, draftBannerText: { fontSize: 9, color: '#856404', textAlign: 'center', }, cancelledBanner: { marginBottom: 16, padding: 10, backgroundColor: '#f8d7da', borderWidth: 2, borderColor: '#721c24', borderRadius: 4, }, cancelledBannerTitle: { fontSize: 14, fontWeight: 'bold', color: '#721c24', textAlign: 'center', marginBottom: 2, }, cancelledBannerText: { fontSize: 9, color: '#721c24', textAlign: 'center', }, footer: { position: 'absolute', bottom: 30, left: 40, right: 40, borderTopWidth: 1, borderTopColor: '#ddd', paddingTop: 10, }, footerText: { fontSize: 8, color: '#999', textAlign: 'center', }, twoColumn: { flexDirection: 'row', justifyContent: 'space-between', }, column: { width: '48%', }, // New: optional branding banner above the document title. brandingHeader: { marginBottom: 12, paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: '#eee', }, brandingHeaderText: { fontSize: 9, color: b.accentColor, textAlign: 'left', }, // ROT/RUT-avdrag info box (Skattereduktion ROT/RUT). Surfaces the // customer's personnummer last 4, fastighetsbeteckning, work type per // row and the statutory notice about fakturamodellen. deductionBox: { marginTop: 18, padding: 12, backgroundColor: '#f5f5f5', borderRadius: 4, borderWidth: 1, borderColor: '#ddd', }, deductionTitle: { fontSize: 10, fontWeight: 'bold', marginBottom: 6, color: b.primaryColor, textTransform: 'uppercase', letterSpacing: 0.5, }, deductionRow: { flexDirection: 'row', marginBottom: 3, }, deductionLabel: { width: 130, fontSize: 9, color: b.accentColor, }, deductionValue: { fontSize: 9, flex: 1, }, deductionLineItem: { fontSize: 9, marginTop: 4, paddingLeft: 8, color: '#444', }, deductionNotice: { fontSize: 8, marginTop: 8, color: b.accentColor, fontStyle: 'italic', }, // New: optional branding footnote rendered above the statutory company // line in the footer block. brandingFooterText: { fontSize: 8, color: b.accentColor, textAlign: 'center', marginBottom: 4, }, }) } // Format currency with explicit ISO code so non-Swedish recipients see "1 234,56 SEK" // instead of the Swedish symbol "kr". Decimal style + appended code works for any // currency (SEK/EUR/USD) and avoids Intl's locale-specific symbol quirks. export function formatPdfCurrency(amount: number, currency: string = 'SEK', language: PdfLang = 'sv'): string { const formatted = new Intl.NumberFormat(language === 'en' ? 'en-US' : 'sv-SE', { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2, // sv-SE emits U+2212, which standard PDF fonts can silently drop. The // ASCII minus is supported by every allowed invoice font. }).format(amount).replaceAll('\u2212', '-') return `${formatted} ${currency}` } export function buildPdfVatBreakdown(items: InvoiceItem[]): Map { const vatByRate = new Map() for (const item of items) { if (isTextLikeLine(item)) continue const rate = item.vat_rate ?? 0 const group = vatByRate.get(rate) || { base: 0, vat: 0 } group.base += item.line_total group.vat += item.vat_amount || 0 vatByRate.set(rate, group) } return vatByRate } // Format date as ISO yyyy-MM-dd in both locales: universally unambiguous and // matches the project's formatDate() convention (lib/utils.ts). // Input is already a YYYY-MM-DD string from the DB, so slice avoids the // new Date() + local-getter timezone hazard. function formatDate(date: string): string { return date.slice(0, 10) } // Format org number function formatOrgNumber(orgNumber: string): string { const cleaned = orgNumber.replace(/\D/g, '') if (cleaned.length === 10) { return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` } return orgNumber } function getDocumentTitle(invoice: Invoice, lang: PdfLang): string { const L = LABELS[lang] if (invoice.credited_invoice_id) return L.titleCreditNote const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice' if (docType === 'proforma') return L.titleProforma if (docType === 'delivery_note') return L.titleDeliveryNote return L.titleInvoice } interface InvoicePDFProps { invoice: Invoice customer: Customer items: InvoiceItem[] company: CompanySettings originalInvoiceNumber?: string isPreview?: boolean language?: PdfLang /** * Per-company branding overrides. Omit to render with the original default * stylesheet: the rendered output is byte-equivalent to the pre-branding * version of this template, which makes the rollout safe for the snapshot * suite and for callers that haven't yet been migrated to forward branding. */ branding?: InvoiceBranding /** Pre-rendered Swish payment QR (PNG data URL). Built offline in * pdf-render-helpers; null/omitted renders no QR. */ swishQrDataUrl?: string | null /** Pre-rendered payment-link QR (PNG data URL) for invoice.payment_link_url. * Built offline in pdf-render-helpers; null/omitted renders no QR. */ paymentLinkQrDataUrl?: string | null } export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl, paymentLinkQrDataUrl }: InvoicePDFProps) { const lang: PdfLang = language ?? customer.language ?? 'sv' const L = LABELS[lang] // Build the stylesheet per-render so each invoice picks up its company's // current branding. createStyles() with no argument returns the original // hardcoded stylesheet: the default code path is unchanged. const styles = createStyles(branding) const isCreditNote = !!invoice.credited_invoice_id // Free-text / blank rows carry no amounts: exclude them from every VAT // calculation. They still render as their own row in the line-items table. // Amount-less product rows count as text too (isTextLikeLine), so they // neither print zeros nor seed an empty per-rate VAT group. const billableItems = items.filter((item) => !isTextLikeLine(item)) // Check if items have mixed VAT rates const hasPerLineVat = billableItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null) const uniqueRates = hasPerLineVat ? new Set(billableItems.map((item) => item.vat_rate)) : new Set() const showVatColumn = hasPerLineVat && uniqueRates.size > 1 // Calculate per-rate VAT breakdown for totals const vatByRate = hasPerLineVat ? buildPdfVatBreakdown(billableItems) : new Map() const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice' const isDeliveryNote = docType === 'delivery_note' const isProforma = docType === 'proforma' // Optional branding banner text. Rendered only when the company has set // invoice_header_text: invisible chrome by default, so the byte-equivalence // promise for un-branded callers holds. const headerText = branding?.headerText ?? null const footerText = branding?.footerText ?? null return ( {/* Optional branded header: rendered above the status banners so it sits at the very top of the page. Non-statutory free-form text. */} {headerText && ( {headerText} )} {/* Status banner: cancelled takes precedence over draft so a cancelled row that lacks a number (legacy un-numbered draft that was later cancelled) still surfaces as MAKULERAD rather than UTKAST. The draft banner only shows for genuine drafts and for the corrupt-state case of a non-cancelled invoice that somehow lacks a number. */} {invoice.status === 'cancelled' ? ( {L.cancelledTitle} {invoice.invoice_number ? L.cancelledWithNumber(invoice.invoice_number) : L.cancelledNoNumber} ) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && ( {L.draftTitle} {invoice.invoice_number ? L.draftWithNumber : L.draftNoNumber} )} {/* Header */} {company.logo_url && (company.invoice_show_logo ?? true) && ( )} {(company.invoice_show_company_name ?? true) && (company.invoice_company_name_position ?? 'header') === 'header' && ( {company.company_name} )} {getDocumentTitle(invoice, lang)} {invoice.invoice_number ?? L.titlePreview} {/* Credit note reference */} {isCreditNote && originalInvoiceNumber && ( {L.creditNoteRef(originalInvoiceNumber)} )} {/* Invoice details and Customer - two columns */} {/* Invoice details */} {L.invoiceInfoHeading} {L.invoiceDate} {formatDate(invoice.invoice_date)} {L.dueDate} {formatDate(invoice.due_date)} {invoice.delivery_date && invoice.delivery_date !== invoice.invoice_date && ( {L.deliveryDate} {formatDate(invoice.delivery_date)} )} {invoice.your_reference && ( {L.yourReference} {invoice.your_reference.split(',').map((ref, i) => ( {ref.trim()} ))} )} {invoice.our_reference && ( {L.ourReference} {invoice.our_reference.split(',').map((ref, i) => ( {ref.trim()} ))} )} {/* Customer */} {L.billedToHeading} {customer.name} {customer.address_line1 && {customer.address_line1}} {customer.address_line2 && {customer.address_line2}} {(customer.postal_code || customer.city) && ( {customer.postal_code} {customer.city} )} {customer.country && customer.country !== 'SE' && ( {customer.country} )} {/* Seller-assigned kundnummer: no per-customer-type guard needed, it identifies the customer in the seller's own register and carries no personal data of its own. */} {customer.customer_number && ( {L.custNo} {customer.customer_number} )} {/* Suppress the identifier row for private customers: their personnummer is not required on a B2C invoice (ML 17 kap 24§ asks for name + address only) and printing it is a GDPR data-minimization regression. ROT/RUT-avdrag invoices surface the masked personnummer in the dedicated deductionBox below when Skatteverket needs it. */} {customer.customer_type !== 'individual' && customer.org_number && ( {L.orgNo} {customer.org_number} )} {/* Same data-minimisation guard as org_number above: for a private customer a VAT number functions as a personal tax identifier in some EU jurisdictions and is not required by ML 17 kap 24§ on a B2C invoice. */} {customer.customer_type !== 'individual' && customer.vat_number && ( {L.vat} {customer.vat_number} )} {/* Items table */} {L.itemsHeading} {/* Table header */} {L.colDescription} {L.colQty} {L.colUnit} {!isDeliveryNote && ( {L.colUnitPrice} )} {!isDeliveryNote && showVatColumn && ( {L.colVat} )} {!isDeliveryNote && ( {L.colTotal} )} {/* Table rows */} {items.map((item, index) => isTextLikeLine(item) ? ( // Free-text / blank row: description spans the full width, no // numeric columns. An empty description renders as a spacer. {item.description || ' '} ) : ( {item.description} {item.quantity} {item.unit} {!isDeliveryNote && ( {formatPdfCurrency(item.unit_price, invoice.currency, lang)} )} {!isDeliveryNote && showVatColumn && ( {item.vat_rate ?? 0}% )} {!isDeliveryNote && ( {formatPdfCurrency(item.line_total, invoice.currency, lang)} )} ) )} {/* Totals - hidden for delivery notes */} {!isDeliveryNote && ( {L.subtotal} {formatPdfCurrency(invoice.subtotal, invoice.currency, lang)} {vatByRate.size > 1 ? ( Array.from(vatByRate.entries()) .sort(([a], [b]) => b - a) .map(([rate, group]) => ( {L.net(rate)} {formatPdfCurrency(group.base, invoice.currency, lang)} {group.vat !== 0 && ( {L.vatRow(rate)} {formatPdfCurrency(group.vat, invoice.currency, lang)} )} )) ) : ( // Suppress the "Moms 0%" row only when the seller is not // VAT-registered AND the invoice actually carries no VAT. // A non-registered seller who states VAT (warned at create time // per ML 16 kap. 23 §) still gets the totals row so the printed // invoice matches what the customer is being asked to pay. !(company.vat_registered === false && invoice.vat_amount === 0) && ( {L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))} {formatPdfCurrency(invoice.vat_amount, invoice.currency, lang)} ) )} {(() => { // Shared with the invoice email (lib/email/invoice-templates.ts) // so the mail and the PDF always state the same "Att betala". const { rounding, deductionApplies: showDeduction, toPay: grandTotal } = getAmountToPay(invoice, company) return ( <> {rounding.applies && ( {L.rounding} {formatPdfCurrency(rounding.roundingDelta, 'SEK', lang)} )} {showDeduction && ( {L.deductionRow} {/* deduction_total is stored as a positive magnitude; -Math.abs() keeps the row a reduction even if the stored sign convention ever changes. */} {formatPdfCurrency(-Math.abs(invoice.deduction_total ?? 0), invoice.currency, lang)} )} {isCreditNote ? L.toCredit : L.toPay} {formatPdfCurrency(grandTotal, invoice.currency, lang)} ) })()} {invoice.currency !== 'SEK' && invoice.total_sek && ( {invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && ( {L.vatInSek(invoice.exchange_rate ?? '')} {formatPdfCurrency(invoice.vat_amount_sek, 'SEK', lang)} )} {L.totalInSek} {formatPdfCurrency(invoice.total_sek, 'SEK', lang)} )} )} {/* ROT/RUT-avdrag underlying details. Surfaces personnummer last 4, fastighetsbeteckning, lägenhetsnummer, the per-line breakdown and the statutory notice about fakturamodellen. Suppressed on delivery notes (no payment info at all). */} {!isDeliveryNote && !isCreditNote && (invoice.deduction_total ?? 0) > 0 && ( {L.deductionInfoHeading} {invoice.deduction_personnummer_last4 && ( {L.deductionPersonnummer} XXXXXXXX-{invoice.deduction_personnummer_last4} )} {(() => { // Show the first item-level housing_designation if any line // has one (typical case for a single property). Falls back to // null when only RUT lines exist (RUT doesn't require it). const housing = items.find((i) => i.housing_designation)?.housing_designation const apartment = items.find((i) => i.apartment_number)?.apartment_number return ( <> {housing && ( {L.deductionHousingDesignation} {housing} )} {apartment && ( {L.deductionApartmentNumber} {apartment} )} ) })()} {/* Labor-only disclaimer (Skatteverket fakturamodellen). Per ML 17 kap, only the labor portion qualifies; material must be invoiced separately. */} {DEDUCTION_LABOR_ONLY_NOTICE} {/* Per-line breakdown: one row per eligible item with kind, work type if present and the deducted amount. */} {items .filter((i) => i.deduction_type) .map((i, idx) => { const kind = i.deduction_type === 'rot' ? 'ROT' : 'RUT' const work = i.work_type ? `, ${i.work_type}` : '' return ( {`${kind}${work}: ${i.description}, ${formatPdfCurrency(i.deduction_amount ?? 0, invoice.currency, lang)}`} ) })} {L.deductionNotice} )} {/* Proforma notice */} {isProforma && ( {L.proformaNotice} )} {/* Payment information - not shown for credit notes, proformas, or delivery notes */} {!isCreditNote && !isProforma && !isDeliveryNote && ( {L.paymentHeading} {invoice.payment_link_url && ( {L.payOnline} {invoice.payment_link_url.length > 60 ? `${invoice.payment_link_url.slice(0, 57)}...` : invoice.payment_link_url} )} {company.bank_name && ( {L.bank} {company.bank_name} )} {(company.clearing_number || company.account_number) && ( {L.account} {company.clearing_number}-{company.account_number} )} {company.bankgiro && (company.invoice_show_bankgiro ?? true) && ( {L.bankgiro} {company.bankgiro} )} {company.plusgiro && (company.invoice_show_plusgiro ?? true) && ( {L.plusgiro} {company.plusgiro} )} {SHOW_SWISH_ON_INVOICE && company.swish && (company.invoice_show_swish ?? false) && ( {L.swish} {company.swish} )} {company.iban && ( {L.iban} {company.iban} )} {company.bic && ( {L.bic} {company.bic} )} {L.dueDate} {formatDate(invoice.due_date)} {invoice.invoice_number && ( {L.invoiceNumber} {invoice.invoice_number} )} {(company.invoice_show_ocr ?? true) && (company.bankgiro || company.plusgiro) && lang === 'sv' && ( {L.ocr} {invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '-'} )} {swishQrDataUrl && ( {L.swishQrCaption} )} {/* Payment-link QR: shifts left when the Swish QR occupies the corner. */} {paymentLinkQrDataUrl && ( {L.paymentLinkQrCaption} )} )} {/* Reverse charge / export / exempt / not-registered notice. "Not VAT-registered" trumps the others ONLY when the invoice actually carries no VAT: a non-registered seller who chose to state VAT on the invoice (warned at create time per ML 16 kap. 23 §) gets the normal reverse-charge / exempt notices instead, since the "ej momsregistrerad" line would contradict the VAT shown in the totals block. */} {company.vat_registered === false && invoice.vat_amount === 0 ? ( {L.notVatRegisteredNotice} ) : ( <> {invoice.reverse_charge_text && ( {invoice.reverse_charge_text} )} {invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && ( {L.exemptNotice} )} )} {/* Notes */} {invoice.notes && ( {invoice.notes} )} {/* Late fee & credit terms */} {(company.invoice_late_fee_text || company.invoice_credit_terms_text) && ( {company.invoice_late_fee_text && ( {company.invoice_late_fee_text} )} {company.invoice_credit_terms_text && ( {company.invoice_credit_terms_text} )} )} {/* Footer: collected legal info per ML 17 kap 24§. Optional branded footnote sits above the statutory line so it can never crowd out the compliance text (which is why the user-supplied string lives in its own Text node, not inside the join). */} {footerText && ( {footerText} )} {[ (company.invoice_show_company_name ?? true) && (company.invoice_company_name_position ?? 'header') === 'footer' ? company.company_name : null, company.address_line1, (company.postal_code || company.city) ? `${company.postal_code ?? ''} ${company.city ?? ''}`.trim() : null, company.org_number ? `${L.orgNoLong} ${formatOrgNumber(company.org_number)}` : null, company.vat_number ? `${L.vatRegNo} ${company.vat_number}` : null, company.f_skatt ? L.fSkatt : null, ].filter(Boolean).join(' · ')} ) }