diff --git a/app/api/reports/balansrapport/pdf/route.ts b/app/api/reports/balansrapport/pdf/route.ts
new file mode 100644
index 00000000..c5a56c48
--- /dev/null
+++ b/app/api/reports/balansrapport/pdf/route.ts
@@ -0,0 +1,79 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { renderToBuffer } from '@react-pdf/renderer'
+import { generateBalansrapport } from '@/lib/reports/balansrapport'
+import { BalansrapportPDF } from '@/lib/reports/operational-report-pdf-template'
+import { requireCompanyId } from '@/lib/company/context'
+import type { CompanySettings } from '@/types'
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('*')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!companyRow) {
+ return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
+ }
+ if (!period) {
+ return NextResponse.json(
+ { error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' },
+ { status: 400 }
+ )
+ }
+
+ try {
+ const report = await generateBalansrapport(supabase, companyId, periodId)
+
+ const pdfBuffer = await renderToBuffer(
+ BalansrapportPDF({
+ report,
+ company: companyRow as CompanySettings,
+ generatedAt: new Date().toISOString(),
+ })
+ )
+
+ // Anchor on period.start to match the convention used by every other
+ // report PDF route in this repo (resultatrapport, balance-sheet,
+ // income-statement). A balansrapport is a snapshot at period end, but
+ // consistent filenames let users sort and script-rename predictably.
+ const filename = `balansrapport-${report.period.start}.pdf`
+
+ return new Response(new Uint8Array(pdfBuffer), {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera balansrapport' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/resultatrapport/pdf/route.ts b/app/api/reports/resultatrapport/pdf/route.ts
new file mode 100644
index 00000000..d5de0857
--- /dev/null
+++ b/app/api/reports/resultatrapport/pdf/route.ts
@@ -0,0 +1,77 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { renderToBuffer } from '@react-pdf/renderer'
+import { generateResultatrapport } from '@/lib/reports/resultatrapport'
+import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-template'
+import { requireCompanyId } from '@/lib/company/context'
+import type { CompanySettings } from '@/types'
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('*')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!companyRow) {
+ return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
+ }
+ // An identifiable period is part of räkenskapsinformation (BFL 7 kap). Refuse
+ // to render a PDF that can't be archived with the period it refers to.
+ if (!period) {
+ return NextResponse.json(
+ { error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' },
+ { status: 400 }
+ )
+ }
+
+ try {
+ const report = await generateResultatrapport(supabase, companyId, periodId)
+
+ const pdfBuffer = await renderToBuffer(
+ ResultatrapportPDF({
+ report,
+ company: companyRow as CompanySettings,
+ generatedAt: new Date().toISOString(),
+ })
+ )
+
+ const filename = `resultatrapport-${report.period.start}.pdf`
+
+ return new Response(new Uint8Array(pdfBuffer), {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera resultatrapport' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/lib/reports/operational-report-pdf-template.tsx b/lib/reports/operational-report-pdf-template.tsx
new file mode 100644
index 00000000..0de6090b
--- /dev/null
+++ b/lib/reports/operational-report-pdf-template.tsx
@@ -0,0 +1,453 @@
+import {
+ Document,
+ Page,
+ Text,
+ View,
+ StyleSheet,
+} from '@react-pdf/renderer'
+import type {
+ CompanySettings,
+ ResultatrapportReport,
+ BalansrapportReport,
+} from '@/types'
+
+// Operational reports (Resultatrapport / Balansrapport) are löpande
+// bookkeeping documents, not draft årsredovisning per ÅRL 2:7 §, so this
+// template intentionally omits the yellow "Arbetsutkast" disclaimer that
+// FinancialStatementPDF carries.
+const styles = StyleSheet.create({
+ page: {
+ paddingTop: 40,
+ paddingHorizontal: 40,
+ paddingBottom: 60,
+ 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',
+ },
+ tableHeader: {
+ flexDirection: 'row',
+ paddingVertical: 4,
+ borderBottomWidth: 0.5,
+ borderBottomColor: '#1a1a1a',
+ marginBottom: 2,
+ },
+ tableHeaderText: {
+ fontSize: 9,
+ fontWeight: 'bold',
+ color: '#444',
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ groupHeading: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ color: '#1a1a1a',
+ marginTop: 12,
+ marginBottom: 4,
+ paddingBottom: 3,
+ borderBottomWidth: 0.5,
+ borderBottomColor: '#888',
+ },
+ row: {
+ flexDirection: 'row',
+ paddingVertical: 2,
+ },
+ subtotalRow: {
+ flexDirection: 'row',
+ paddingVertical: 4,
+ marginTop: 2,
+ borderTopWidth: 0.5,
+ borderTopColor: '#888',
+ marginBottom: 4,
+ },
+ colAccount: {
+ width: 48,
+ color: '#666',
+ fontFamily: 'Courier',
+ },
+ colName: {
+ flex: 1,
+ color: '#1a1a1a',
+ paddingRight: 12,
+ },
+ colAmount: {
+ width: 90,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ color: '#1a1a1a',
+ },
+ colAmountMuted: {
+ width: 90,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ color: '#666',
+ },
+ subtotalLabel: {
+ flex: 1,
+ fontStyle: 'italic',
+ color: '#444',
+ paddingLeft: 48,
+ },
+ subtotalAmount: {
+ width: 90,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ fontStyle: 'italic',
+ color: '#444',
+ },
+ summary: {
+ marginTop: 18,
+ paddingTop: 10,
+ borderTopWidth: 1,
+ borderTopColor: '#1a1a1a',
+ },
+ summaryRow: {
+ flexDirection: 'row',
+ paddingVertical: 3,
+ },
+ summaryLabel: {
+ flex: 1,
+ color: '#1a1a1a',
+ },
+ summaryEmphasis: {
+ flex: 1,
+ fontWeight: 'bold',
+ fontSize: 11,
+ color: '#1a1a1a',
+ },
+ summaryAmount: {
+ width: 90,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ color: '#1a1a1a',
+ },
+ summaryAmountMuted: {
+ width: 90,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ color: '#666',
+ },
+ summaryAmountEmphasis: {
+ width: 90,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ fontWeight: 'bold',
+ fontSize: 11,
+ },
+ balanceVerdict: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 6,
+ paddingHorizontal: 10,
+ marginTop: 8,
+ borderRadius: 3,
+ },
+ balanceVerdictOk: {
+ backgroundColor: '#ecfdf5',
+ borderWidth: 0.5,
+ borderColor: '#059669',
+ },
+ balanceVerdictBad: {
+ backgroundColor: '#fef2f2',
+ borderWidth: 0.5,
+ borderColor: '#dc2626',
+ },
+ balanceVerdictLabel: {
+ fontWeight: 'bold',
+ fontSize: 11,
+ color: '#1a1a1a',
+ },
+ balanceVerdictOkBadge: {
+ fontWeight: 'bold',
+ fontSize: 10,
+ color: '#065f46',
+ },
+ balanceVerdictBadBadge: {
+ fontWeight: 'bold',
+ fontSize: 10,
+ color: '#991b1b',
+ },
+ 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')
+}
+
+interface CommonHeaderProps {
+ title: string
+ company: CompanySettings
+ period: { start: string; end: string }
+}
+
+function HeaderBlock({ title, company, period }: CommonHeaderProps) {
+ const companyDisplayName = company.trade_name || company.company_name || ''
+ const periodLabel = period.start && period.end
+ ? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}`
+ : ''
+ return (
+
+
+ {title}
+ {companyDisplayName && (
+ {companyDisplayName}
+ )}
+ {periodLabel && (
+ Period: {periodLabel}
+ )}
+
+
+ {company.company_name && (
+ {company.company_name}
+ )}
+ {company.org_number && (
+
+ Org.nr: {formatOrgNumber(company.org_number)}
+
+ )}
+ {company.vat_number && (
+ VAT: {company.vat_number}
+ )}
+
+
+ )
+}
+
+function FooterBlock({ company, generatedAt }: { company: CompanySettings; generatedAt: string }) {
+ const companyDisplayName = company.trade_name || company.company_name || ''
+ return (
+
+
+ {companyDisplayName}
+ {company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''}
+
+ `Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`}
+ />
+
+ )
+}
+
+interface ResultatrapportPDFProps {
+ report: ResultatrapportReport
+ company: CompanySettings
+ generatedAt: string
+}
+
+export function ResultatrapportPDF({ report, company, generatedAt }: ResultatrapportPDFProps) {
+ const hasPrior = report.prior_period !== null
+
+ return (
+
+
+
+
+
+ Konto
+ Kontonamn
+ Innevarande
+ {hasPrior && (
+ Föregående
+ )}
+
+
+ {report.groups.map((group) => (
+ // No wrap={false} on the outer View — large account classes (80+
+ // active accounts) would otherwise be silently clipped instead of
+ // flowing onto the next page.
+
+ {group.class_label}
+ {group.rows.map((row) => (
+
+ {row.account_number}
+ {row.account_name}
+ {formatAmount(row.current_period)}
+ {hasPrior && (
+ {formatAmount(row.prior_period)}
+ )}
+
+ ))}
+
+ Summa
+ {formatAmount(group.subtotal_current)}
+ {hasPrior && (
+
+ {formatAmount(group.subtotal_prior)}
+
+ )}
+
+
+ ))}
+
+
+
+ Beräknat resultat
+
+ {formatAmount(report.net_result_current)}
+
+ {hasPrior && (
+
+ {formatAmount(report.net_result_prior)}
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+interface BalansrapportPDFProps {
+ report: BalansrapportReport
+ company: CompanySettings
+ generatedAt: string
+}
+
+export function BalansrapportPDF({ report, company, generatedAt }: BalansrapportPDFProps) {
+ return (
+
+
+
+
+
+ Konto
+ Kontonamn
+ Ingående
+ Utgående
+ Förändring
+
+
+ {report.groups.map((group) => (
+ // See ResultatrapportPDF: outer View must wrap so large classes
+ // flow across pages instead of being clipped.
+
+ {group.class_label}
+ {group.rows.map((row) => (
+
+ {row.account_number}
+ {row.account_name}
+ {formatAmount(row.ib)}
+ {formatAmount(row.ub)}
+ {formatAmount(row.period_change)}
+
+ ))}
+
+ Summa
+ {formatAmount(group.subtotal_ib)}
+ {formatAmount(group.subtotal_ub)}
+
+ {formatAmount(group.subtotal_ub - group.subtotal_ib)}
+
+
+
+ ))}
+
+
+
+ Summa tillgångar
+ {formatAmount(report.total_assets_ub)}
+
+
+ Summa eget kapital, reserver, avsättningar och skulder
+ {formatAmount(report.total_equity_liabilities_ub)}
+
+
+ Beräknat resultat (ej bokslutsjusterat)
+ {formatAmount(report.beraknat_resultat)}
+
+
+ Balanscheck
+
+ {report.is_balanced ? 'Balanserar' : 'Balanserar ej'}
+
+
+
+
+
+
+
+ )
+}