Files
accounted/app/api/reports/income-statement/pdf/route.ts
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

216 lines
7.9 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 { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStatementSection, type FinancialStatementSummaryRow } from '@/lib/reports/financial-statement-pdf-template'
import { requireCompanyId } from '@/lib/company/context'
import type { CompanySettings } from '@/types'
// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8
// into three named blocks with subtotals:
// 80–84 → Finansiella poster (followed by "Resultat efter finansiella poster")
// 88 → Bokslutsdispositioner
// 89 → Skatt på årets resultat
// The generator lumps these together under financial_sections, so we split
// here by the first row's account prefix.
const FINANSIELLA_POSTER_PREFIXES = ['80', '81', '82', '83', '84']
const BOKSLUTSDISPOSITIONER_PREFIXES = ['88']
const SKATT_PREFIXES = ['89']
const KNOWN_CLASS_8_PREFIXES = [
...FINANSIELLA_POSTER_PREFIXES,
...BOKSLUTSDISPOSITIONER_PREFIXES,
...SKATT_PREFIXES,
]
function sectionPrefix(section: FinancialStatementSection, prefixes: string[]): boolean {
if (section.rows.length === 0) return false
const acc = section.rows[0].account_number
return prefixes.some((p) => acc.startsWith(p))
}
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 generateIncomeStatement(supabase, companyId, periodId)
report.period = { start: period.period_start, end: period.period_end }
const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100
// Split class 8 into its three K2/K3 blocks plus a catch-all for any
// prefix the generator emits but we haven't explicitly mapped. If a future
// generator change adds sections for 85/86/87 or similar, this keeps them
// visible and arithmetically accounted for rather than silently dropped.
const finansiellaPosterSections = report.financial_sections.filter((s) =>
sectionPrefix(s, FINANSIELLA_POSTER_PREFIXES),
)
const bokslutsdispositionerSections = report.financial_sections.filter((s) =>
sectionPrefix(s, BOKSLUTSDISPOSITIONER_PREFIXES),
)
const skattSections = report.financial_sections.filter((s) =>
sectionPrefix(s, SKATT_PREFIXES),
)
const ovrigaFinansiellaPosterSections = report.financial_sections.filter(
(s) => !sectionPrefix(s, KNOWN_CLASS_8_PREFIXES),
)
const totalFinansiellaPoster = Math.round(
finansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
const totalBokslutsdispositioner = Math.round(
bokslutsdispositionerSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
const totalSkatt = Math.round(
skattSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
const totalOvrigaFinansiellaPoster = Math.round(
ovrigaFinansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
) / 100
// Catch-all is treated as part of "finansiella poster" for the subtotal —
// 85-87 accounts in BAS are financial-adjacent (not tax, not bokslut).
const resultatEfterFinansiellaPoster = Math.round(
(operatingResult + totalFinansiellaPoster + totalOvrigaFinansiellaPoster) * 100,
) / 100
const groups: FinancialStatementGroup[] = [
{
heading: 'Rörelseintäkter',
sections: report.revenue_sections,
totalLabel: 'Summa rörelseintäkter',
total: report.total_revenue,
},
{
heading: 'Rörelsekostnader',
sections: report.expense_sections,
totalLabel: 'Summa rörelsekostnader',
total: report.total_expenses,
negate: true,
},
]
if (finansiellaPosterSections.length > 0) {
groups.push({
heading: 'Finansiella poster',
sections: finansiellaPosterSections,
totalLabel: 'Summa finansiella poster',
total: totalFinansiellaPoster,
})
}
if (ovrigaFinansiellaPosterSections.length > 0) {
groups.push({
heading: 'Övriga finansiella poster',
sections: ovrigaFinansiellaPosterSections,
totalLabel: 'Summa övriga finansiella poster',
total: totalOvrigaFinansiellaPoster,
})
}
if (bokslutsdispositionerSections.length > 0) {
groups.push({
heading: 'Bokslutsdispositioner',
sections: bokslutsdispositionerSections,
totalLabel: 'Summa bokslutsdispositioner',
total: totalBokslutsdispositioner,
})
}
if (skattSections.length > 0) {
groups.push({
heading: 'Skatter',
sections: skattSections,
totalLabel: 'Summa skatter',
total: totalSkatt,
})
}
// K2/K3 uppställningsform (ÅRL bilaga 2) summary structure:
// Rörelseresultat
// Resultat efter finansiella poster (only if finansiella poster present)
// Bokslutsdispositioner (only if present)
// Skatt på årets resultat (always, so the reader can verify the tax calc)
// Årets resultat
const summary: FinancialStatementSummaryRow[] = [
{ label: 'Rörelseresultat', amount: operatingResult },
]
if (
finansiellaPosterSections.length > 0 ||
ovrigaFinansiellaPosterSections.length > 0
) {
summary.push({
label: 'Resultat efter finansiella poster',
amount: resultatEfterFinansiellaPoster,
})
}
if (bokslutsdispositionerSections.length > 0) {
summary.push({ label: 'Bokslutsdispositioner', amount: totalBokslutsdispositioner })
}
summary.push({ label: 'Skatt på årets resultat', amount: totalSkatt })
summary.push({ label: 'Årets resultat', amount: report.net_result, emphasis: true })
const pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
title: 'Resultaträkning',
groups,
summary,
period: report.period,
company: companyRow as CompanySettings,
generatedAt: new Date().toISOString(),
})
)
// "-utkast" suffix keeps the draft status visible even after the file
// leaves the browser — complements the in-document ÅRL 2:7 disclaimer.
const filename = `resultatrakning-${report.period.start}-utkast.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 resultaträkning' },
{ status: 500 }
)
}
}