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
This commit is contained in:
@@ -1298,6 +1298,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
|
||||
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
|
||||
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
|
||||
[2026-08-27] PDF minus sign: added lib/pdf/number-text.ts (pdfNumberText for locale-formatted amounts, pdfText for free text) instead of embedding a Unicode font: sv-SE Intl writes U+2212, which bundled Helvetica/Courier cannot draw, so losses printed unsigned (#1982). Routed through it: financial-statement (RR/BR), kassaflodesanalys, reskontra, momsdeklaration, operational-report and the payslip (fmt, the literal Preliminar skatt prefix, and the calculation-engine formula strings via pdfText; the engine strings themselves keep U+2212 because they are shown on screen too). The K2/K3 arsredovisning templates and note builders use formatPdfKronor from #2013 instead (whole kronor, NBSP grouping), so they do not import pdfNumberText. Font embedding would change every report's look and size for a one-glyph problem; the ASCII hyphen is what the invoice and webshop PDFs already use. Negative zero is printed unsigned on purpose, matching the screen.
|
||||
[2026-08-27] gnubok_update_customer promoted to the default catalog (tools/list), superseding the 2026-08-25 entry that kept it search-only: that entry assumed gnubok_search_tools reachability was enough, but Claude.ai only calls tools present in tools/list (SilverPark E2E 2026-08-26; same reason as the #1936/#1967 promotions), so the tool was uncallable on the primary connector and the reporter of #1706/#1876 read it as missing twice. Bench ceiling 63.4K to 64.1K (+761 measured). Not chosen: a description hint on gnubok_create_customer pointing at the update tool: it would not make the tool callable, and it made gnubok_create_customer rank for "update customer" in gnubok_search_tools.
|
||||
[2026-08-27] MCP update_invoice keeps FULL REPLACE item semantics (no preserve-on-omit) but its lines now accept article_id with the same prefill and default-set VAT adoption guard as create_invoice, plus the permitted-set VAT gate at staging; gnubok_get_invoice is the round-trip read surface (search-only: payload-size.bench.test.ts has no headroom and the update tool it serves is search-only too); the staged preview snapshots the lines being replaced and the effective vat_rate/revenue_account per new line so an approver sees a rebooking; the commit executor scope-checks staged article ids like create does (the FK proves existence only, and arg-guard never sees nested keys). Supersedes the 2026-08-17 'create_invoice only' entry (issue #1642). The web PATCH route's missing article scope check is a separate follow-up.
|
||||
[2026-08-27] MCP invoice round trip closed for non-article lines (skeptic review of #1993): gnubok_get_invoice now returns the ROT property columns (housing_designation/apartment_number/brf_org_number: property identifiers, never the personnummer ciphertext) and gnubok_update_invoice declares line_type/revenue_account/deduction/accrual fields on its item schema, exempts text rows from the quantity gates like CreateInvoiceItemSchema, and gates ROT/RUT completeness (arbetstyp/timmar, fastighetsbeteckning, personnummer availability) at staging so the failure reaches the agent, not the approver. gnubok_create_invoice only gained line_type + revenue_account: commitCreateInvoice drops deduction/accrual fields, so declaring them there would stage silent loss, and the tools/list ceiling (63.4K) has ~60 tokens of headroom left.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Issue #1982: a loss printed as a profit in the årsredovisning PDF.
|
||||
*
|
||||
* sv-SE number formatting writes negatives with U+2212 MINUS SIGN. The
|
||||
* bundled Helvetica has no glyph for it, so the rendered document carried an
|
||||
* unmapped byte where the sign should be and "Årets resultat" read as
|
||||
* +4 684 while "Summa eget kapital" (20 316) only reconciled with -4 684.
|
||||
* These tests decode the content streams of the real render, which is the
|
||||
* only place the bug is visible: the element tree always had the sign.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { ArsredovisningPDF } from '../arsredovisning-pdf'
|
||||
import { ArsredovisningK3PDF } from '../arsredovisning-k3-pdf'
|
||||
import type { ArsredovisningData } from '../types'
|
||||
import { pdfTextStrings } from '@/tests/pdf-text'
|
||||
|
||||
const RENDER_TIMEOUT = 30_000
|
||||
|
||||
/** Unmapped-glyph byte react-pdf emits for U+2212 in a WinAnsi font. */
|
||||
const UNMAPPED = String.fromCharCode(0x12)
|
||||
|
||||
function makeLossYearData(framework: 'k2' | 'k3'): ArsredovisningData {
|
||||
return {
|
||||
company: {
|
||||
name: 'Testbolaget AB',
|
||||
org_number: '556677-8899',
|
||||
entity_type: 'aktiebolag',
|
||||
city: 'Stockholm',
|
||||
},
|
||||
fiscal_period: {
|
||||
id: 'fp1',
|
||||
name: 'Första räkenskapsåret 2025/2026',
|
||||
period_start: '2025-10-14',
|
||||
period_end: '2026-01-31',
|
||||
},
|
||||
previous_period: null,
|
||||
accounting_framework: framework,
|
||||
forvaltningsberattelse: {
|
||||
description: 'Bolaget bedriver konsultverksamhet inom IT.',
|
||||
important_events: 'Inga väsentliga händelser.',
|
||||
kontrollbalans_required: false,
|
||||
flerarsoversikt: [
|
||||
{ year: '2025/2026', net_revenue: 0, result_after_financial: -4684, soliditet_pct: 100 },
|
||||
],
|
||||
egen_kapital_changes: [
|
||||
{ label: 'Aktiekapital', amount: 25_000 },
|
||||
{ label: 'Årets resultat', amount: -4684 },
|
||||
{ label: 'Summa eget kapital', amount: 20_316 },
|
||||
],
|
||||
resultatdisposition: 'Styrelsen föreslår att årets förlust balanseras i ny räkning.',
|
||||
proposed_dividend: 0,
|
||||
resultatdisposition_amounts: {
|
||||
retained_earnings: 0,
|
||||
share_premium_reserve: 0,
|
||||
current_year_result: -4684,
|
||||
total: -4684,
|
||||
proposed_dividend: 0,
|
||||
carried_forward: -4684,
|
||||
},
|
||||
agm_date: '2026-06-15',
|
||||
agm_disposition_outcome: 'proposal_approved',
|
||||
agm_disposition_decision: null,
|
||||
},
|
||||
resultatrakning: [
|
||||
{ label: 'Rörelsekostnader', current: null, previous: null, is_heading: true },
|
||||
{ label: 'Övriga externa kostnader', current: -4684, previous: null, indent: 1 },
|
||||
{ label: 'Summa rörelsekostnader', current: -4684, previous: null, is_total: true },
|
||||
{ label: 'Rörelseresultat', current: -4684, previous: null, is_total: true },
|
||||
{
|
||||
label: 'Årets resultat',
|
||||
current: -4684,
|
||||
previous: null,
|
||||
is_total: true,
|
||||
semantic_key: 'income_statement_result',
|
||||
},
|
||||
],
|
||||
balansrakning: {
|
||||
assets: [
|
||||
{ label: 'Kassa och bank', current: 20_316, previous: null, indent: 2 },
|
||||
{ label: 'Summa tillgångar', current: 20_316, previous: null, is_total: true },
|
||||
],
|
||||
total_assets: 20_316,
|
||||
total_assets_previous: null,
|
||||
equity_liabilities: [
|
||||
{ label: 'Eget kapital', current: null, previous: null, is_heading: true },
|
||||
{ label: 'Aktiekapital', current: 25_000, previous: null, indent: 2 },
|
||||
{ label: 'Balanserat resultat', current: 0, previous: null, indent: 2 },
|
||||
{
|
||||
label: 'Årets resultat',
|
||||
current: -4684,
|
||||
previous: null,
|
||||
indent: 2,
|
||||
semantic_key: 'balance_sheet_current_year_result',
|
||||
},
|
||||
{ label: 'Summa fritt eget kapital', current: -4684, previous: null, is_total: true, indent: 1 },
|
||||
{ label: 'Summa eget kapital', current: 20_316, previous: null, is_total: true },
|
||||
{ label: 'Summa eget kapital och skulder', current: 20_316, previous: null, is_total: true },
|
||||
],
|
||||
total_equity_liabilities: 20_316,
|
||||
total_equity_liabilities_previous: null,
|
||||
},
|
||||
noter: [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Redovisnings- och värderingsprinciper',
|
||||
body: 'Årsredovisningen är upprättad enligt BFNAR 2016:10.',
|
||||
},
|
||||
],
|
||||
signatures: [],
|
||||
warnings: [],
|
||||
disclosures: {
|
||||
long_term_debt_over_five_years: null,
|
||||
securities_pledged: null,
|
||||
contingent_liabilities: null,
|
||||
parent_company_name: null,
|
||||
parent_company_org_number: null,
|
||||
parent_company_city: null,
|
||||
confirmations: {
|
||||
long_term_debt_over_five_years: true,
|
||||
securities_pledged: true,
|
||||
contingent_liabilities: true,
|
||||
parent_company: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function expectSignedLoss(pages: string[]): void {
|
||||
const text = pages.join('\n')
|
||||
// The sign reaches the file as an ASCII hyphen the font can draw ...
|
||||
expect(text).toContain('-4 684')
|
||||
// ... and never as the unmapped byte a WinAnsi font draws as nothing.
|
||||
expect(text).not.toContain(UNMAPPED)
|
||||
expect(text).not.toContain('−')
|
||||
// The positive totals are untouched.
|
||||
expect(text).toContain('20 316')
|
||||
expect(text).toContain('25 000')
|
||||
}
|
||||
|
||||
describe('årsredovisning PDF keeps the sign of a loss (issue #1982)', () => {
|
||||
it('K2: Årets resultat and Summa fritt eget kapital print as -4 684', async () => {
|
||||
const buffer = await renderToBuffer(ArsredovisningPDF({ data: makeLossYearData('k2') }))
|
||||
expectSignedLoss(pdfTextStrings(buffer))
|
||||
}, RENDER_TIMEOUT)
|
||||
|
||||
it('K3: the same rows print signed', async () => {
|
||||
const buffer = await renderToBuffer(ArsredovisningK3PDF({ data: makeLossYearData('k3') }))
|
||||
expectSignedLoss(pdfTextStrings(buffer))
|
||||
}, RENDER_TIMEOUT)
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pdfNumberText, pdfText, UNICODE_MINUS } from '../number-text'
|
||||
|
||||
describe('pdfNumberText', () => {
|
||||
it('maps every U+2212 to the ASCII hyphen-minus', () => {
|
||||
const formatted = (-4684.24).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
|
||||
expect(formatted).toContain(UNICODE_MINUS)
|
||||
const safe = pdfNumberText(formatted)
|
||||
expect(safe).not.toContain(UNICODE_MINUS)
|
||||
expect(safe.startsWith('-4')).toBe(true)
|
||||
expect(pdfNumberText(`${UNICODE_MINUS}1 ${UNICODE_MINUS}2`)).toBe('-1 -2')
|
||||
})
|
||||
|
||||
it('prints a negative zero unsigned', () => {
|
||||
expect(pdfNumberText((-0).toLocaleString('sv-SE'))).toBe('0')
|
||||
expect(pdfNumberText(`${UNICODE_MINUS}0,00`)).toBe('0,00')
|
||||
expect(pdfNumberText('-0.00')).toBe('0.00')
|
||||
// A real small negative keeps its sign.
|
||||
expect(pdfNumberText(`${UNICODE_MINUS}0,01`)).toBe('-0,01')
|
||||
})
|
||||
|
||||
it('leaves text without a minus sign untouched', () => {
|
||||
expect(pdfNumberText('20 316')).toBe('20 316')
|
||||
expect(pdfNumberText('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pdfText', () => {
|
||||
it('maps every U+2212 in free text to the ASCII hyphen-minus', () => {
|
||||
expect(pdfText(`bruttolön ${UNICODE_MINUS} skatt ${UNICODE_MINUS} nettoavdrag`)).toBe(
|
||||
'bruttolön - skatt - nettoavdrag'
|
||||
)
|
||||
expect(pdfText(`${UNICODE_MINUS}(full lön ${UNICODE_MINUS} sjuklön + karensavdrag)`)).toBe(
|
||||
'-(full lön - sjuklön + karensavdrag)'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat the text as a number', () => {
|
||||
// A formula that happens to start with "-0" keeps its sign.
|
||||
expect(pdfText(`${UNICODE_MINUS}0,5 × lön`)).toBe('-0,5 × lön')
|
||||
expect(pdfText('grundlön + tillägg')).toBe('grundlön + tillägg')
|
||||
expect(pdfText('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Sign guard for numbers rendered with @react-pdf/renderer.
|
||||
*
|
||||
* `Intl.NumberFormat('sv-SE')` and `toLocaleString('sv-SE')` write negatives
|
||||
* with U+2212 MINUS SIGN, not the ASCII hyphen-minus. The bundled standard
|
||||
* PDF fonts (Helvetica, Courier, Times: WinAnsi encoding) have no glyph for
|
||||
* U+2212, so react-pdf emits an unmapped byte and the viewer draws nothing:
|
||||
* a loss prints as a profit, a credit note as a charge (issue #1982, first
|
||||
* seen on Årets resultat in the årsredovisning PDF).
|
||||
*
|
||||
* Run every locale-formatted amount through this before it reaches a <Text>
|
||||
* that uses a standard font. Embedded TrueType fonts do carry the glyph, but
|
||||
* the ASCII hyphen renders identically there, so applying it everywhere is
|
||||
* safe.
|
||||
*/
|
||||
|
||||
/** Built via fromCharCode so no editor or transport can normalise it away. */
|
||||
export const UNICODE_MINUS = String.fromCharCode(0x2212)
|
||||
|
||||
/** "-0", "-0,00", "-0.00": a negated zero or sub-öre rounding noise. */
|
||||
const NEGATIVE_ZERO = /^-0(?:[.,]0+)?$/
|
||||
|
||||
/**
|
||||
* Locale-formatted number text made safe for a standard PDF font: U+2212
|
||||
* becomes the ASCII hyphen-minus, and a negative zero (which the dropped
|
||||
* glyph used to hide) prints unsigned, as it does on screen.
|
||||
*/
|
||||
export function pdfNumberText(text: string): string {
|
||||
const ascii = text.replaceAll(UNICODE_MINUS, '-')
|
||||
return NEGATIVE_ZERO.test(ascii) ? ascii.slice(1) : ascii
|
||||
}
|
||||
|
||||
/**
|
||||
* Free text (a formula, a label) made safe for a standard PDF font: U+2212
|
||||
* becomes the ASCII hyphen-minus. Unlike pdfNumberText it never touches a
|
||||
* leading "-0", because the text is not a single number.
|
||||
*/
|
||||
export function pdfText(text: string): string {
|
||||
return text.replaceAll(UNICODE_MINUS, '-')
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { FinancialStatementPDF } from '../financial-statement-pdf-template'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { pdfTextStrings } from '@/tests/pdf-text'
|
||||
|
||||
function fakeCompany(): CompanySettings {
|
||||
return {
|
||||
@@ -140,4 +141,40 @@ describe('FinancialStatementPDF', () => {
|
||||
const buffer = await renderToBuffer(doc)
|
||||
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
||||
}, RENDER_TIMEOUT)
|
||||
|
||||
it('prints a loss with its sign in the rendered bytes (issue #1982)', async () => {
|
||||
// sv-SE formats negatives with U+2212, which the bundled Helvetica cannot
|
||||
// draw: Årets resultat -4 684,24 used to print as 4 684,24 in both the
|
||||
// resultaträkning summary and the balansräkning 2099 row.
|
||||
const doc = FinancialStatementPDF({
|
||||
title: 'Balansräkning',
|
||||
groups: [
|
||||
{
|
||||
heading: 'Eget kapital och skulder',
|
||||
sections: [
|
||||
{
|
||||
title: 'Eget kapital',
|
||||
rows: [
|
||||
{ account_number: '2081', account_name: 'Aktiekapital', amount: 25_000 },
|
||||
{ account_number: '2099', account_name: 'Årets resultat', amount: -4684.24 },
|
||||
],
|
||||
subtotal: 20_315.76,
|
||||
},
|
||||
],
|
||||
totalLabel: 'Summa eget kapital och skulder',
|
||||
total: 20_315.76,
|
||||
},
|
||||
],
|
||||
summary: [{ label: 'Årets resultat', amount: -4684.24, emphasis: true }],
|
||||
period: { start: '2025-10-14', end: '2026-01-31' },
|
||||
company: fakeCompany(),
|
||||
generatedAt: '2026-08-27T10:00:00Z',
|
||||
})
|
||||
|
||||
const text = pdfTextStrings(await renderToBuffer(doc)).join('\n')
|
||||
expect(text).toContain('-4 684,24')
|
||||
expect(text).not.toContain(String.fromCharCode(0x12))
|
||||
expect(text).not.toContain('\u2212')
|
||||
expect(text).toContain('20 315,76')
|
||||
}, RENDER_TIMEOUT)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { KassaflodesanalysPDF } from '../kassaflodesanalys-pdf-template'
|
||||
import type { KassaflodesanalysReport } from '../kassaflodesanalys'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { pdfTextStrings } from '@/tests/pdf-text'
|
||||
|
||||
function fakeCompany(): CompanySettings {
|
||||
return {
|
||||
company_name: 'Testbolaget AB',
|
||||
org_number: '5566778899',
|
||||
} as unknown as CompanySettings
|
||||
}
|
||||
|
||||
function report(): KassaflodesanalysReport {
|
||||
return {
|
||||
fiscal_period_id: 'fp-1',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
lopande: {
|
||||
resultat_efter_finansiella_poster: -4684.24,
|
||||
avskrivningar: 1000,
|
||||
ovriga_ej_kassaflodesposter: 0,
|
||||
delta_kortfristiga_fordringar: -250.5,
|
||||
delta_varulager: 0,
|
||||
delta_kortfristiga_skulder: 300,
|
||||
skatt_betald: 0,
|
||||
total: -3634.74,
|
||||
},
|
||||
investerings: {
|
||||
forvarv_anlaggningar: -12000,
|
||||
avyttring_anlaggningar: 0,
|
||||
total: -12000,
|
||||
},
|
||||
finansierings: {
|
||||
delta_lan: 0,
|
||||
utdelningar: 0,
|
||||
nyemission: 0,
|
||||
erhallna_aktieagartillskott: 0,
|
||||
total: 0,
|
||||
},
|
||||
total_cash_flow: -15634.74,
|
||||
reconciliation: {
|
||||
opening_cash_1xxx: 50000,
|
||||
closing_cash_1xxx: 34365.26,
|
||||
delta_actual: -15634.74,
|
||||
delta_calculated: -15634.74,
|
||||
mismatch_amount: 0,
|
||||
is_reconciled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('KassaflodesanalysPDF sign rendering (issue #1982)', () => {
|
||||
it('prints negative cash flows with an ASCII minus the bundled font can draw', async () => {
|
||||
const text = pdfTextStrings(
|
||||
await renderToBuffer(
|
||||
KassaflodesanalysPDF({
|
||||
report: report(),
|
||||
company: fakeCompany(),
|
||||
generatedAt: '2026-08-29T10:00:00Z',
|
||||
})
|
||||
)
|
||||
).join('\n')
|
||||
|
||||
expect(text).toContain('-4 684,24')
|
||||
expect(text).toContain('-12 000,00')
|
||||
expect(text).toContain('-15 634,74')
|
||||
expect(text).not.toContain(String.fromCharCode(0x12))
|
||||
expect(text).not.toContain('−')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
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
|
||||
@@ -218,3 +219,20 @@ describe('ReskontraPDF unit disclosure', () => {
|
||||
// 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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { VatDeclarationPDF } from '../vat-declaration-pdf-template'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { pdfTextStrings } from '@/tests/pdf-text'
|
||||
|
||||
function fakeCompany(): CompanySettings {
|
||||
return {
|
||||
company_name: 'Testbolaget AB',
|
||||
org_number: '5566778899',
|
||||
vat_number: 'SE556677889901',
|
||||
} as unknown as CompanySettings
|
||||
}
|
||||
|
||||
describe('VatDeclarationPDF sign rendering (issue #1982)', () => {
|
||||
it('prints a negative ruta with an ASCII minus the bundled font can draw', async () => {
|
||||
const text = pdfTextStrings(
|
||||
await renderToBuffer(
|
||||
VatDeclarationPDF({
|
||||
rows: [
|
||||
{ ruta: '05', label: 'Momspliktig försäljning', amount: 100000 },
|
||||
{ ruta: '10', label: 'Utgående moms 25 %', amount: 25000 },
|
||||
// A credit-note-heavy period can push a ruta below zero.
|
||||
{ ruta: '48', label: 'Ingående moms att dra av', amount: -1234 },
|
||||
{ ruta: '49', label: 'Moms att betala', amount: 26234, isNet: true },
|
||||
],
|
||||
period: { start: '2026-04-01', end: '2026-06-30' },
|
||||
periodLabel: 'April till juni 2026',
|
||||
company: fakeCompany(),
|
||||
generatedAt: '2026-08-29T10:00:00Z',
|
||||
})
|
||||
)
|
||||
).join('\n')
|
||||
|
||||
expect(text).toContain('-1 234')
|
||||
expect(text).toContain('26 234')
|
||||
expect(text).not.toContain(String.fromCharCode(0x12))
|
||||
expect(text).not.toContain('−')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import { pdfNumberText } from '@/lib/pdf/number-text'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -209,10 +210,14 @@ const styles = StyleSheet.create({
|
||||
})
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
|
||||
// so a negative årets resultat would print as a profit (issue #1982).
|
||||
return pdfNumberText(
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount),
|
||||
)
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import { pdfNumberText } from '@/lib/pdf/number-text'
|
||||
import type { KassaflodesanalysReport } from './kassaflodesanalys'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
@@ -162,10 +163,14 @@ const styles = StyleSheet.create({
|
||||
})
|
||||
|
||||
function formatAmount(n: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n)
|
||||
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
|
||||
// so a negative cash flow would print as positive (issue #1982).
|
||||
return pdfNumberText(
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n),
|
||||
)
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import { pdfNumberText } from '@/lib/pdf/number-text'
|
||||
import type {
|
||||
CompanySettings,
|
||||
LatestVoucherPerSeries,
|
||||
@@ -226,15 +227,14 @@ const styles = StyleSheet.create({
|
||||
})
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
// Intl emits U+2212 (true minus). The bundled @react-pdf/renderer
|
||||
// Helvetica/Courier fonts lack that glyph and silently drop it, so
|
||||
// negatives would render as positives. Map to ASCII hyphen.
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
.format(amount)
|
||||
.replace(/\u2212/g, '-')
|
||||
// pdfNumberText: Intl emits U+2212 (true minus), which the bundled
|
||||
// Helvetica/Courier fonts lack, so negatives would render as positives.
|
||||
return pdfNumberText(
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount),
|
||||
)
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import { pdfNumberText } from '@/lib/pdf/number-text'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -201,10 +202,14 @@ const styles = StyleSheet.create({
|
||||
})
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
|
||||
// so a credit note or overpayment would print unsigned (issue #1982).
|
||||
return pdfNumberText(
|
||||
new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount),
|
||||
)
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import { pdfNumberText } from '@/lib/pdf/number-text'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import type { ManualFilingRow } from '@/lib/reports/vat-manual-filing'
|
||||
|
||||
@@ -94,7 +95,9 @@ const styles = StyleSheet.create({
|
||||
// already truncated to whole kronor (öretal faller bort, see
|
||||
// buildManualFilingRows), so format with no decimals.
|
||||
function formatKr(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount)
|
||||
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica/Courier,
|
||||
// so moms att få tillbaka would print as moms att betala (issue #1982).
|
||||
return pdfNumberText(new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 0 }).format(amount))
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PayslipPDF, type PayslipData } from '../payslip-template'
|
||||
import { pdfTextStrings } from '@/tests/pdf-text'
|
||||
|
||||
const UNICODE_MINUS = String.fromCharCode(0x2212)
|
||||
|
||||
function payslip(over: Partial<PayslipData> = {}): PayslipData {
|
||||
return {
|
||||
companyName: 'Testbolaget AB',
|
||||
companyOrgNumber: '556677-8899',
|
||||
employeeName: 'Anna Andersson',
|
||||
personnummerMasked: '19850101-XXXX',
|
||||
employmentType: 'tillsvidare',
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
paymentDate: '2026-08-25',
|
||||
lineItems: [
|
||||
{ description: 'Månadslön', quantity: 1, unitPrice: 35000, amount: 35000 },
|
||||
{ description: 'Sjukfrånvaro', quantity: 1, unitPrice: -1234.56, amount: -1234.56 },
|
||||
],
|
||||
grossSalary: 33765.44,
|
||||
taxWithheld: 7654.32,
|
||||
netSalary: 26111.12,
|
||||
taxReference: 'Tabell 33, kolumn 1',
|
||||
avgifterRate: 0.3142,
|
||||
avgifterAmount: 10609.1,
|
||||
vacationAccrual: 4051.85,
|
||||
vacationAccrualAvgifter: 1273.09,
|
||||
totalEmployerCost: 49699.48,
|
||||
ytdGross: 270123.52,
|
||||
ytdTax: 61234.56,
|
||||
ytdNet: 208888.96,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
// Real @react-pdf/renderer layout is CPU-heavy; under a fully parallel test
|
||||
// run these can exceed the 5s default on a saturated machine.
|
||||
const RENDER_TIMEOUT = 30_000
|
||||
|
||||
describe('PayslipPDF sign rendering (issue #1982)', () => {
|
||||
it('prints the tax deduction and a negative line with an ASCII minus the bundled font can draw', async () => {
|
||||
const text = pdfTextStrings(await renderToBuffer(PayslipPDF({ data: payslip() }))).join('\n')
|
||||
|
||||
// The Preliminär skatt row used to prefix a literal U+2212 outside fmt(),
|
||||
// which Helvetica has no glyph for: the content stream carried an unmapped
|
||||
// 0x12 byte and the viewer drew "7 654,32" with no sign.
|
||||
expect(text).toContain('-7 654,32')
|
||||
// A negative line item (absence, deduction) goes through fmt().
|
||||
expect(text).toContain('-1 234,56')
|
||||
expect(text).not.toContain(String.fromCharCode(0x12))
|
||||
expect(text).not.toContain(UNICODE_MINUS)
|
||||
}, RENDER_TIMEOUT)
|
||||
|
||||
it('prints the calculation-engine formula strings with an ASCII minus', async () => {
|
||||
// The engine builds these with U+2212 (they are shown on screen too, where
|
||||
// the glyph exists); the PDF must map them at the render site.
|
||||
const text = pdfTextStrings(
|
||||
await renderToBuffer(
|
||||
PayslipPDF({
|
||||
data: payslip({
|
||||
breakdownSteps: [
|
||||
{
|
||||
label: 'Bruttolön',
|
||||
formula: `grundlön + tillägg + frånvaro ${UNICODE_MINUS} bruttoavdrag`,
|
||||
output: 33765.44,
|
||||
},
|
||||
{
|
||||
label: 'Nettolön',
|
||||
formula: `bruttolön ${UNICODE_MINUS} skatt ${UNICODE_MINUS} nettoavdrag`,
|
||||
output: 26111.12,
|
||||
},
|
||||
{
|
||||
label: 'Sjukavdrag',
|
||||
formula: `${UNICODE_MINUS}(full lön ${UNICODE_MINUS} sjuklön + karensavdrag)`,
|
||||
output: -1234.56,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).join('\n')
|
||||
|
||||
expect(text).toContain('grundlön + tillägg + frånvaro - bruttoavdrag')
|
||||
expect(text).toContain('bruttolön - skatt - nettoavdrag')
|
||||
expect(text).toContain('-(full lön - sjuklön + karensavdrag)')
|
||||
expect(text).not.toContain(String.fromCharCode(0x12))
|
||||
expect(text).not.toContain(UNICODE_MINUS)
|
||||
}, RENDER_TIMEOUT)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
|
||||
import { pdfNumberText, pdfText } from '@/lib/pdf/number-text'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
|
||||
/**
|
||||
@@ -247,7 +248,14 @@ export interface PayslipLineItem {
|
||||
}
|
||||
|
||||
function fmt(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount)
|
||||
// pdfNumberText: Intl's U+2212 has no glyph in the bundled Helvetica, so a
|
||||
// negative line (deduction, absence) would print as an addition (issue
|
||||
// #1982). The two other U+2212 sources on this page are handled at their
|
||||
// render sites: the Preliminär skatt prefix (an ASCII hyphen) and the
|
||||
// breakdown formula strings from the calculation engine (pdfText).
|
||||
return pdfNumberText(
|
||||
new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount),
|
||||
)
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
@@ -327,7 +335,7 @@ export function PayslipPDF({ data }: { data: PayslipData }) {
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Preliminär skatt ({data.taxReference})</Text>
|
||||
<Text style={styles.summaryValue}>−{fmt(data.taxWithheld)}</Text>
|
||||
<Text style={styles.summaryValue}>-{fmt(data.taxWithheld)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Nettolön (utbetalas)</Text>
|
||||
@@ -380,7 +388,7 @@ export function PayslipPDF({ data }: { data: PayslipData }) {
|
||||
{data.breakdownSteps.map((step, i) => (
|
||||
<View key={i} style={styles.breakdownRow}>
|
||||
<Text style={styles.breakdownLabel}>{step.label}</Text>
|
||||
<Text style={styles.breakdownFormula}>{step.formula}</Text>
|
||||
<Text style={styles.breakdownFormula}>{pdfText(step.formula)}</Text>
|
||||
<Text style={styles.breakdownValue}>{fmt(step.output)}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { inflateSync } from 'node:zlib'
|
||||
|
||||
/**
|
||||
* Text a rendered @react-pdf/renderer document actually carries, as one
|
||||
* string per page content stream (test helper).
|
||||
*
|
||||
* Standard-font text is written as WinAnsi bytes inside `[...] TJ` / `(...) Tj`
|
||||
* operators. Inflating the streams and decoding those operands byte-for-byte
|
||||
* shows what a viewer will draw, which is the only way to prove a glyph made
|
||||
* it into the file: a character with no glyph in the font (U+2212 in
|
||||
* Helvetica, issue #1982) survives as an unmapped control byte and is drawn
|
||||
* as nothing. Thousands separators come back as U+00A0 and are normalised to
|
||||
* a plain space so assertions can use "4 684".
|
||||
*/
|
||||
export function pdfTextStrings(pdf: Buffer): string[] {
|
||||
const raw = pdf.toString('latin1')
|
||||
const out: string[] = []
|
||||
const streamRe = /stream\r?\n/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = streamRe.exec(raw)) !== null) {
|
||||
const start = match.index + match[0].length
|
||||
const end = raw.indexOf('endstream', start)
|
||||
if (end === -1) continue
|
||||
const bytes = Buffer.from(raw.slice(start, end), 'latin1')
|
||||
let content: string
|
||||
try {
|
||||
content = inflateSync(bytes).toString('latin1')
|
||||
} catch {
|
||||
content = bytes.toString('latin1')
|
||||
}
|
||||
// Only page content streams carry text objects; embedded font programs
|
||||
// and images are binary and would produce false hex/paren matches.
|
||||
if (!/\bBT\b[\s\S]*\bET\b/.test(content)) continue
|
||||
const parts: string[] = []
|
||||
const opRe = /<([0-9a-fA-F]+)>|\(((?:\\.|[^\\)])*)\)/g
|
||||
let op: RegExpExecArray | null
|
||||
while ((op = opRe.exec(content)) !== null) {
|
||||
if (op[1] !== undefined) {
|
||||
parts.push(Buffer.from(op[1], 'hex').toString('latin1'))
|
||||
} else {
|
||||
parts.push(op[2].replace(/\\([()\\])/g, '$1'))
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) out.push(parts.join('').replaceAll(' ', ' '))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user