Files
accounted/lib/reports/kassaflodesanalys-pdf-template.tsx
T
Jakob WennbergandClaude Opus 4.8 88f49c0ccc fix(bookkeeping): harden correction flow and align VAT/cashflow reports (#726)
Bundles a set of bookkeeping-correctness fixes developed together.

Correction / storno flow
- correctEntry resolves (and seeds standard BAS) accounts for the
  corrected lines BEFORE writing the storno. The old order created and
  posted the storno first, then hit AccountsNotInChartError on the
  corrected lines and had to cancel it again — leaving a voided 0 kr
  storno in the chain and permanently burning a voucher number (an
  unexplained BFNAR 2013:2 gap). It now fails fast with nothing written.
- correctEntry re-points the bank transaction and underlag from the
  reversed original to the live corrected entry, so the transaction keeps
  reading as booked (and stays correctable) and the underlag travels with
  it. recordateEntry delegates both relinks to correctEntry.
- reverseEntry (engine) clears transactions.journal_entry_id for rows
  booked by the reversed entry, so a plain storno returns the bank row to
  "Att bokföra" with a re-booking affordance. The agent paths did this
  manually; the dashboard reverse route did not.
- findUnresolvableAccounts replaces findMissingActiveAccounts in the
  categorize routes: a standard BAS account merely absent from the chart
  is seeded on demand by the engine, so pre-validation must not 400 on it
  — only unknown numbers or deactivated accounts block.
- CorrectionChain dims cancelled (0 kr) entries and labels them so they
  no longer render like a live storno.

Report accuracy
- calculateVatLiability() (lib/reports/kpi.ts) is shared by the KPI route,
  the KPI xlsx export and the MCP period-summary tool, and uses the same
  26xx accounts as the momsdeklaration (ruta 49). Reverse-charge and
  import pairs (e.g. 2614 credit + 2645 debit) net to zero instead of
  inflating the receivable (#715). VAT_OUTPUT_ACCOUNTS / VAT_INPUT_ACCOUNTS
  are derived from ACCOUNT_RUTA so the widget can never drift from the
  declaration.
- Kassaflödesanalys records erhållna aktieägartillskott (2093) as a
  financing inflow and counts överkursfond (2086/2097) toward nyemission.
  2093 was previously unmapped, so any contribution broke the 19xx
  reconciliation by exactly the contributed amount (#716). Wired through
  the report type, both PDF templates, the K3 PDF, the dashboard client
  and the årsredovisning summary type.

Agent guidance
- shared-rules: describe the real Accounted correction flow (Rätta rader /
  Rätta datum / Radera verifikat, on-demand BAS backfill) so the assistant
  stops inventing flows that don't exist.
- verifikation-draft: clearer locked-period guidance.

Tests cover all of the above (storno fail-fast + seeding + relink,
reverseEntry unlink, findUnresolvableAccounts, VAT netting and the
cashflow reconciliation cases).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:17:44 +02:00

400 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Document,
Page,
Text,
View,
StyleSheet,
} from '@react-pdf/renderer'
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 {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n)
}
function formatOrgNumber(orgNumber: string): string {
const cleaned = orgNumber.replace(/\D/g, '')
if (cleaned.length === 10) {
return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
}
return orgNumber
}
function formatDateSv(iso: string): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('sv-SE')
}
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>
)
}