85e039035d
* feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API
Requested by a v1/MCP user: the web UI can produce resultat- and
balansrapport for a custom period with PDF export, but REST v1 and the
MCP tools only served whole fiscal years and silently ignored
from_date/to_date.
- v1 income-statement: optional from_date/to_date (validated against the
fiscal period via the same parseReportDateRange the dashboard uses)
- v1 balance-sheet: same, plus as_of as the natural alias for to_date
(mutually exclusive with it)
- Unknown query params on these report routes now return
VALIDATION_ERROR with the unknown and allowed names instead of being
silently dropped (scoped to these routes, not a global v1 change)
- MCP gnubok_get_income_statement: from_date/to_date;
gnubok_get_balance_sheet: as_of_date; both validate format, in-period
and ordering, and reject unknown args (tools/list payload bench held
under the ceiling by trimming the same tools' descriptions)
- New v1 PDF endpoints reports/{income-statement,balance-sheet}/pdf,
byte-equivalent to the dashboard export: the K2/K3 grouping and the
balance gate moved to lib/reports/financial-statement-pdf.ts, shared
by both surfaces
- Both JSON endpoints echo the effective range in data.period
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): range semantics, empty-date validation, and review findings on PR #1909
Consolidated resolution of the skeptic refutations, CI failures, and
CodeRabbit findings:
- Ranged income statement summed closing balances, so from_date after
period start returned year-to-date figures mislabeled as the range
(July revenue reported as Jan-Jul on JSON, PDF, and MCP). The trial
balance rolls pre-range P&L activity into opening columns, so
generateIncomeStatement now builds from period movements whenever
fromDate is set, matching the resultatrapport convention. Full-period
behavior is unchanged; generator-level regression tests added.
- from_date dropped from the v1 balance-sheet routes (JSON + PDF): a
balansraking is a cumulative position, not a flow over a window
(ÅRL 3 kap); matches the MCP tool's as_of_date-only surface.
- Empty date values (from_date=) now fail validation instead of
silently producing a full-period report with an empty period echo
(null-check instead of truthiness in parseReportDateRange).
- dry_run, read by the withApiV1 wrapper on every request, is tolerated
by the strict param check instead of being rejected as unknown.
- Unbalanced balansrakning on the v1 PDF route returns 400 (caller-data
condition), matching the dashboard export, instead of 500.
- skills/accounted-api regenerated (apiskill:check gate).
- Removed the ISO_DATE_RE import that collided with the pre-existing
local declaration in the MCP server (TS2440 on core build).
- CodeRabbit: 401 tests for both PDF endpoints; event bus cleared in
the new MCP test's beforeEach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
195 lines
7.0 KiB
TypeScript
195 lines
7.0 KiB
TypeScript
/**
|
|
* Shared model builders for the financial-statement PDFs (resultaträkning /
|
|
* balansräkning). Extracted from the dashboard PDF routes so the v1 REST PDF
|
|
* endpoints render byte-equivalent documents: one place owns the K2/K3
|
|
* grouping and the balance check, two thin routes own auth + transport.
|
|
*/
|
|
|
|
import type {
|
|
FinancialStatementGroup,
|
|
FinancialStatementSection,
|
|
FinancialStatementSummaryRow,
|
|
} from './financial-statement-pdf-template'
|
|
import type { BalanceSheetReport, IncomeStatementReport } 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 interface IncomeStatementPdfModel {
|
|
groups: FinancialStatementGroup[]
|
|
summary: FinancialStatementSummaryRow[]
|
|
}
|
|
|
|
/**
|
|
* Build the K2/K3 uppställningsform groups + summary for the resultaträkning
|
|
* PDF from a generated income statement.
|
|
*/
|
|
export function buildIncomeStatementPdfModel(report: IncomeStatementReport): IncomeStatementPdfModel {
|
|
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 })
|
|
|
|
return { groups, summary }
|
|
}
|
|
|
|
export interface BalanceSheetPdfModel {
|
|
groups: FinancialStatementGroup[]
|
|
}
|
|
|
|
/** Build the balansräkning PDF groups from a generated balance sheet. */
|
|
export function buildBalanceSheetPdfModel(report: BalanceSheetReport): BalanceSheetPdfModel {
|
|
return {
|
|
groups: [
|
|
{
|
|
heading: 'Tillgångar',
|
|
sections: report.asset_sections,
|
|
totalLabel: 'Summa tillgångar',
|
|
total: report.total_assets,
|
|
},
|
|
{
|
|
heading: 'Eget kapital och skulder',
|
|
sections: report.equity_liability_sections,
|
|
totalLabel: 'Summa eget kapital och skulder',
|
|
total: report.total_equity_liabilities,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ÅRL 3 kap / K2 / K3 require balansräkningen to balance. Compare rounded
|
|
* to whole kronor: matches SFL 22:1's truncation convention for statutory
|
|
* reports and is immune to floating-point accumulation across hundreds of
|
|
* ledger lines (öresavrundning noise under half a krona is never a real
|
|
* accounting error). The on-screen view still surfaces a "Balanserar ej"
|
|
* warning at öre precision so users can diagnose smaller discrepancies.
|
|
*/
|
|
export function balanceSheetImbalanceKronor(report: BalanceSheetReport): number {
|
|
return Math.abs(Math.round(report.total_assets) - Math.round(report.total_equity_liabilities))
|
|
}
|