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:
co-authored by
Claude Fable 5.1
parent
cbe5580886
commit
eb2ae1da17
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user