Files
accounted/lib/invoices/pdf-template.tsx
T
Mattsson d54b43f80f Bug/resend and invoices (#1192)
* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:56:17 +02:00

1238 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>([
...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<CompanySettings> & Record<string, unknown>),
): 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<number, { base: number; vat: number }> {
const vatByRate = new Map<number, { base: number; vat: number }>()
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<number>()
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Calculate per-rate VAT breakdown for totals
const vatByRate = hasPerLineVat
? buildPdfVatBreakdown(billableItems)
: new Map<number, { base: number; vat: number }>()
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 (
<Document>
<Page size="A4" style={styles.page}>
{/* Optional branded header: rendered above the status banners so it
sits at the very top of the page. Non-statutory free-form text. */}
{headerText && (
<View style={styles.brandingHeader}>
<Text style={styles.brandingHeaderText}>{headerText}</Text>
</View>
)}
{/* 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' ? (
<View style={styles.cancelledBanner}>
<Text style={styles.cancelledBannerTitle}>{L.cancelledTitle}</Text>
<Text style={styles.cancelledBannerText}>
{invoice.invoice_number
? L.cancelledWithNumber(invoice.invoice_number)
: L.cancelledNoNumber}
</Text>
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>{L.draftTitle}</Text>
<Text style={styles.draftBannerText}>
{invoice.invoice_number
? L.draftWithNumber
: L.draftNoNumber}
</Text>
</View>
)}
{/* Header */}
<View style={styles.header}>
<View style={styles.companyInfo}>
{company.logo_url && (company.invoice_show_logo ?? true) && (
<Image
src={company.logo_url}
style={{
maxHeight: INVOICE_LOGO_MAX_HEIGHT_PT,
maxWidth: INVOICE_LOGO_MAX_WIDTH_PT,
marginBottom: 6,
alignSelf: 'flex-start',
objectFit: 'contain',
// Any logo bigger than the reserved area is clamped to the
// full 240x80pt box, so the box never hugs the image and the
// image is placed *inside* it. Anchor it top-left: with the
// default centering, a near-square logo scaled down to the
// 80pt height cap is only ~117pt wide and gets pushed ~60pt
// in from the left margin, while a wide banner logo fills the
// width and looks correctly aligned. Left-anchoring makes
// every aspect ratio start at the margin instead, so a
// company doesn't have to reshape its logo to fit the layout.
objectPosition: 'left top',
}}
/>
)}
{(company.invoice_show_company_name ?? true) &&
(company.invoice_company_name_position ?? 'header') === 'header' && (
<Text style={styles.companyName}>{company.company_name}</Text>
)}
</View>
<View style={{ textAlign: 'right' }}>
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
{getDocumentTitle(invoice, lang)}
</Text>
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number ?? L.titlePreview}</Text>
</View>
</View>
{/* Credit note reference */}
{isCreditNote && originalInvoiceNumber && (
<View style={styles.creditNoteBox}>
<Text style={styles.creditNoteText}>
{L.creditNoteRef(originalInvoiceNumber)}
</Text>
</View>
)}
{/* Invoice details and Customer - two columns */}
<View style={styles.twoColumn}>
{/* Invoice details */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>{L.invoiceInfoHeading}</Text>
<View style={styles.row}>
<Text style={styles.label}>{L.invoiceDate}</Text>
<Text style={styles.value}>{formatDate(invoice.invoice_date)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>{L.dueDate}</Text>
<Text style={styles.value}>{formatDate(invoice.due_date)}</Text>
</View>
{invoice.delivery_date && invoice.delivery_date !== invoice.invoice_date && (
<View style={styles.row}>
<Text style={styles.label}>{L.deliveryDate}</Text>
<Text style={styles.value}>{formatDate(invoice.delivery_date)}</Text>
</View>
)}
{invoice.your_reference && (
<View style={{ marginBottom: 4 }}>
<Text style={styles.label}>{L.yourReference}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
{invoice.your_reference.split(',').map((ref, i) => (
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
{ref.trim()}
</Text>
))}
</View>
</View>
)}
{invoice.our_reference && (
<View style={{ marginBottom: 4 }}>
<Text style={styles.label}>{L.ourReference}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
{invoice.our_reference.split(',').map((ref, i) => (
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
{ref.trim()}
</Text>
))}
</View>
</View>
)}
</View>
{/* Customer */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>{L.billedToHeading}</Text>
<View style={styles.customerBox}>
<Text style={styles.customerName}>{customer.name}</Text>
{customer.address_line1 && <Text>{customer.address_line1}</Text>}
{customer.address_line2 && <Text>{customer.address_line2}</Text>}
{(customer.postal_code || customer.city) && (
<Text>{customer.postal_code} {customer.city}</Text>
)}
{customer.country && customer.country !== 'SE' && (
<Text>{customer.country}</Text>
)}
{/* 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 && (
<Text style={{ marginTop: 6 }}>{L.custNo} {customer.customer_number}</Text>
)}
{/* 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 && (
<Text style={{ marginTop: 6 }}>{L.orgNo} {customer.org_number}</Text>
)}
{/* 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 && (
<Text>{L.vat} {customer.vat_number}</Text>
)}
</View>
</View>
</View>
{/* Items table */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{L.itemsHeading}</Text>
<View style={styles.table}>
{/* Table header */}
<View style={styles.tableHeader}>
<Text style={[styles.colDescription, styles.tableHeaderText]}>{L.colDescription}</Text>
<Text style={[styles.colQty, styles.tableHeaderText]}>{L.colQty}</Text>
<Text style={[styles.colUnit, styles.tableHeaderText]}>{L.colUnit}</Text>
{!isDeliveryNote && (
<Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text>
)}
{!isDeliveryNote && (
<Text style={[styles.colTotal, styles.tableHeaderText]}>{L.colTotal}</Text>
)}
</View>
{/* 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.
<View key={index} style={styles.tableRow}>
<Text style={[styles.colDescription, { width: '100%' }]}>
{item.description || ' '}
</Text>
</View>
) : (
<View key={index} style={styles.tableRow}>
<Text style={styles.colDescription}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatPdfCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatPdfCurrency(item.line_total, invoice.currency, lang)}</Text>
)}
</View>
)
)}
</View>
</View>
{/* Totals - hidden for delivery notes */}
{!isDeliveryNote && (
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.subtotal}</Text>
<Text style={styles.totalValue}>{formatPdfCurrency(invoice.subtotal, invoice.currency, lang)}</Text>
</View>
{vatByRate.size > 1 ? (
Array.from(vatByRate.entries())
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<View key={rate}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.net(rate)}</Text>
<Text style={styles.totalValue}>{formatPdfCurrency(group.base, invoice.currency, lang)}</Text>
</View>
{group.vat !== 0 && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(rate)}</Text>
<Text style={styles.totalValue}>{formatPdfCurrency(group.vat, invoice.currency, lang)}</Text>
</View>
)}
</View>
))
) : (
// 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) && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
<Text style={styles.totalValue}>{formatPdfCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
</View>
)
)}
{(() => {
// 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 && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>{L.rounding}</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatPdfCurrency(rounding.roundingDelta, 'SEK', lang)}</Text>
</View>
)}
{showDeduction && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.deductionRow}</Text>
<Text style={styles.totalValue}>
{/* 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)}
</Text>
</View>
)}
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatPdfCurrency(grandTotal, invoice.currency, lang)}</Text>
</View>
</>
)
})()}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<View style={{ marginTop: 8 }}>
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.vatInSek(invoice.exchange_rate ?? '')}</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatPdfCurrency(invoice.vat_amount_sek, 'SEK', lang)}</Text>
</View>
)}
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.totalInSek}</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatPdfCurrency(invoice.total_sek, 'SEK', lang)}</Text>
</View>
</View>
)}
</View>
)}
{/* 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 && (
<View style={styles.deductionBox} wrap={false}>
<Text style={styles.deductionTitle}>{L.deductionInfoHeading}</Text>
{invoice.deduction_personnummer_last4 && (
<View style={styles.deductionRow}>
<Text style={styles.deductionLabel}>{L.deductionPersonnummer}</Text>
<Text style={styles.deductionValue}>XXXXXXXX-{invoice.deduction_personnummer_last4}</Text>
</View>
)}
{(() => {
// 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 && (
<View style={styles.deductionRow}>
<Text style={styles.deductionLabel}>{L.deductionHousingDesignation}</Text>
<Text style={styles.deductionValue}>{housing}</Text>
</View>
)}
{apartment && (
<View style={styles.deductionRow}>
<Text style={styles.deductionLabel}>{L.deductionApartmentNumber}</Text>
<Text style={styles.deductionValue}>{apartment}</Text>
</View>
)}
</>
)
})()}
{/* Labor-only disclaimer (Skatteverket fakturamodellen). Per ML
17 kap, only the labor portion qualifies; material must be
invoiced separately. */}
<Text style={styles.deductionNotice}>{DEDUCTION_LABOR_ONLY_NOTICE}</Text>
{/* 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 (
<Text key={idx} style={styles.deductionLineItem}>
{`${kind}${work}: ${i.description}, ${formatPdfCurrency(i.deduction_amount ?? 0, invoice.currency, lang)}`}
</Text>
)
})}
<Text style={styles.deductionNotice}>{L.deductionNotice}</Text>
</View>
)}
{/* Proforma notice */}
{isProforma && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
{L.proformaNotice}
</Text>
</View>
)}
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
{!isCreditNote && !isProforma && !isDeliveryNote && (
<View style={styles.paymentSection}>
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
{invoice.payment_link_url && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.payOnline}</Text>
<Link src={invoice.payment_link_url} style={styles.paymentValue}>
{invoice.payment_link_url.length > 60
? `${invoice.payment_link_url.slice(0, 57)}...`
: invoice.payment_link_url}
</Link>
</View>
)}
{company.bank_name && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.bank}</Text>
<Text style={styles.paymentValue}>{company.bank_name}</Text>
</View>
)}
{(company.clearing_number || company.account_number) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.account}</Text>
<Text style={styles.paymentValue}>
{company.clearing_number}-{company.account_number}
</Text>
</View>
)}
{company.bankgiro && (company.invoice_show_bankgiro ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.bankgiro}</Text>
<Text style={styles.paymentValue}>{company.bankgiro}</Text>
</View>
)}
{company.plusgiro && (company.invoice_show_plusgiro ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.plusgiro}</Text>
<Text style={styles.paymentValue}>{company.plusgiro}</Text>
</View>
)}
{SHOW_SWISH_ON_INVOICE && company.swish && (company.invoice_show_swish ?? false) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.swish}</Text>
<Text style={styles.paymentValue}>{company.swish}</Text>
</View>
)}
{company.iban && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.iban}</Text>
<Text style={styles.paymentValue}>{company.iban}</Text>
</View>
)}
{company.bic && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.bic}</Text>
<Text style={styles.paymentValue}>{company.bic}</Text>
</View>
)}
<View style={[styles.paymentRow, { marginTop: 8 }]}>
<Text style={styles.paymentLabel}>{L.dueDate}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{formatDate(invoice.due_date)}</Text>
</View>
{invoice.invoice_number && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.invoiceNumber}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number}</Text>
</View>
)}
{(company.invoice_show_ocr ?? true) && (company.bankgiro || company.plusgiro) && lang === 'sv' && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>{L.ocr}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '-'}</Text>
</View>
)}
{swishQrDataUrl && (
<View style={{ position: 'absolute', top: 15, right: 15, width: 96, alignItems: 'center' }}>
<Image src={swishQrDataUrl} style={{ width: 96, height: 96 }} />
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.swishQrCaption}</Text>
</View>
)}
{/* Payment-link QR: shifts left when the Swish QR occupies the corner. */}
{paymentLinkQrDataUrl && (
<View style={{ position: 'absolute', top: 15, right: swishQrDataUrl ? 125 : 15, width: 96, alignItems: 'center' }}>
<Image src={paymentLinkQrDataUrl} style={{ width: 96, height: 96 }} />
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.paymentLinkQrCaption}</Text>
</View>
)}
</View>
)}
{/* 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 ? (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{L.notVatRegisteredNotice}</Text>
</View>
) : (
<>
{invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{invoice.reverse_charge_text}</Text>
</View>
)}
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{L.exemptNotice}</Text>
</View>
)}
</>
)}
{/* Notes */}
{invoice.notes && (
<View style={styles.notesBox}>
<Text style={styles.notesText}>{invoice.notes}</Text>
</View>
)}
{/* Late fee & credit terms */}
{(company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
<View style={{ marginTop: 10, marginBottom: 10 }}>
{company.invoice_late_fee_text && (
<Text style={{ fontSize: 8, color: '#666', marginBottom: 2 }}>{company.invoice_late_fee_text}</Text>
)}
{company.invoice_credit_terms_text && (
<Text style={{ fontSize: 8, color: '#666' }}>{company.invoice_credit_terms_text}</Text>
)}
</View>
)}
{/* 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). */}
<View style={styles.footer}>
{footerText && (
<Text style={styles.brandingFooterText}>{footerText}</Text>
)}
<Text style={styles.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(' · ')}
</Text>
</View>
</Page>
</Document>
)
}