fix(invoices): render statutory PDF notices in the document language (#2321)

* fix(invoices): render statutory PDF notices in the document language

An English invoice PDF printed "Omsättning utanför EU, ML 10 kap." and
"Godkänd för F-skatt" in Swedish, and the notice boxes below the totals
(proforma, VAT notice, notes) each had their own colour, border and
spacing, so the stack looked patchy.

The export notice is stamped in Swedish on invoices.reverse_charge_text at
create time and stored as a snapshot; the PDF printed it verbatim. The
template now matches the stored text against the shared EXPORT_NOTICE_SV
constant from vat-rules.ts and renders it from LABELS in the document
language, so already-created invoices are fixed as well. Custom or unknown
text is printed exactly as stored. The English footer reads "Approved for
F-tax (Godkänd för F-skatt)": SFL 10 kap. 12 § requires the approval to be
stated but prescribes no language, and Peppol SE-R-005 is satisfied by the
UBL file, which is unchanged.

All notices share one noticeBox style: same border, radius, padding and
spacing in a neutral palette.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYtWUhYjfQyrEJodjipxW1

* docs(invoices): cite the skill for the F-tax wording and add docstrings

Review pass on PR #2321: the Swedish compliance review flagged that the
F-skatt comment asserted an SFL paragraph without a skill citation, so the
comment and the DECISIONS.md line now rest on what the
swedish-invoice-compliance skill states (no language requirement for
invoice text in ML) and on the literal Swedish phrase staying on the PDF.
CodeRabbit's docstring check wanted JSDoc on localizeVatNotice and the
test helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYtWUhYjfQyrEJodjipxW1

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-05 16:32:48 +02:00
committed by GitHub
parent cbe5580886
commit eb2ae1da17
4 changed files with 176 additions and 34 deletions
+1
View File
@@ -1602,4 +1602,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] PR #2305 review: encrypt both OAuth handoff payload columns with AES-256-GCM using a purpose-scoped derivation of the existing server-only service-role secret, matching other extension credential storage. Authenticate the handoff token, consent, initiating user, destination origin and column as additional data; reject plaintext or unreadable payloads after atomic consume. This resolves the at-rest encryption finding without new configuration, dependencies, or edits to the already-applied migration.
[2026-09-05] PR #2305 cleanup review: expire provider_otc rows through a service-role cron every five minutes, including abandoned states and encrypted handoffs whose consents remain. Use the existing cron-auth wrapper and generated hosted/self-hosted schedules; the migration is already applied on staging and remains unchanged.
[2026-09-05] Supplier and customer pages: the register fills the row's own contact fields (e-mail, phone, postal address, VAT number) when they are empty or still carry what the register said last time, marked "från SCB" by equality with the registry fact, never a value a person typed. Chosen over a read-only fallback because the row is what payment files and documents use; provenance by equality instead of a source column because it needs no schema and a person's edit ends it by itself. Företagsuppgifter keeps only what the register alone knows (status line, industry, seat, size). Agents get the party read-only first: ?expand=party on v1 supplier/customer detail, party_id on list rows, and gnubok_get_party in MCP; the parties resource (suggest, promote, enrich) comes as its own v1 surface next.
[2026-09-05] Invoice PDF statutory notices follow the document language: the export notice (stored in Swedish on invoices.reverse_charge_text at create time) is matched against the shared EXPORT_NOTICE_SV constant and rendered from LABELS, so existing invoices are fixed too, and the English footer reads "Approved for F-tax (Godkänd för F-skatt)". Rejected translating at create time (would leave every existing export invoice Swedish) and deriving purely from vat_treatment (would override custom or v1-supplied text). The old Swedish-only F-skatt footer cited Peppol SE-R-005, which governs the UBL file, not the PDF; the swedish-invoice-compliance skill (invoice-rules.md §4) states ML has no language requirement for invoice text, and the literal Swedish phrase stays on the English PDF in parentheses. All notice boxes (proforma, quote, VAT notice, notes) now share one neutral style; custom invoice layouts are a separate feature request.
[2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups.
@@ -0,0 +1,115 @@
/**
* Statutory notices on the invoice PDF follow the document language.
*
* reverse_charge_text is stamped in Swedish at create time and stored on the
* invoice, so an English PDF used to print "Omsättning utanför EU, ML 10 kap."
* verbatim, and the F-skatt footer was hard-wired to Swedish. Known statutory
* defaults now render from LABELS; custom text is printed as stored.
*/
import { describe, expect, it } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { InvoicePDF, localizeVatNotice, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template'
import { EU_REVERSE_CHARGE_NOTICE, EXPORT_NOTICE_SV, getVatRules } from '@/lib/invoices/vat-rules'
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import type { InvoiceItem } from '@/types'
/** Every string leaf in the element tree, in document order. */
function textLeaves(node: ReactNode, out: string[] = []): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return out
if (typeof node === 'string' || typeof node === 'number') {
out.push(String(node))
return out
}
if (Array.isArray(node)) {
for (const child of node) textLeaves(child, out)
return out
}
const element = node as ReactElement<{ children?: ReactNode }>
if (element.props) textLeaves(element.props.children, out)
return out
}
const items: InvoiceItem[] = [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
line_type: 'product',
description: 'Retainer',
quantity: 1,
unit: 'st',
unit_price: 2500,
line_total: 2500,
vat_rate: 0,
vat_amount: 0,
created_at: '2026-09-05T00:00:00Z',
},
]
const company = makeCompanySettings({ f_skatt: true, vat_registered: true })
/** Render the PDF for a non-EU business customer and return its visible text. */
function renderText(invoice: InvoicePdfInvoice, language: 'sv' | 'en'): string {
const tree = InvoicePDF({
invoice,
customer: makeCustomer({ language, country: 'GB', customer_type: 'non_eu_business' }),
items,
company,
paymentLinkQrDataUrl: null,
swishQrDataUrl: null,
})
return textLeaves(tree).join('\n')
}
/** A GBP proforma to a non-EU customer, stamped with the export notice as getVatRules() writes it. */
const exportInvoice = (overrides: Partial<InvoicePdfInvoice> = {}): InvoicePdfInvoice =>
makeInvoice({
id: 'inv-1',
status: 'sent',
document_type: 'proforma',
currency: 'GBP',
subtotal: 2500,
vat_rate: 0,
vat_amount: 0,
total: 2500,
vat_treatment: 'export',
reverse_charge_text: getVatRules('non_eu_business').reverseChargeText ?? null,
...overrides,
})
describe('invoice PDF statutory notices follow the document language', () => {
it('renders the stored Swedish export notice in English on an English PDF', () => {
const text = renderText(exportInvoice(), 'en')
expect(text).toContain('Sale outside the EU, exempt from Swedish VAT (ML 10 kap., Swedish VAT Act).')
expect(text).not.toContain(EXPORT_NOTICE_SV)
})
it('keeps the Swedish export notice on a Swedish PDF', () => {
const text = renderText(exportInvoice(), 'sv')
expect(text).toContain(EXPORT_NOTICE_SV)
expect(text).not.toContain('Sale outside the EU')
})
it('prints the F-skatt footer as Approved for F-tax with the Swedish term kept', () => {
expect(renderText(exportInvoice(), 'en')).toContain('Approved for F-tax (Godkänd för F-skatt)')
const sv = renderText(exportInvoice(), 'sv')
expect(sv).toContain('Godkänd för F-skatt')
expect(sv).not.toContain('Approved for F-tax')
})
it('prints custom or unknown reverse-charge text exactly as stored', () => {
const custom = 'Byggtjänst, omvänd betalningsskyldighet enligt ML 10 kap. 6 §'
expect(renderText(exportInvoice({ reverse_charge_text: custom }), 'en')).toContain(custom)
expect(localizeVatNotice(custom, 'en')).toBe(custom)
})
it('leaves the bilingual EU reverse-charge notice untouched in both languages', () => {
expect(localizeVatNotice(EU_REVERSE_CHARGE_NOTICE, 'en')).toBe(EU_REVERSE_CHARGE_NOTICE)
expect(localizeVatNotice(EU_REVERSE_CHARGE_NOTICE, 'sv')).toBe(EU_REVERSE_CHARGE_NOTICE)
})
it('stamps the shared constants at create time so the PDF match cannot drift', () => {
expect(getVatRules('non_eu_business').reverseChargeText).toBe(EXPORT_NOTICE_SV)
expect(getVatRules('eu_business', true, 'DE').reverseChargeText).toBe(EU_REVERSE_CHARGE_NOTICE)
})
})
+48 -32
View File
@@ -22,6 +22,7 @@ import { getAmountToPay } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display'
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
import { getCountryName } from '@/lib/vat/country-codes'
import { EXPORT_NOTICE_SV } from '@/lib/invoices/vat-rules'
type PdfLang = 'sv' | 'en'
@@ -102,6 +103,7 @@ const LABELS = {
proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.',
quoteNotice: 'Detta är en offert och utgör ingen faktura eller betalningsanmodan.',
exemptNotice: 'Undantag från skatteplikt, ML 3 kap.',
exportNotice: EXPORT_NOTICE_SV,
notVatRegisteredNotice: 'Företaget är inte momsregistrerat. Mervärdesskatt redovisas ej.',
// Payment
paymentHeading: 'Betalningsinformation',
@@ -189,6 +191,7 @@ const LABELS = {
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
quoteNotice: 'This is a quote and is not an invoice or a request for payment.',
exemptNotice: 'Exempt from VAT (ML 3 kap., Swedish VAT Act).',
exportNotice: 'Sale outside the EU, exempt from Swedish VAT (ML 10 kap., Swedish VAT Act).',
notVatRegisteredNotice: 'The seller is not VAT-registered. No VAT is charged on this invoice.',
paymentHeading: 'Payment information',
bank: 'Bank:',
@@ -210,10 +213,14 @@ const LABELS = {
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',
// The F-skatt approval must be stated on the invoice, but ML sets no
// language requirement for invoice text (swedish-invoice-compliance,
// invoice-rules.md §4). Skatteverket's own English term is "approved for
// F-tax"; the Swedish phrase stays in parentheses so the literal statutory
// wording is still on the document. Peppol SE-R-005 (the literal string
// rule) applies to the UBL file (peppol-bis-billing.ts), which is
// unaffected by PDF language.
fSkatt: 'Approved for F-tax (Godkänd för F-skatt)',
},
} as const
@@ -221,6 +228,21 @@ const LABELS = {
// and QR render on the invoice PDF and the settings "Visa Swish" toggle is live.
export const SHOW_SWISH_ON_INVOICE = true
/**
* Render a stored VAT notice in the document language.
*
* reverse_charge_text is stamped in Swedish at create time and stored on the
* invoice, so an English PDF of an existing export invoice would otherwise
* print "Omsättning utanför EU, ML 10 kap." verbatim. Known statutory defaults
* (byte-identical to the constants getVatRules() writes) are rendered from
* LABELS in the document language; any other text (custom or unknown) is
* printed exactly as stored.
*/
export function localizeVatNotice(text: string, lang: PdfLang): string {
if (text === EXPORT_NOTICE_SV) return LABELS[lang].exportNotice
return text
}
// 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.
@@ -476,27 +498,21 @@ function createStyles(branding?: InvoiceBranding) {
paymentValue: {
flex: 1,
},
reverseChargeBox: {
marginTop: 20,
// One shape for every notice below the totals (proforma / quote notice,
// statutory VAT notice, free-text notes): the same border, radius,
// padding and spacing, in a neutral palette that does not fight the
// brand colour. Per-notice colours made the stack look patchy.
noticeBox: {
marginTop: 12,
padding: 12,
backgroundColor: '#fff3cd',
backgroundColor: '#f8f9fa',
borderRadius: 4,
borderWidth: 1,
borderColor: '#ffc107',
borderColor: '#dee2e6',
},
reverseChargeText: {
noticeText: {
fontSize: 9,
color: '#856404',
},
notesBox: {
marginTop: 20,
padding: 12,
backgroundColor: '#e8f4fd',
borderRadius: 4,
},
notesText: {
fontSize: 9,
color: '#0c5460',
color: '#495057',
},
creditNoteBox: {
marginBottom: 20,
@@ -1254,8 +1270,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Proforma notice */}
{isProforma && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>
{L.proformaNotice}
</Text>
</View>
@@ -1263,8 +1279,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Quote notice */}
{isQuote && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>
{L.quoteNotice}
</Text>
</View>
@@ -1388,19 +1404,19 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
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 style={styles.noticeBox}>
<Text style={styles.noticeText}>{L.notVatRegisteredNotice}</Text>
</View>
) : (
<>
{invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{invoice.reverse_charge_text}</Text>
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{localizeVatNotice(invoice.reverse_charge_text, lang)}</Text>
</View>
)}
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{L.exemptNotice}</Text>
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{L.exemptNotice}</Text>
</View>
)}
</>
@@ -1408,8 +1424,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Notes */}
{invoice.notes && (
<View style={styles.notesBox}>
<Text style={styles.notesText}>{invoice.notes}</Text>
<View style={styles.noticeBox}>
<Text style={styles.noticeText}>{invoice.notes}</Text>
</View>
)}
+12 -2
View File
@@ -176,6 +176,16 @@ export interface VatRule {
reverseChargeText?: string
}
/**
* Statutory notices stamped on an invoice's reverse_charge_text at create
* time. The stored value is a snapshot, so the PDF template matches against
* these exact strings to render the notice in the recipient's language;
* keep them byte-identical to what getVatRules() writes.
*/
export const EU_REVERSE_CHARGE_NOTICE =
'Omvänd skattskyldighet / Reverse charge - VAT to be accounted for by the recipient as per Article 196, Council Directive 2006/112/EC'
export const EXPORT_NOTICE_SV = 'Omsättning utanför EU, ML 10 kap.'
/**
* Determine VAT treatment based on customer type and VAT validation status.
*
@@ -209,7 +219,7 @@ export function getVatRules(
treatment: 'reverse_charge',
rate: 0,
momsRuta: '39',
reverseChargeText: 'Omvänd skattskyldighet / Reverse charge - VAT to be accounted for by the recipient as per Article 196, Council Directive 2006/112/EC',
reverseChargeText: EU_REVERSE_CHARGE_NOTICE,
}
}
// EU business without validated VAT number, or one whose country is
@@ -225,7 +235,7 @@ export function getVatRules(
treatment: 'export',
rate: 0,
momsRuta: '40',
reverseChargeText: 'Omsättning utanför EU, ML 10 kap.',
reverseChargeText: EXPORT_NOTICE_SV,
}
default: