feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API (#1909)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a83baede72
commit
85e039035d
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `136`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `138`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -41,9 +41,11 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"GET /api/v1/companies/:companyId/reports/ar-ledger",
|
||||
"GET /api/v1/companies/:companyId/reports/avgifter-basis",
|
||||
"GET /api/v1/companies/:companyId/reports/balance-sheet",
|
||||
"GET /api/v1/companies/:companyId/reports/balance-sheet/pdf",
|
||||
"GET /api/v1/companies/:companyId/reports/continuity-check",
|
||||
"GET /api/v1/companies/:companyId/reports/general-ledger",
|
||||
"GET /api/v1/companies/:companyId/reports/income-statement",
|
||||
"GET /api/v1/companies/:companyId/reports/income-statement/pdf",
|
||||
"GET /api/v1/companies/:companyId/reports/journal-register",
|
||||
"GET /api/v1/companies/:companyId/reports/monthly-breakdown",
|
||||
"GET /api/v1/companies/:companyId/reports/salary-journal",
|
||||
|
||||
@@ -124,7 +124,9 @@ import '@/app/api/v1/companies/[companyId]/salary/vacation-year-close/route'
|
||||
// to a follow-up PR (different lib-module structures).
|
||||
import '@/app/api/v1/companies/[companyId]/reports/trial-balance/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/balance-sheet/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/balance-sheet/pdf/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/income-statement/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/income-statement/pdf/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/general-ledger/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/journal-register/route'
|
||||
import '@/app/api/v1/companies/[companyId]/reports/vat-declaration/route'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { z } from 'zod'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import { parseReportDateRange, type DateRange } from '@/lib/reports/date-range'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from './errors'
|
||||
|
||||
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
||||
@@ -96,6 +97,100 @@ export async function loadPeriodFromQuery(
|
||||
return { ok: true, period: data as FiscalPeriodRow }
|
||||
}
|
||||
|
||||
export type QueryParamsResult = { ok: true } | { ok: false; response: Response }
|
||||
|
||||
/**
|
||||
* Reject unknown query parameters instead of silently ignoring them.
|
||||
*
|
||||
* Report endpoints historically dropped anything they didn't read, so an
|
||||
* agent passing a misspelled or unsupported parameter (e.g. `from=` instead
|
||||
* of `from_date=`) got a full-period report back with no signal that its
|
||||
* intent was ignored. For date-scoped financial reports that's dangerous:
|
||||
* the caller believes it holds a January-July resultatrapport when it holds
|
||||
* the whole year. Scoped to the report routes that opt in; not a global v1
|
||||
* behavior change.
|
||||
*/
|
||||
// Params the withApiV1 wrapper itself reads on every request; a route-level
|
||||
// allowlist must never reject them.
|
||||
const WRAPPER_PARAMS = ['dry_run']
|
||||
|
||||
export async function assertKnownQueryParams(
|
||||
request: Request,
|
||||
allowed: readonly string[],
|
||||
ctx: { requestId: string; log: Logger },
|
||||
): Promise<QueryParamsResult> {
|
||||
const url = new URL(request.url)
|
||||
const unknown = [...new Set(url.searchParams.keys())].filter(
|
||||
(k) => !allowed.includes(k) && !WRAPPER_PARAMS.includes(k),
|
||||
)
|
||||
if (unknown.length === 0) return { ok: true }
|
||||
return {
|
||||
ok: false,
|
||||
response: await v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
unknown_params: unknown,
|
||||
allowed_params: [...allowed],
|
||||
message: `Unknown query parameter(s): ${unknown.join(', ')}. Unknown parameters are rejected rather than silently ignored.`,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export type RangeResult =
|
||||
| { ok: true; range: DateRange }
|
||||
| { ok: false; response: Response }
|
||||
|
||||
/**
|
||||
* Parse the optional `from_date` / `to_date` (and, when `asOfAlias` is set,
|
||||
* `as_of` as an alias for `to_date`: the natural vocabulary for a balance
|
||||
* position) from the query string, validated against the fiscal period via
|
||||
* the same `parseReportDateRange` the dashboard report routes use. Keeping
|
||||
* one validator means the REST surface accepts exactly the ranges the web
|
||||
* UI accepts: clamped inside the räkenskapsår, `from_date <= to_date`.
|
||||
*/
|
||||
export async function loadRangeFromQuery(
|
||||
request: Request,
|
||||
period: FiscalPeriodRow,
|
||||
ctx: { requestId: string; log: Logger },
|
||||
opts?: { asOfAlias?: boolean },
|
||||
): Promise<RangeResult> {
|
||||
const url = new URL(request.url)
|
||||
const searchParams = new URLSearchParams(url.searchParams)
|
||||
|
||||
if (opts?.asOfAlias) {
|
||||
const asOf = searchParams.get('as_of')
|
||||
if (asOf !== null) {
|
||||
if (searchParams.get('to_date') !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
response: await v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'as_of',
|
||||
message: 'Pass either as_of or to_date, not both (as_of is an alias for to_date).',
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
searchParams.set('to_date', asOf)
|
||||
searchParams.delete('as_of')
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseReportDateRange(searchParams, period)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
response: await v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { fields: ['from_date', 'to_date'], message: parsed.error },
|
||||
}),
|
||||
}
|
||||
}
|
||||
return { ok: true, range: parsed.range }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a report-generator call in a try/catch that surfaces a structured
|
||||
* REPORT_GENERATION_FAILED error if the generator throws. Mirrors the
|
||||
|
||||
@@ -161,6 +161,10 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'GET /api/v1/companies/:companyId/reports/trial-balance': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/reports/balance-sheet': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/reports/income-statement': 'reports:read',
|
||||
// Binary reports: PDF exports of the two financial statements, sharing the
|
||||
// dashboard's renderer (custom date ranges supported via query params).
|
||||
'GET /api/v1/companies/:companyId/reports/balance-sheet/pdf': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/reports/income-statement/pdf': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/reports/general-ledger': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/reports/journal-register': 'reports:read',
|
||||
'GET /api/v1/companies/:companyId/reports/vat-declaration': 'reports:read',
|
||||
|
||||
@@ -358,3 +358,67 @@ describe('generateIncomeStatement', () => {
|
||||
expect(roundOre(sectionSum)).toBe(expectedTotal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateIncomeStatement with a fromDate range', () => {
|
||||
// With fromDate > period_start, the trial balance rolls all pre-range
|
||||
// activity (P&L accounts included) into the opening columns, so closing
|
||||
// columns hold year-to-date figures. The ranged income statement must sum
|
||||
// the window's movements only (period columns): a July-only report of a
|
||||
// company with 10 000 kr January revenue and 5 000 kr July revenue shows
|
||||
// 5 000, not 15 000.
|
||||
const RANGED_ROWS = [
|
||||
makeRow({
|
||||
account_number: '3001',
|
||||
account_name: 'Försäljning 25%',
|
||||
account_class: 3,
|
||||
opening_credit: 10000, // Jan-Jun activity rolled into IB at range start
|
||||
period_credit: 5000, // July activity
|
||||
closing_credit: 15000, // opening + period = YTD
|
||||
}),
|
||||
makeRow({
|
||||
account_number: '5010',
|
||||
account_name: 'Lokalhyra',
|
||||
account_class: 5,
|
||||
opening_debit: 6000,
|
||||
period_debit: 1000,
|
||||
closing_debit: 7000,
|
||||
}),
|
||||
]
|
||||
|
||||
it('sums period movements, not YTD closing balances, when fromDate is set', async () => {
|
||||
mockTrialBalance.mockResolvedValue({
|
||||
rows: RANGED_ROWS,
|
||||
totalDebit: 7000,
|
||||
totalCredit: 15000,
|
||||
isBalanced: false,
|
||||
})
|
||||
|
||||
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1', {
|
||||
fromDate: '2026-07-01',
|
||||
toDate: '2026-07-31',
|
||||
})
|
||||
|
||||
expect(report.total_revenue).toBe(5000)
|
||||
expect(report.total_expenses).toBe(1000)
|
||||
expect(report.net_result).toBe(4000)
|
||||
})
|
||||
|
||||
it('keeps closing-balance behavior when no fromDate is given', async () => {
|
||||
mockTrialBalance.mockResolvedValue({
|
||||
rows: RANGED_ROWS,
|
||||
totalDebit: 7000,
|
||||
totalCredit: 15000,
|
||||
isBalanced: false,
|
||||
})
|
||||
|
||||
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1', {
|
||||
toDate: '2026-07-31',
|
||||
})
|
||||
|
||||
// Without fromDate there is no roll-forward: closing = period for P&L
|
||||
// accounts in real data. The fixture's opening values stand in for the
|
||||
// (absent) roll-forward, so closing-column sums are expected here.
|
||||
expect(report.total_revenue).toBe(15000)
|
||||
expect(report.total_expenses).toBe(7000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,10 +22,13 @@ export function parseReportDateRange(
|
||||
const rawFrom = searchParams.get('from_date')
|
||||
const rawTo = searchParams.get('to_date')
|
||||
|
||||
if (rawFrom && !ISO_DATE.test(rawFrom)) {
|
||||
// Null-check, not truthiness: an empty value (`from_date=`, the classic
|
||||
// unfilled template variable) must fail the format check rather than slip
|
||||
// through as a silent full-period request with an empty period echo.
|
||||
if (rawFrom !== null && !ISO_DATE.test(rawFrom)) {
|
||||
return { ok: false, error: 'from_date måste vara på formen YYYY-MM-DD.' }
|
||||
}
|
||||
if (rawTo && !ISO_DATE.test(rawTo)) {
|
||||
if (rawTo !== null && !ISO_DATE.test(rawTo)) {
|
||||
return { ok: false, error: 'to_date måste vara på formen YYYY-MM-DD.' }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
@@ -35,7 +35,15 @@ export async function generateIncomeStatement(
|
||||
dimensions: options?.dimensions,
|
||||
})
|
||||
|
||||
return buildIncomeStatementFromRows(rows)
|
||||
// With a fromDate after period start, the trial balance rolls all earlier
|
||||
// activity (P&L accounts included) into the opening columns, so the closing
|
||||
// columns hold year-to-date figures, not the requested window. A ranged
|
||||
// resultaträkning must therefore sum period movements only: the same
|
||||
// convention resultatrapport uses. Without a fromDate the closing columns
|
||||
// equal the movements for P&L accounts and behavior is unchanged.
|
||||
return buildIncomeStatementFromRows(rows, {
|
||||
periodMovements: Boolean(options?.fromDate),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +55,18 @@ export async function generateIncomeStatement(
|
||||
* above for why).
|
||||
*/
|
||||
export function buildIncomeStatementFromRows(
|
||||
rows: TrialBalanceRow[]
|
||||
rows: TrialBalanceRow[],
|
||||
buildOptions?: {
|
||||
/**
|
||||
* Sum period movements (period_debit/period_credit) instead of closing
|
||||
* balances. Required whenever the rows were generated with a fromDate
|
||||
* after period start: the roll-forward puts pre-range P&L activity into
|
||||
* the opening columns and the closing columns become year-to-date.
|
||||
*/
|
||||
periodMovements?: boolean
|
||||
}
|
||||
): IncomeStatementReport {
|
||||
const periodMovements = buildOptions?.periodMovements ?? false
|
||||
// Filter to income/expense accounts (class 3-8)
|
||||
const incomeExpenseRows = rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8
|
||||
@@ -71,6 +89,7 @@ export function buildIncomeStatementFromRows(
|
||||
},
|
||||
'credit', // Revenue has credit normal balance
|
||||
'Övriga intäkter',
|
||||
periodMovements,
|
||||
)
|
||||
|
||||
// Expense sections (class 4-7)
|
||||
@@ -118,6 +137,7 @@ export function buildIncomeStatementFromRows(
|
||||
},
|
||||
'debit', // Expenses have debit normal balance
|
||||
'Övriga kostnader',
|
||||
periodMovements,
|
||||
)
|
||||
|
||||
// Financial sections (class 8): exclude 8999 "Årets resultat".
|
||||
@@ -141,6 +161,7 @@ export function buildIncomeStatementFromRows(
|
||||
},
|
||||
'mixed',
|
||||
'Övriga finansiella poster',
|
||||
periodMovements,
|
||||
)
|
||||
|
||||
const totalRevenue = revenueSections.reduce((sum, s) => sum + s.subtotal, 0)
|
||||
@@ -173,16 +194,20 @@ function buildSections(
|
||||
rows: TrialBalanceRow[],
|
||||
groupLabels: Record<string, string>,
|
||||
normalBalance: 'debit' | 'credit' | 'mixed',
|
||||
fallbackTitle: string
|
||||
fallbackTitle: string,
|
||||
periodMovements = false
|
||||
): IncomeStatementSection[] {
|
||||
const makeSection = (title: string, groupRows: TrialBalanceRow[]): IncomeStatementSection => {
|
||||
const sectionRows = groupRows.map((r) => {
|
||||
// Expenses (debit) use debit - credit; revenue (credit) and financial
|
||||
// (mixed) use credit - debit.
|
||||
// (mixed) use credit - debit. Ranged reports sum the window's movements
|
||||
// (period columns); full-period reports keep the closing columns.
|
||||
const debit = periodMovements ? r.period_debit : r.closing_debit
|
||||
const credit = periodMovements ? r.period_credit : r.closing_credit
|
||||
const amount =
|
||||
normalBalance === 'debit'
|
||||
? r.closing_debit - r.closing_credit
|
||||
: r.closing_credit - r.closing_debit
|
||||
? debit - credit
|
||||
: credit - debit
|
||||
|
||||
return {
|
||||
account_number: r.account_number,
|
||||
|
||||
Reference in New Issue
Block a user