* feat: copy voucher, MRU booking templates, and PDF export for reports - Add "Kopiera verifikat" action on the journal-entry detail page that prefills a new draft with the source entry's lines, description, and notes. Date defaults to today so locked-period posts can't happen by accident; source_type resets to manual. - Track per-company MRU for booking_template_library rows via a new booking_template_usage table (fire-and-forget touch endpoint hooked into both pickers) and sort the list most-recently-used first for the active company. - Generate downloadable PDFs for balansräkning and resultaträkning using the existing @react-pdf/renderer toolchain. Adds a reusable parameterized template and two API routes, with download buttons on the matching report views. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review feedback on copy-voucher + report PDFs Compliance review (Swedish accounting): - Balance-sheet PDF now refuses to render when tillgångar ≠ eget kapital och skulder; the stale "Differens" summary row is gone. The on-screen view still surfaces the existing "Balanserar ej" warning so users can diagnose the imbalance before downloading. ÅRL 3 kap / K2 / K3 require exact balance. - Both PDF routes now 400 when the requested fiscal period cannot be resolved — identifiable period is part of räkenskapsinformation under BFL 7 kap. - Income-statement PDF adds the mandatory "Resultat efter finansiella poster" subtotal when financial items are present, per K2/K3 uppställningsform (ÅRL bilaga 2). - Copy-voucher flow now shows a clear banner ("Kopia av verifikat X — nytt, fristående verifikat skapas") so users cannot mistake the copy for a rättelse/storno. Code review (Greptile): - New migration adds updated_at column + trigger to booking_template_usage (project convention; applied to the Supabase project). - Replace localeCompare on ISO timestamps with plain relational comparison to avoid any locale-dependent ordering. - UUID-format validation on the copy_from query param before it goes into the fetch URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: second round of Swedish compliance fixes on report PDFs - Balance-sheet PDF imbalance check now compares rounded-to-whole-kronor totals (SFL 22:1 convention). The previous 0.5-öre tolerance could reject a legitimate balance sheet when accumulated floating-point noise across hundreds of ledger lines exceeded the threshold. The on-screen view still surfaces the öre-precise "Balanserar ej" badge for diagnostic visibility. - Both PDFs now carry a prominent "Arbetsutkast — ej undertecknat" notice per ÅRL 2 kap 7 §. Prevents a downloaded PDF from being mistaken for or filed as an approved årsredovisning. - Income-statement PDF now follows K2/K3 uppställningsform (ÅRL bilaga 2) by splitting class 8 into three blocks with named subtotals: Finansiella poster (80–84), Bokslutsdispositioner (88), Skatter (89). The summary now always shows a "Skatt på årets resultat" row so the reader can verify the tax calculation, and adds "Resultat efter finansiella poster" / "Bokslutsdispositioner" subtotals when each block is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: harden report PDFs against out-of-band filing + future BAS growth - Append "-utkast" to downloaded PDF filenames. The filename survives the PDF's disclaimer context — a file named balansrakning-2026-01-01.pdf in a Downloads folder or forwarded attachment is ambiguous, whereas balansrakning-2026-01-01-utkast.pdf makes the draft status legible even without opening the document. - Add a catch-all "Övriga finansiella poster" bucket in the income-statement PDF for any class-8 section whose account prefix isn't one of the known K2/K3 blocks (80–84 / 88 / 89). Counted in the "Resultat efter finansiella poster" subtotal so arithmetic stays consistent. Future-proofs the PDF against a generator change that starts emitting 85–87 sections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
4.1 KiB
TypeScript
141 lines
4.1 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { renderToBuffer } from '@react-pdf/renderer'
|
|
import { FinancialStatementPDF } from '../financial-statement-pdf-template'
|
|
import type { CompanySettings } from '@/types'
|
|
|
|
function fakeCompany(): CompanySettings {
|
|
return {
|
|
company_name: 'Gnubok AB',
|
|
trade_name: 'Gnubok',
|
|
org_number: '5566778899',
|
|
vat_number: 'SE556677889901',
|
|
address_line1: 'Kungsgatan 1',
|
|
postal_code: '11143',
|
|
city: 'Stockholm',
|
|
country: 'SE',
|
|
entity_type: 'aktiebolag',
|
|
} as unknown as CompanySettings
|
|
}
|
|
|
|
describe('FinancialStatementPDF', () => {
|
|
it('renders a balance-sheet-shaped document to a PDF buffer', async () => {
|
|
const doc = FinancialStatementPDF({
|
|
title: 'Balansräkning',
|
|
groups: [
|
|
{
|
|
heading: 'Tillgångar',
|
|
sections: [
|
|
{
|
|
title: 'Kassa och bank',
|
|
rows: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', amount: 125_432.5 },
|
|
],
|
|
subtotal: 125_432.5,
|
|
},
|
|
],
|
|
totalLabel: 'Summa tillgångar',
|
|
total: 125_432.5,
|
|
},
|
|
{
|
|
heading: 'Eget kapital och skulder',
|
|
sections: [
|
|
{
|
|
title: 'Eget kapital',
|
|
rows: [
|
|
{ account_number: '2010', account_name: 'Eget kapital', amount: 100_000 },
|
|
{ account_number: '2091', account_name: 'Balanserat resultat', amount: 25_432.5 },
|
|
],
|
|
subtotal: 125_432.5,
|
|
},
|
|
],
|
|
totalLabel: 'Summa eget kapital och skulder',
|
|
total: 125_432.5,
|
|
},
|
|
],
|
|
period: { start: '2026-01-01', end: '2026-12-31' },
|
|
company: fakeCompany(),
|
|
generatedAt: '2026-04-21T10:00:00Z',
|
|
})
|
|
|
|
const buffer = await renderToBuffer(doc)
|
|
expect(buffer).toBeInstanceOf(Buffer)
|
|
expect(buffer.length).toBeGreaterThan(1000)
|
|
// PDF files always start with "%PDF-"
|
|
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
|
})
|
|
|
|
it('renders an income-statement-shaped document with a summary block', async () => {
|
|
const doc = FinancialStatementPDF({
|
|
title: 'Resultaträkning',
|
|
groups: [
|
|
{
|
|
heading: 'Rörelseintäkter',
|
|
sections: [
|
|
{
|
|
title: 'Huvudintäkter',
|
|
rows: [
|
|
{ account_number: '3001', account_name: 'Försäljning 25%', amount: 500_000 },
|
|
],
|
|
subtotal: 500_000,
|
|
},
|
|
],
|
|
totalLabel: 'Summa rörelseintäkter',
|
|
total: 500_000,
|
|
},
|
|
{
|
|
heading: 'Rörelsekostnader',
|
|
sections: [
|
|
{
|
|
title: 'Lokalkostnader',
|
|
rows: [
|
|
{ account_number: '5010', account_name: 'Lokalhyra', amount: 120_000 },
|
|
],
|
|
subtotal: 120_000,
|
|
},
|
|
],
|
|
totalLabel: 'Summa rörelsekostnader',
|
|
total: 120_000,
|
|
negate: true,
|
|
},
|
|
],
|
|
summary: [
|
|
{ label: 'Rörelseresultat', amount: 380_000 },
|
|
{ label: 'Årets resultat', amount: 380_000, emphasis: true },
|
|
],
|
|
period: { start: '2026-01-01', end: '2026-12-31' },
|
|
company: fakeCompany(),
|
|
generatedAt: '2026-04-21T10:00:00Z',
|
|
})
|
|
|
|
const buffer = await renderToBuffer(doc)
|
|
expect(buffer).toBeInstanceOf(Buffer)
|
|
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
|
})
|
|
|
|
it('handles empty section groups gracefully', async () => {
|
|
const doc = FinancialStatementPDF({
|
|
title: 'Balansräkning',
|
|
groups: [
|
|
{
|
|
heading: 'Tillgångar',
|
|
sections: [],
|
|
totalLabel: 'Summa tillgångar',
|
|
total: 0,
|
|
},
|
|
{
|
|
heading: 'Eget kapital och skulder',
|
|
sections: [],
|
|
totalLabel: 'Summa eget kapital och skulder',
|
|
total: 0,
|
|
},
|
|
],
|
|
period: { start: '', end: '' },
|
|
company: fakeCompany(),
|
|
generatedAt: '2026-04-21T10:00:00Z',
|
|
})
|
|
|
|
const buffer = await renderToBuffer(doc)
|
|
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
|
})
|
|
})
|