Files
accounted/lib/reports/financial-statement-pdf-template.tsx
T
Jakob WennbergandClaude Opus 4.7 4cd0a55761 Copy voucher, MRU booking templates, and PDF export for reports (#303)
* 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>
2026-04-21 22:28:41 +02:00

383 lines
9.6 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 { CompanySettings } from '@/types'
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingHorizontal: 40,
// Leave room for the fixed disclaimer + footer at the bottom of every page.
paddingBottom: 120,
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: 20,
fontWeight: 'bold',
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',
},
group: {
marginBottom: 18,
},
groupHeading: {
fontSize: 12,
fontWeight: 'bold',
color: '#1a1a1a',
marginBottom: 8,
paddingBottom: 4,
borderBottomWidth: 1,
borderBottomColor: '#1a1a1a',
},
section: {
marginBottom: 8,
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
color: '#444',
marginBottom: 4,
marginTop: 6,
},
row: {
flexDirection: 'row',
paddingVertical: 2,
},
colAccount: {
width: 48,
color: '#666',
fontFamily: 'Courier',
},
colName: {
flex: 1,
color: '#1a1a1a',
paddingRight: 12,
},
colAmount: {
width: 110,
textAlign: 'right',
fontFamily: 'Courier',
color: '#1a1a1a',
},
sectionSubtotalRow: {
flexDirection: 'row',
paddingVertical: 3,
marginTop: 2,
borderTopWidth: 0.5,
borderTopColor: '#d4d4d4',
},
sectionSubtotalLabel: {
flex: 1,
fontStyle: 'italic',
color: '#444',
paddingLeft: 48,
},
sectionSubtotalAmount: {
width: 110,
textAlign: 'right',
fontFamily: 'Courier',
fontStyle: 'italic',
color: '#444',
},
groupTotalRow: {
flexDirection: 'row',
paddingVertical: 6,
marginTop: 6,
borderTopWidth: 1,
borderTopColor: '#1a1a1a',
},
groupTotalLabel: {
flex: 1,
fontWeight: 'bold',
fontSize: 11,
},
groupTotalAmount: {
width: 110,
textAlign: 'right',
fontFamily: 'Courier',
fontWeight: 'bold',
fontSize: 11,
},
summaryBlock: {
marginTop: 20,
paddingTop: 10,
borderTopWidth: 2,
borderTopColor: '#1a1a1a',
},
summaryRow: {
flexDirection: 'row',
paddingVertical: 4,
},
summaryLabel: {
flex: 1,
color: '#1a1a1a',
},
summaryAmount: {
width: 110,
textAlign: 'right',
fontFamily: 'Courier',
},
summaryEmphasisLabel: {
flex: 1,
fontWeight: 'bold',
fontSize: 12,
},
summaryEmphasisAmount: {
width: 110,
textAlign: 'right',
fontFamily: 'Courier',
fontWeight: 'bold',
fontSize: 12,
},
disclaimer: {
position: 'absolute',
bottom: 52,
left: 40,
right: 40,
paddingTop: 6,
paddingBottom: 6,
paddingHorizontal: 10,
borderWidth: 0.8,
borderColor: '#b45309',
backgroundColor: '#fef3c7',
borderRadius: 3,
},
disclaimerTitle: {
fontSize: 8,
fontWeight: 'bold',
color: '#78350f',
marginBottom: 2,
},
disclaimerText: {
fontSize: 7.5,
color: '#78350f',
lineHeight: 1.3,
},
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(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
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')
}
export interface FinancialStatementSection {
title: string
rows: { account_number: string; account_name: string; amount: number }[]
subtotal: number
}
export interface FinancialStatementGroup {
heading: string
sections: FinancialStatementSection[]
totalLabel: string
total: number
negate?: boolean
}
export interface FinancialStatementSummaryRow {
label: string
amount: number
emphasis?: boolean
}
interface FinancialStatementPDFProps {
title: string
groups: FinancialStatementGroup[]
summary?: FinancialStatementSummaryRow[]
period: { start: string; end: string }
company: CompanySettings
generatedAt: string
}
export function FinancialStatementPDF({
title,
groups,
summary,
period,
company,
generatedAt,
}: FinancialStatementPDFProps) {
const companyDisplayName = company.trade_name || company.company_name || ''
const periodLabel = period.start && period.end
? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}`
: ''
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
<Text style={styles.title}>{title}</Text>
{companyDisplayName && (
<Text style={styles.subtitle}>{companyDisplayName}</Text>
)}
{periodLabel && (
<Text style={styles.period}>Period: {periodLabel}</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>
{groups.map((group, gi) => (
<View key={gi} style={styles.group} wrap>
<Text style={styles.groupHeading}>{group.heading}</Text>
{group.sections.length === 0 ? (
<Text style={{ fontSize: 9, color: '#888', fontStyle: 'italic' }}>
Inga poster i perioden.
</Text>
) : (
group.sections.map((section, si) => (
<View key={si} style={styles.section} wrap={false}>
<Text style={styles.sectionTitle}>{section.title}</Text>
{section.rows.map((row, ri) => {
const displayAmount = group.negate ? -row.amount : row.amount
return (
<View key={ri} style={styles.row}>
<Text style={styles.colAccount}>{row.account_number}</Text>
<Text style={styles.colName}>{row.account_name}</Text>
<Text style={styles.colAmount}>{formatAmount(displayAmount)}</Text>
</View>
)
})}
{section.rows.length > 1 && (
<View style={styles.sectionSubtotalRow}>
<Text style={styles.sectionSubtotalLabel}>Summa {section.title.toLowerCase()}</Text>
<Text style={styles.sectionSubtotalAmount}>
{formatAmount(group.negate ? -section.subtotal : section.subtotal)}
</Text>
</View>
)}
</View>
))
)}
<View style={styles.groupTotalRow}>
<Text style={styles.groupTotalLabel}>{group.totalLabel}</Text>
<Text style={styles.groupTotalAmount}>
{formatAmount(group.negate ? -group.total : group.total)}
</Text>
</View>
</View>
))}
{summary && summary.length > 0 && (
<View style={styles.summaryBlock} wrap={false}>
{summary.map((row, i) => (
<View key={i} style={styles.summaryRow}>
<Text style={row.emphasis ? styles.summaryEmphasisLabel : styles.summaryLabel}>
{row.label}
</Text>
<Text style={row.emphasis ? styles.summaryEmphasisAmount : styles.summaryAmount}>
{formatAmount(row.amount)}
</Text>
</View>
))}
</View>
)}
<View style={styles.disclaimer} fixed>
<Text style={styles.disclaimerTitle}>Arbetsutkast – ej undertecknat</Text>
<Text style={styles.disclaimerText}>
Detta dokument är ett internt arbetsutkast och utgör inte en godkänd
årsredovisning enligt ÅRL 2 kap 7 §. Den formella årsredovisningen ska
undertecknas av samtliga styrelseledamöter och, i förekommande fall, VD
innan den lämnas in till Bolagsverket.
</Text>
</View>
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{companyDisplayName}
{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>
)
}