Files
accounted/lib/reports/__tests__/reskontra-pdf-template.test.ts
T
Jakob Wennberg cc7050f6cb fix(pdf): keep the minus sign on losses in standard-font PDFs (#1982) (#1987)
Intl sv-SE formats negatives with U+2212, which the bundled react-pdf Helvetica/Courier fonts cannot render, so a loss printed as a profit. lib/pdf/number-text.ts pdfNumberText maps U+2212 to an ASCII hyphen and prints negative zero unsigned; routed through financial-statement, kassaflodesanalys, reskontra, momsdeklaration, payslip (fmt, the literal Preliminar skatt sign and the calculation formula text) and operational-report templates, with content-stream regression tests. The K2/K3 arsredovisning templates keep main's formatPdfKronor from #2013.

Closes #1982
2026-08-30 11:22:05 +02:00

239 lines
8.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { isValidElement } from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import {
ReskontraPDF,
type ReskontraAgingRow,
type ReskontraInvoiceRow,
} from '../reskontra-pdf-template'
import type { CompanySettings } from '@/types'
import { pdfTextStrings } from '@/tests/pdf-text'
/**
* Flatten every string/number leaf of a react-pdf element tree in document
* order. Joining with '' reproduces the text of any single <Text>, which is
* what the unit-labelling assertions below check.
*/
function renderedText(node: unknown): string {
const out: string[] = []
const walk = (n: unknown): void => {
if (n === null || n === undefined || typeof n === 'boolean') return
if (typeof n === 'string' || typeof n === 'number') {
out.push(String(n))
return
}
if (Array.isArray(n)) {
n.forEach(walk)
return
}
if (isValidElement(n)) {
walk((n.props as { children?: unknown }).children)
}
}
walk(node)
// Intl's sv-SE group separator is a non-breaking space; normalise every
// space-like character so the assertions below can use ordinary spaces.
return out.join('').replace(/\s/g, ' ')
}
function fakeCompany(): CompanySettings {
return {
company_name: 'Testbolaget AB',
org_number: '5566778899',
} as unknown as CompanySettings
}
function agingRow(over: Partial<ReskontraAgingRow> = {}): ReskontraAgingRow {
return {
name: 'Acme AB',
current: 0,
days_1_30: 0,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 0,
...over,
}
}
function invoiceRow(over: Partial<ReskontraInvoiceRow> = {}): ReskontraInvoiceRow {
return {
counterparty: 'Acme AB',
invoice_number: 'F001',
invoice_date: '2026-05-01',
due_date: '2026-06-01',
outstanding: 1000,
outstanding_sek: 1000,
currency: 'SEK',
days_overdue: 14,
...over,
}
}
function build(over: {
aging?: ReskontraAgingRow[]
totals?: ReskontraAgingRow
invoices?: ReskontraInvoiceRow[]
unpaidCount?: number
unconvertedFxCount?: number
}) {
return ReskontraPDF({
title: 'Kundreskontra',
counterpartyLabel: 'Kund',
asOfDate: '2026-06-15',
aging: over.aging ?? [],
totals: over.totals ?? agingRow({ name: 'Summa' }),
unpaidCount: over.unpaidCount ?? 0,
unconvertedFxCount: over.unconvertedFxCount ?? 0,
invoices: over.invoices,
company: fakeCompany(),
generatedAt: '2026-06-15T10:00:00Z',
})
}
describe('ReskontraPDF unit disclosure', () => {
it('labels the aging table as SEK even for a SEK-only company', () => {
const text = renderedText(
build({
aging: [agingRow({ days_1_30: 1000, total_outstanding: 1000 })],
totals: agingRow({ name: 'Summa', days_1_30: 1000, total_outstanding: 1000 }),
invoices: [invoiceRow()],
unpaidCount: 1,
})
)
expect(text).toContain('Åldersfördelning per kund (SEK)')
expect(text).toContain('Alla belopp i SEK')
})
it('leaves a SEK-only invoice table without the bridge column and states its unit', () => {
const text = renderedText(
build({
aging: [agingRow({ days_1_30: 1000, total_outstanding: 1000 })],
totals: agingRow({ name: 'Summa', days_1_30: 1000, total_outstanding: 1000 }),
invoices: [invoiceRow(), invoiceRow({ invoice_number: 'F002' })],
unpaidCount: 2,
})
)
// No redundant second SEK column when both tables are already SEK.
expect(text).not.toContain('Utest. SEK')
expect(text).toContain('Fakturor (SEK)')
expect(text).toContain('samma enhet som åldersfördelningen ovan')
})
it('shows the SEK bridge column and unambiguous units for a mixed-currency company', () => {
const text = renderedText(
build({
aging: [agingRow({ days_1_30: 12475, total_outstanding: 12475 })],
totals: agingRow({ name: 'Summa', days_1_30: 12475, total_outstanding: 12475 }),
invoices: [
invoiceRow({ invoice_number: 'F001', outstanding: 1000, outstanding_sek: 1000 }),
invoiceRow({
invoice_number: 'F002',
outstanding: 1000,
outstanding_sek: 11475,
currency: 'EUR',
}),
],
unpaidCount: 2,
})
)
// The aging table is SEK, the line table is invoice currency, and both say so.
expect(text).toContain('Åldersfördelning per kund (SEK)')
expect(text).toContain('Fakturor (fakturans valuta)')
expect(text).toContain('Utest. SEK')
// The bridge amount itself: 1 000 EUR at 11.475 = 11 475,00 SEK.
expect(text).toContain('11 475,00')
expect(text).toContain('ingår i åldersfördelningen ovan')
expect(text).toContain('blandar valutor')
// The SEK column totals to the same figure as the aging summary above it.
expect(text).toContain('12 475,00')
})
it('counts an unconvertible FX invoice visibly instead of dropping it', () => {
const text = renderedText(
build({
aging: [agingRow({ days_1_30: 1000, total_outstanding: 1000 })],
totals: agingRow({ name: 'Summa', days_1_30: 1000, total_outstanding: 1000 }),
invoices: [
invoiceRow({ invoice_number: 'F001' }),
invoiceRow({
invoice_number: 'F002',
outstanding: 500,
outstanding_sek: null,
currency: 'USD',
}),
],
unpaidCount: 2,
unconvertedFxCount: 1,
})
)
// Listed as a row...
expect(text).toContain('F002')
expect(text).toContain('USD')
// ...with the missing conversion called out in the SEK cell...
expect(text).toContain('saknas')
// ...and counted in the note, which explains what it is missing from.
expect(text).toContain('1 faktura i utländsk valuta saknar växelkurs')
expect(text).toContain('ingår därför inte i åldersfördelningen i SEK ovan')
expect(text).toContain('markerade med "saknas" i kolumnen Utest. SEK')
// It stays out of the SEK column total: 1 000, not 1 500.
expect(text).toContain('Summa1 000,00')
expect(text).not.toContain('1 500,00')
})
it('pluralises the unconverted note and omits the column hint without an invoice table', () => {
// Supplier ledger shape: aging only, no per-invoice rows.
const text = renderedText(
build({
aging: [agingRow({ name: 'Leverantör AB', current: 500, total_outstanding: 500 })],
totals: agingRow({ name: 'Summa', current: 500, total_outstanding: 500 }),
unpaidCount: 1,
unconvertedFxCount: 3,
})
)
expect(text).toContain('3 fakturor i utländsk valuta saknar växelkurs')
expect(text).not.toContain('Utest. SEK')
})
it('still lays out to a real PDF with the extra column', async () => {
const doc = build({
aging: [agingRow({ days_1_30: 12475, total_outstanding: 12475 })],
totals: agingRow({ name: 'Summa', days_1_30: 12475, total_outstanding: 12475 }),
invoices: [
invoiceRow({ counterparty: 'Ett ovanligt långt kundnamn AB', outstanding_sek: 1000 }),
invoiceRow({ invoice_number: 'F002', outstanding_sek: 11475, currency: 'EUR' }),
invoiceRow({ invoice_number: 'F003', outstanding_sek: null, currency: 'USD' }),
],
unpaidCount: 3,
unconvertedFxCount: 1,
})
const buffer = await renderToBuffer(doc)
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
expect(buffer.length).toBeGreaterThan(1000)
// Real react-pdf layout plus font loading: slower than the 5s default.
}, 30000)
})
describe('ReskontraPDF sign rendering (issue #1982)', () => {
it('prints a credit-note balance with an ASCII minus the bundled font can draw', async () => {
const buffer = await renderToBuffer(
build({
aging: [agingRow({ current: -500, total_outstanding: -500 })],
totals: agingRow({ name: 'Summa', current: -500, total_outstanding: -500 }),
invoices: [invoiceRow({ outstanding: -500, outstanding_sek: -500 })],
unpaidCount: 1,
})
)
const text = pdfTextStrings(buffer).join('\n')
expect(text).toContain('-500,00')
expect(text).not.toContain(String.fromCharCode(0x12))
expect(text).not.toContain('\u2212')
}, 30_000)
})