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:
Mattsson
2026-08-25 20:34:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a83baede72
commit 85e039035d
21 changed files with 1591 additions and 200 deletions
+3 -25
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
import { buildBalanceSheetPdfModel, balanceSheetImbalanceKronor } from '@/lib/reports/financial-statement-pdf'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
@@ -53,17 +54,7 @@ export const GET = withRouteContext('report.balance_sheet.pdf', async (request,
const report = await generateBalanceSheet(supabase, companyId, periodId, range)
report.period = { start: effectiveStart, end: effectiveEnd }
const totalAssets = report.total_assets
const totalEquityLiab = 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.
const diffInKronor = Math.abs(Math.round(totalAssets) - Math.round(totalEquityLiab))
if (diffInKronor >= 1) {
if (balanceSheetImbalanceKronor(report) >= 1) {
return NextResponse.json(
{
error:
@@ -76,20 +67,7 @@ export const GET = withRouteContext('report.balance_sheet.pdf', async (request,
const pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
title: 'Balansräkning',
groups: [
{
heading: 'Tillgångar',
sections: report.asset_sections,
totalLabel: 'Summa tillgångar',
total: totalAssets,
},
{
heading: 'Eget kapital och skulder',
sections: report.equity_liability_sections,
totalLabel: 'Summa eget kapital och skulder',
total: totalEquityLiab,
},
],
groups: buildBalanceSheetPdfModel(report).groups,
period: report.period,
company: companyRow as CompanySettings,
generatedAt: new Date().toISOString(),
+3 -132
View File
@@ -1,35 +1,14 @@
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 { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
import { buildIncomeStatementPdfModel } from '@/lib/reports/financial-statement-pdf'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// 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 const GET = withRouteContext('report.income_statement.pdf', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
@@ -84,115 +63,7 @@ export const GET = withRouteContext('report.income_statement.pdf', async (reques
})
report.period = { start: effectiveStart, end: effectiveEnd }
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 { groups, summary } = buildIncomeStatementPdfModel(report)
const pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
@@ -0,0 +1,283 @@
/**
* Route-layer tests for the v1 report PDF endpoints (income-statement/pdf,
* balance-sheet/pdf). Rendering is mocked: what is under test is the route
* contract: strict query params, range validation, company-settings guard,
* the balance gate, and the binary response headers.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`reports pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const mocks = vi.hoisted(() => ({
generateBalanceSheet: vi.fn(),
generateIncomeStatement: vi.fn(),
renderToBuffer: vi.fn(),
}))
vi.mock('@/lib/reports/balance-sheet', () => ({
generateBalanceSheet: mocks.generateBalanceSheet,
}))
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: mocks.generateIncomeStatement,
}))
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: mocks.renderToBuffer,
}))
vi.mock('@/lib/reports/financial-statement-pdf-template', () => ({
FinancialStatementPDF: vi.fn().mockReturnValue(null),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as incomeStatementPdf } from '../income-statement/pdf/route'
import { GET as balanceSheetPdf } from '../balance-sheet/pdf/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const PERIOD_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const PERIOD_ROW = {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
}
const INCOME_STATEMENT_REPORT = {
revenue_sections: [],
total_revenue: 100,
expense_sections: [],
total_expenses: 40,
financial_sections: [],
total_financial: 0,
net_result: 60,
period: { start: '2026-01-01', end: '2026-12-31' },
}
const BALANCED_BALANCE_SHEET = {
asset_sections: [],
total_assets: 500,
equity_liability_sections: [],
total_equity_liabilities: 500,
period: { start: '2026-01-01', end: '2026-12-31' },
}
function makeReq(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
function mockClientWith(settings: TableResp = { data: { company_id: COMPANY_ID, company_name: 'Testbolaget AB' }, error: null }) {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: { data: PERIOD_ROW, error: null },
company_settings: settings,
}),
)
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['reports:read'],
mode: 'live',
})
mocks.renderToBuffer.mockResolvedValue(Buffer.from('%PDF-fixture'))
})
describe('GET /reports/income-statement/pdf', () => {
it('returns 401 without a bearer token', async () => {
mockClientWith()
const res = await incomeStatementPdf(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(401)
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
})
it('renders a PDF for a custom range with the range in the filename', async () => {
mockClientWith()
mocks.generateIncomeStatement.mockResolvedValue({ ...INCOME_STATEMENT_REPORT })
const res = await incomeStatementPdf(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}&from_date=2026-01-01&to_date=2026-07-31`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain(
'resultatrakning-2026-01-01--2026-07-31-utkast.pdf',
)
expect(mocks.generateIncomeStatement).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
PERIOD_ID,
{ fromDate: '2026-01-01', toDate: '2026-07-31' },
)
})
it('rejects unknown query parameters', async () => {
mockClientWith()
const res = await incomeStatementPdf(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}&locale=sv`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.unknown_params).toEqual(['locale'])
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
})
it('returns 404 NOT_FOUND when company settings are missing', async () => {
mockClientWith({ data: null, error: null })
const res = await incomeStatementPdf(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
})
})
describe('GET /reports/balance-sheet/pdf', () => {
it('returns 401 without a bearer token', async () => {
mockClientWith()
const res = await balanceSheetPdf(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet/pdf?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(401)
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
})
it('renders the balance position as of a custom date', async () => {
mockClientWith()
mocks.generateBalanceSheet.mockResolvedValue({ ...BALANCED_BALANCE_SHEET })
const res = await balanceSheetPdf(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet/pdf?period_id=${PERIOD_ID}&as_of=2026-07-31`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain(
'balansrakning-2026-01-01--2026-07-31-utkast.pdf',
)
expect(mocks.generateBalanceSheet).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
PERIOD_ID,
{ fromDate: undefined, toDate: '2026-07-31' },
)
})
it('refuses to render an unbalanced balansräkning', async () => {
mockClientWith()
mocks.generateBalanceSheet.mockResolvedValue({
...BALANCED_BALANCE_SHEET,
total_equity_liabilities: 400,
})
const res = await balanceSheetPdf(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet/pdf?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
})
})
@@ -299,6 +299,103 @@ describe('GET /reports/balance-sheet', () => {
const body = await res.json()
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
it('maps as_of to the generator toDate and echoes the effective window', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
mocks.generateBalanceSheet.mockResolvedValue({ sections: [], totals: {} })
const res = await balanceSheet(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}&as_of=2026-07-31`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(mocks.generateBalanceSheet).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
PERIOD_ID,
{ fromDate: undefined, toDate: '2026-07-31' },
)
const body = await res.json()
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
})
it('rejects from_date as an unknown parameter (a balance sheet is a position, not a flow)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
const res = await balanceSheet(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}&from_date=2026-07-01`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.unknown_params).toEqual(['from_date'])
expect(mocks.generateBalanceSheet).not.toHaveBeenCalled()
})
it('returns 400 VALIDATION_ERROR when both as_of and to_date are passed', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
const res = await balanceSheet(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}&as_of=2026-07-31&to_date=2026-06-30`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mocks.generateBalanceSheet).not.toHaveBeenCalled()
})
})
describe('GET /reports/income-statement', () => {
@@ -331,6 +428,141 @@ describe('GET /reports/income-statement', () => {
const body = await res.json()
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
function mockPeriodClient() {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
}
it('passes from_date/to_date to the generator and echoes the effective range', async () => {
mockPeriodClient()
mocks.generateIncomeStatement.mockResolvedValue({ sections: [], grossMargin: 0, netResult: 0 })
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=2026-01-01&to_date=2026-07-31`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(mocks.generateIncomeStatement).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
PERIOD_ID,
{ fromDate: '2026-01-01', toDate: '2026-07-31' },
)
const body = await res.json()
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
})
it('returns 400 VALIDATION_ERROR for a malformed from_date', async () => {
mockPeriodClient()
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=07%2F31%2F2026`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
})
it('returns 400 VALIDATION_ERROR when the range is outside the fiscal period', async () => {
mockPeriodClient()
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&to_date=2027-01-31`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
})
it('returns 400 VALIDATION_ERROR when from_date is after to_date', async () => {
mockPeriodClient()
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=2026-08-01&to_date=2026-07-01`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
})
it('returns 400 VALIDATION_ERROR for an empty from_date value', async () => {
mockPeriodClient()
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
})
it('tolerates the wrapper-level dry_run parameter', async () => {
mockPeriodClient()
mocks.generateIncomeStatement.mockResolvedValue({ sections: [], grossMargin: 0, netResult: 0 })
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&dry_run=true`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
})
it('rejects unknown query parameters instead of silently ignoring them', async () => {
mockPeriodClient()
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&fromdate=2026-01-01`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.unknown_params).toEqual(['fromdate'])
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
})
})
describe('GET /reports/sie-export', () => {
@@ -0,0 +1,170 @@
/**
* GET /api/v1/companies/{companyId}/reports/balance-sheet/pdf
*
* Render the balansräkning as application/pdf, byte-equivalent to the
* dashboard's PDF export. Supports the same optional `as_of` (alias for
* `to_date`) / `from_date` / `to_date` range as the JSON endpoint, so an
* agent can fetch the balance position at e.g. the latest month-end.
*/
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
assertKnownQueryParams,
loadPeriodFromQuery,
loadRangeFromQuery,
safeGenerate,
} from '@/lib/api/v1/report-period'
import { contentDisposition } from '@/lib/api/content-disposition'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
import {
buildBalanceSheetPdfModel,
balanceSheetImbalanceKronor,
} from '@/lib/reports/financial-statement-pdf'
import type { CompanySettings } from '@/types'
// No from_date: a balansräkning is a cumulative position, not a flow over a
// window (see the JSON route). as_of is the natural spelling; to_date is
// accepted as its synonym for consistency with the income statement.
const ALLOWED_PARAMS = ['period_id', 'to_date', 'as_of'] as const
registerEndpoint({
operation: 'reports.balance-sheet.pdf',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/balance-sheet/pdf',
summary: 'Balance sheet (balansräkning) as a PDF.',
description:
'Renders the balansräkning as application/pdf, byte-equivalent to the dashboard export. Optional `as_of` (alias for `to_date`, YYYY-MM-DD inside the fiscal period) returns the balance position at that date, e.g. the latest month-end for bank reporting. Refuses to render when tillgångar and eget kapital + skulder differ by a full krona or more.',
useWhen:
'You need a presentable PDF of the balance position at period end or a custom date: bank requests, board packs, or sharing outside Accounted.',
doNotUseFor:
'Machine-readable figures (use the JSON endpoint without /pdf). The formal K2/K3 årsredovisning document (use the year-end flow).',
pitfalls: [
'`period_id` is required; `as_of` (alias: `to_date`, pass at most one) is optional and must lie within that fiscal period. `from_date` is not accepted: a balance sheet is a cumulative position, not a flow over a window.',
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
'An unbalanced balansräkning (>= 1 kr difference) returns REPORT_GENERATION_FAILED instead of a PDF: fix the imbalance first.',
'The PDF is marked "utkast": it is a working report, not a fastställd årsredovisning.',
],
example: {
response: {
_note: 'Returns application/pdf binary stream.',
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: {
success: z.unknown(), // Marker: binary response, see contentType.
contentType: 'application/pdf',
},
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.balance-sheet.pdf',
async (request, ctx) => {
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
if (!params.ok) return params.response
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const rangeResult = await loadRangeFromQuery(request, period.period, ctx, { asOfAlias: true })
if (!rangeResult.ok) return rangeResult.response
const range = rangeResult.range
const { data: company, error: companyErr } = await ctx.supabase
.from('company_settings')
.select('*')
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (companyErr) {
return v1ErrorResponse(companyErr, ctx.log, { requestId: ctx.requestId })
}
if (!company) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'company_settings' },
})
}
const gen = await safeGenerate(
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id, range),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'balance-sheet-pdf' },
)
if (!gen.ok) return gen.response
const report = gen.result
report.period = {
start: range.fromDate ?? period.period.period_start,
end: range.toDate ?? period.period.period_end,
}
// Same gate as the dashboard export: ÅRL 3 kap requires balansräkningen
// to balance; a PDF of an unbalanced one would misrepresent the books.
if (balanceSheetImbalanceKronor(report) >= 1) {
// 400, matching the dashboard export: the imbalance is a condition in
// the caller's books, not a server fault; a 5xx would put retry loops
// and error monitoring on a request that will never succeed unchanged.
return v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
requestId: ctx.requestId,
status: 400,
details: {
report: 'balance-sheet-pdf',
reason:
'Balansräkningen balanserar inte (tillgångar och eget kapital + skulder skiljer sig med minst 1 kr). Åtgärda differensen innan du genererar PDF.',
total_assets: report.total_assets,
total_equity_liabilities: report.total_equity_liabilities,
},
})
}
let pdfBuffer: Buffer
try {
pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
title: 'Balansräkning',
groups: buildBalanceSheetPdfModel(report).groups,
period: report.period,
company: company as CompanySettings,
generatedAt: new Date().toISOString(),
}),
)
} catch (err) {
ctx.log.error('reports.balance-sheet.pdf: render failed', err as Error, {
companyId: ctx.companyId,
periodId: period.period.id,
})
return v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { report: 'balance-sheet-pdf', reason: 'PDF rendering failed.' },
})
}
// "-utkast" suffix keeps the draft status visible even after the file
// leaves the client: complements the in-document ÅRL 2:7 disclaimer.
const filename = `balansrakning-${report.period.start}--${report.period.end}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': String(pdfBuffer.length),
'Cache-Control': 'private, no-store',
'X-Request-Id': ctx.requestId,
},
})
},
)
@@ -10,9 +10,19 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import {
assertKnownQueryParams,
loadPeriodFromQuery,
loadRangeFromQuery,
safeGenerate,
} from '@/lib/api/v1/report-period'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
// No from_date here: a balansräkning is a cumulative position, not a flow
// over a window; a from_date would only mislabel the echoed period. Matches
// the MCP tool, which exposes as_of_date alone.
const ALLOWED_PARAMS = ['period_id', 'to_date', 'as_of'] as const
// Use z.unknown for the rich nested shape: the lib types are stable and
// callers consume via `data.sections[…]`. Strict Zod schemas here would
// require importing every BAS-section type, which adds maintenance with no
@@ -23,15 +33,16 @@ registerEndpoint({
operation: 'reports.balance-sheet',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/balance-sheet',
summary: 'Balance sheet (balansräkning) for a fiscal period.',
summary: 'Balance sheet (balansräkning) for a fiscal period or as of a custom date.',
description:
'Returns assets / liabilities / equity grouped into BAS sections, with the period\'s opening and closing balances. Sums match the income statement for the same period; the closing equity flows into next period\'s opening balance.',
'Returns assets / liabilities / equity grouped into BAS sections, with the period\'s opening and closing balances. Optional `as_of` (alias for `to_date`, YYYY-MM-DD inside the fiscal period) returns the balance position at that date, e.g. the latest month-end for bank reporting. Sums match the income statement for the same period; the closing equity flows into next period\'s opening balance.',
useWhen:
'You need the company\'s balance position at period end: typically for management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.',
'You need the company\'s balance position at period end or at a custom date: typically management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.',
doNotUseFor:
'Per-account drill-down (use /reports/general-ledger). Net result for the period (use /reports/income-statement).',
pitfalls: [
'`period_id` is required.',
'`period_id` is required; `as_of` (alias: `to_date`, pass at most one) is optional and must lie within that fiscal period. `from_date` is not accepted: a balance sheet is a cumulative position, not a flow over a window.',
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
'Balance sheet equity includes the period\'s computed result: recalculation happens on every call, so a freshly-posted entry is reflected immediately (no caching).',
],
example: {
@@ -55,6 +66,9 @@ registerEndpoint({
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.balance-sheet',
async (request, ctx) => {
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
if (!params.ok) return params.response
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
@@ -63,8 +77,12 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
if (!period.ok) return period.response
const rangeResult = await loadRangeFromQuery(request, period.period, ctx, { asOfAlias: true })
if (!rangeResult.ok) return rangeResult.response
const range = rangeResult.range
const gen = await safeGenerate(
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id),
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id, range),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'balance-sheet' },
)
if (!gen.ok) return gen.response
@@ -73,9 +91,12 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
// The cast through `unknown` is the standard pattern for adding an
// ad-hoc field to a structurally-typed lib return (BalanceSheetReport
// doesn't formally include `period`, but the dashboard's behavior
// attaches it).
// attaches it). Echo the effective range, not the fiscal-period bounds.
const result = gen.result as unknown as Record<string, unknown>
result.period = { start: period.period.period_start, end: period.period.period_end }
result.period = {
start: range.fromDate ?? period.period.period_start,
end: range.toDate ?? period.period.period_end,
}
return ok(result, { requestId: ctx.requestId })
},
@@ -0,0 +1,147 @@
/**
* GET /api/v1/companies/{companyId}/reports/income-statement/pdf
*
* Render the resultaträkning as application/pdf, byte-equivalent to the
* dashboard's PDF export. Supports the same optional `from_date` / `to_date`
* range as the JSON endpoint, so an agent can fetch e.g. a January-July
* report for bank requests without touching the web UI.
*/
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
assertKnownQueryParams,
loadPeriodFromQuery,
loadRangeFromQuery,
safeGenerate,
} from '@/lib/api/v1/report-period'
import { contentDisposition } from '@/lib/api/content-disposition'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
import { buildIncomeStatementPdfModel } from '@/lib/reports/financial-statement-pdf'
import type { CompanySettings } from '@/types'
const ALLOWED_PARAMS = ['period_id', 'from_date', 'to_date'] as const
registerEndpoint({
operation: 'reports.income-statement.pdf',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/income-statement/pdf',
summary: 'Income statement (resultaträkning) as a PDF.',
description:
'Renders the resultaträkning as application/pdf, byte-equivalent to the dashboard export. Optional `from_date` / `to_date` (YYYY-MM-DD, inside the fiscal period) narrow the report to a custom range. The filename carries the effective date range and an "utkast" suffix (the document is a working report, not a signed årsredovisning).',
useWhen:
'You need a presentable PDF of the profit/loss for a period or partial period: bank requests, board packs, or sharing outside Accounted.',
doNotUseFor:
'Machine-readable figures (use the JSON endpoint without /pdf). The formal K2/K3 årsredovisning document (use the year-end flow).',
pitfalls: [
'`period_id` is required; `from_date`/`to_date` are optional and must lie within that fiscal period.',
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
'The PDF is marked "utkast": it is a working report, not a fastställd årsredovisning.',
],
example: {
response: {
_note: 'Returns application/pdf binary stream.',
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: {
success: z.unknown(), // Marker: binary response, see contentType.
contentType: 'application/pdf',
},
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.income-statement.pdf',
async (request, ctx) => {
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
if (!params.ok) return params.response
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const rangeResult = await loadRangeFromQuery(request, period.period, ctx)
if (!rangeResult.ok) return rangeResult.response
const range = rangeResult.range
const { data: company, error: companyErr } = await ctx.supabase
.from('company_settings')
.select('*')
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (companyErr) {
return v1ErrorResponse(companyErr, ctx.log, { requestId: ctx.requestId })
}
if (!company) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'company_settings' },
})
}
const gen = await safeGenerate(
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id, range),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'income-statement-pdf' },
)
if (!gen.ok) return gen.response
const report = gen.result
report.period = {
start: range.fromDate ?? period.period.period_start,
end: range.toDate ?? period.period.period_end,
}
const { groups, summary } = buildIncomeStatementPdfModel(report)
let pdfBuffer: Buffer
try {
pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
title: 'Resultaträkning',
groups,
summary,
period: report.period,
company: company as CompanySettings,
generatedAt: new Date().toISOString(),
}),
)
} catch (err) {
ctx.log.error('reports.income-statement.pdf: render failed', err as Error, {
companyId: ctx.companyId,
periodId: period.period.id,
})
return v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { report: 'income-statement-pdf', reason: 'PDF rendering failed.' },
})
}
// "-utkast" suffix keeps the draft status visible even after the file
// leaves the client: complements the in-document ÅRL 2:7 disclaimer.
const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': String(pdfBuffer.length),
'Cache-Control': 'private, no-store',
'X-Request-Id': ctx.requestId,
},
})
},
)
@@ -10,24 +10,32 @@ import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import {
assertKnownQueryParams,
loadPeriodFromQuery,
loadRangeFromQuery,
safeGenerate,
} from '@/lib/api/v1/report-period'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
const ALLOWED_PARAMS = ['period_id', 'from_date', 'to_date'] as const
const IncomeStatementResponse = z.unknown()
registerEndpoint({
operation: 'reports.income-statement',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/income-statement',
summary: 'Income statement (resultatrapport) for a fiscal period.',
summary: 'Income statement (resultatrapport) for a fiscal period or a custom date range.',
description:
'Returns the period\'s revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). The net result flows into the balance-sheet equity for the same period.',
'Returns the period\'s revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). Optional `from_date` / `to_date` (YYYY-MM-DD, inside the fiscal period) narrow the report to a custom range, e.g. January 1 to July 31 for month-end bank reporting. The net result flows into the balance-sheet equity for the same period.',
useWhen:
'You need the company\'s profit/loss for a period: month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.',
'You need the company\'s profit/loss for a period or partial period: month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.',
doNotUseFor:
'Per-account drill (use /reports/general-ledger). VAT figures (use /reports/vat-declaration). Balance position (use /reports/balance-sheet).',
pitfalls: [
'`period_id` is required.',
'`period_id` is required; `from_date`/`to_date` are optional and must lie within that fiscal period.',
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
'Net result on the income statement equals the period\'s equity-line delta on the balance sheet: they\'re derived from the same posted entries.',
],
example: {
@@ -47,6 +55,9 @@ registerEndpoint({
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.income-statement',
async (request, ctx) => {
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
if (!params.ok) return params.response
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
@@ -55,14 +66,23 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
})
if (!period.ok) return period.response
const rangeResult = await loadRangeFromQuery(request, period.period, ctx)
if (!rangeResult.ok) return rangeResult.response
const range = rangeResult.range
const gen = await safeGenerate(
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id),
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id, range),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'income-statement' },
)
if (!gen.ok) return gen.response
const result = gen.result as unknown as Record<string, unknown>
result.period = { start: period.period.period_start, end: period.period.period_end }
// Echo the effective range, not the fiscal-period bounds, so the caller
// sees exactly which window the numbers cover.
result.period = {
start: range.fromDate ?? period.period.period_start,
end: range.toDate ?? period.period.period_end,
}
return ok(result, { requestId: ctx.requestId })
},