Files
accounted/lib/reports/kassaflodesanalys-pdf-template.tsx
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

393 lines
13 KiB
TypeScript

import { formatOrgNumber } from '@/lib/utils'
import {
Document,
Page,
Text,
View,
StyleSheet,
} from '@react-pdf/renderer'
import { pdfNumberText, formatDateSv } from '@/lib/pdf/number-text'
import type { KassaflodesanalysReport } from './kassaflodesanalys'
import type { CompanySettings } from '@/types'
// Single-file PDF template: the kassaflödesanalys layout is distinct enough
// from the BR/RR template (no per-account rows, three labelled sections, a
// reconciliation footer) that a dedicated template keeps things readable.
// Typography mirrors CLAUDE.md design system: Times-Roman as the serif
// (closest stock @react-pdf font to Hedvig Letters Serif, which @react-pdf
// can't ship by default), Helvetica for body, Courier for tabular numbers
// so columns align without us shipping a custom font.
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingHorizontal: 40,
paddingBottom: 110,
fontSize: 10,
fontFamily: 'Helvetica',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 24,
paddingBottom: 14,
borderBottomWidth: 1,
borderBottomColor: '#d4d4d4',
},
titleBlock: { flex: 1 },
title: {
fontSize: 22,
fontFamily: 'Times-Roman',
color: '#1a1a1a',
marginBottom: 4,
},
subtitle: { fontSize: 11, color: '#333', marginBottom: 2 },
period: { fontSize: 10, color: '#666' },
companyInfo: { textAlign: 'right' },
companyName: { fontSize: 11, fontWeight: 'bold', marginBottom: 2 },
companyMeta: { fontSize: 9, color: '#666' },
section: { marginBottom: 18 },
sectionHeading: {
fontSize: 12,
fontFamily: 'Times-Roman',
color: '#1a1a1a',
marginBottom: 8,
paddingBottom: 4,
borderBottomWidth: 1,
borderBottomColor: '#1a1a1a',
},
row: {
flexDirection: 'row',
paddingVertical: 3,
},
label: {
flex: 1,
color: '#1a1a1a',
},
amount: {
width: 130,
textAlign: 'right',
fontFamily: 'Courier',
color: '#1a1a1a',
},
subtotalRow: {
flexDirection: 'row',
paddingVertical: 5,
marginTop: 4,
borderTopWidth: 1,
borderTopColor: '#1a1a1a',
},
subtotalLabel: {
flex: 1,
fontWeight: 'bold',
fontSize: 11,
},
subtotalAmount: {
width: 130,
textAlign: 'right',
fontFamily: 'Courier',
fontWeight: 'bold',
fontSize: 11,
},
totalBlock: {
marginTop: 20,
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: '#1a1a1a',
},
totalLabel: {
flex: 1,
fontWeight: 'bold',
fontFamily: 'Times-Roman',
fontSize: 13,
},
totalAmount: {
width: 130,
textAlign: 'right',
fontFamily: 'Courier',
fontWeight: 'bold',
fontSize: 13,
},
reconciliationBlock: {
marginTop: 24,
padding: 12,
borderWidth: 1,
borderColor: '#d4d4d4',
borderRadius: 4,
backgroundColor: '#fafafa',
},
reconciliationOk: {
borderColor: '#16a34a',
backgroundColor: '#f0fdf4',
},
reconciliationBad: {
// Terracotta destructive-color analogue for PDFs (since CSS vars
// aren't available). Surfaces a clear "mismatch" signal without
// depending on the chrome design tokens.
borderColor: '#b91c1c',
backgroundColor: '#fef2f2',
},
reconciliationTitle: {
fontSize: 11,
fontWeight: 'bold',
color: '#1a1a1a',
marginBottom: 6,
},
reconciliationRow: {
flexDirection: 'row',
paddingVertical: 2,
},
reconciliationLabel: { flex: 1, color: '#444' },
reconciliationAmount: {
width: 130,
textAlign: 'right',
fontFamily: 'Courier',
color: '#1a1a1a',
},
reconciliationMismatch: {
color: '#b91c1c',
fontWeight: 'bold',
},
footer: {
position: 'absolute',
bottom: 24,
left: 40,
right: 40,
borderTopWidth: 0.5,
borderTopColor: '#d4d4d4',
paddingTop: 6,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: { fontSize: 8, color: '#888' },
})
function formatAmount(n: number): string {
// 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),
)
}
interface KassaflodePDFProps {
report: KassaflodesanalysReport
company: CompanySettings
generatedAt: string
}
export function KassaflodesanalysPDF({
report,
company,
generatedAt,
}: KassaflodePDFProps) {
const companyName = company.company_name || ''
const periodLabel = `${formatDateSv(report.period_start)}: ${formatDateSv(report.period_end)}`
const recon = report.reconciliation
const reconStyles = [
styles.reconciliationBlock,
recon.is_reconciled ? styles.reconciliationOk : styles.reconciliationBad,
]
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
<Text style={styles.title}>Kassaflödesanalys</Text>
{companyName && <Text style={styles.subtitle}>{companyName}</Text>}
<Text style={styles.period}>Period: {periodLabel}</Text>
<Text style={styles.period}>Indirekt metod enligt BFNAR 2012:1 kap 7</Text>
</View>
<View style={styles.companyInfo}>
{company.company_name && (
<Text style={styles.companyName}>{company.company_name}</Text>
)}
{company.org_number && (
<Text style={styles.companyMeta}>
Org.nr: {formatOrgNumber(company.org_number)}
</Text>
)}
{company.vat_number && (
<Text style={styles.companyMeta}>VAT: {company.vat_number}</Text>
)}
</View>
</View>
{/* Section 1: Löpande verksamhet */}
<View style={styles.section}>
<Text style={styles.sectionHeading}>Den löpande verksamheten</Text>
<View style={styles.row}>
<Text style={styles.label}>Resultat efter finansiella poster</Text>
<Text style={styles.amount}>
{formatAmount(report.lopande.resultat_efter_finansiella_poster)}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Justeringar för avskrivningar</Text>
<Text style={styles.amount}>{formatAmount(report.lopande.avskrivningar)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Övriga ej-kassaflödespåverkande poster</Text>
<Text style={styles.amount}>
{formatAmount(report.lopande.ovriga_ej_kassaflodesposter)}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Förändring av kortfristiga fordringar</Text>
<Text style={styles.amount}>
{formatAmount(report.lopande.delta_kortfristiga_fordringar)}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Förändring av varulager</Text>
<Text style={styles.amount}>{formatAmount(report.lopande.delta_varulager)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Förändring av kortfristiga skulder</Text>
<Text style={styles.amount}>
{formatAmount(report.lopande.delta_kortfristiga_skulder)}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Betald inkomstskatt</Text>
<Text style={styles.amount}>{formatAmount(report.lopande.skatt_betald)}</Text>
</View>
<View style={styles.subtotalRow}>
<Text style={styles.subtotalLabel}>
Kassaflöde från den löpande verksamheten
</Text>
<Text style={styles.subtotalAmount}>{formatAmount(report.lopande.total)}</Text>
</View>
</View>
{/* Section 2: Investeringsverksamhet */}
<View style={styles.section}>
<Text style={styles.sectionHeading}>Investeringsverksamheten</Text>
<View style={styles.row}>
<Text style={styles.label}>Förvärv av anläggningstillgångar</Text>
<Text style={styles.amount}>
{formatAmount(report.investerings.forvarv_anlaggningar)}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Avyttring av anläggningstillgångar</Text>
<Text style={styles.amount}>
{formatAmount(report.investerings.avyttring_anlaggningar)}
</Text>
</View>
<View style={styles.subtotalRow}>
<Text style={styles.subtotalLabel}>
Kassaflöde från investeringsverksamheten
</Text>
<Text style={styles.subtotalAmount}>
{formatAmount(report.investerings.total)}
</Text>
</View>
</View>
{/* Section 3: Finansieringsverksamhet */}
<View style={styles.section}>
<Text style={styles.sectionHeading}>Finansieringsverksamheten</Text>
<View style={styles.row}>
<Text style={styles.label}>Förändring av lån (långfristiga skulder)</Text>
<Text style={styles.amount}>{formatAmount(report.finansierings.delta_lan)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Utdelningar till ägare</Text>
<Text style={styles.amount}>
{formatAmount(report.finansierings.utdelningar)}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Nyemission</Text>
<Text style={styles.amount}>{formatAmount(report.finansierings.nyemission)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Erhållna aktieägartillskott</Text>
<Text style={styles.amount}>
{formatAmount(report.finansierings.erhallna_aktieagartillskott)}
</Text>
</View>
<View style={styles.subtotalRow}>
<Text style={styles.subtotalLabel}>
Kassaflöde från finansieringsverksamheten
</Text>
<Text style={styles.subtotalAmount}>
{formatAmount(report.finansierings.total)}
</Text>
</View>
</View>
{/* Total */}
<View style={[styles.totalBlock, styles.row]}>
<Text style={styles.totalLabel}>Årets kassaflöde</Text>
<Text style={styles.totalAmount}>{formatAmount(report.total_cash_flow)}</Text>
</View>
{/* Reconciliation */}
<View style={reconStyles}>
<Text style={styles.reconciliationTitle}>
Avstämning mot likvida medel (19xx)
</Text>
<View style={styles.reconciliationRow}>
<Text style={styles.reconciliationLabel}>Ingående saldo</Text>
<Text style={styles.reconciliationAmount}>
{formatAmount(recon.opening_cash_1xxx)}
</Text>
</View>
<View style={styles.reconciliationRow}>
<Text style={styles.reconciliationLabel}>Utgående saldo</Text>
<Text style={styles.reconciliationAmount}>
{formatAmount(recon.closing_cash_1xxx)}
</Text>
</View>
<View style={styles.reconciliationRow}>
<Text style={styles.reconciliationLabel}>Faktisk förändring</Text>
<Text style={styles.reconciliationAmount}>
{formatAmount(recon.delta_actual)}
</Text>
</View>
<View style={styles.reconciliationRow}>
<Text style={styles.reconciliationLabel}>Beräknad förändring</Text>
<Text style={styles.reconciliationAmount}>
{formatAmount(recon.delta_calculated)}
</Text>
</View>
{!recon.is_reconciled && (
<View style={styles.reconciliationRow}>
<Text
style={[styles.reconciliationLabel, styles.reconciliationMismatch]}
>
Avvikelse: kontrollera bokföringen
</Text>
<Text
style={[styles.reconciliationAmount, styles.reconciliationMismatch]}
>
{formatAmount(recon.mismatch_amount)}
</Text>
</View>
)}
</View>
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{companyName}
{company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''}
</Text>
<Text
style={styles.footerText}
render={({ pageNumber, totalPages }) =>
`Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`
}
/>
</View>
</Page>
</Document>
)
}