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
+2
View File
@@ -1238,5 +1238,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] acount, arbore, elma, m360, redovisningskompaniet, willem, and ziffr under accounted.se are customer-facing production hosts and fail closed with an empty 503 before session handling when NEXT_PUBLIC_SUPABASE_URL targets staging project metjnjrhvujscngnpzdv: Emil classified the hosts explicitly; exact-host callback allowlisting and the canonical app remain unchanged, while project ownership, Auth settings, DNS, keys, data, and migration are separate operational work.
[2026-08-25] Supabase Auth bot protection uses dependency-free Cloudflare Turnstile with a two-step rollout: a missing public site key keeps existing Auth flows available, while a configured client fails closed until it has a token; provider enforcement is enabled only after the client deploy is verified. The generic Docker image always permits Cloudflare's script and frame origins because its optional site key is substituted at runtime, after the CSP has been built.
[2026-08-25] Plugin distribution goes through the Claude plugin directory (public GitHub link, claude plugin validate, submit from claude.ai admin-settings or Console), not an organisation marketplace: org marketplaces accept private/internal repos only and require the Claude GitHub App, so a public monorepo can never pass that dialog (the 'Repository not accessible' error is misleading). Install-time guidance is a /accounted:setup slash command, the convention Anthropic's own plugins use (commands/*-setup.md); no SETUP.md mechanism exists in the plugin spec.
[2026-08-25] Report date-range API (Deepgrid request): unknown-query-param rejection is scoped to the income-statement/balance-sheet report routes and the two matching MCP tools, not a global v1/MCP behavior change: a blanket strict mode across 100+ tools and every route risks breaking existing integrations for zero user benefit; the report surfaces are where silent ignoring corrupts meaning (a full-year report mistaken for a partial one). REST balance sheet accepts as_of as an alias for to_date (natural balance-position vocabulary, mutually exclusive with to_date); the MCP balance-sheet tool got only as_of_date (matches existing gnubok as_of_date naming, keeps tools/list payload under the bench ceiling). No MCP PDF tool: binary payloads over MCP are a separate design question; agents fetch the new v1 REST /pdf endpoints instead.
[2026-08-25] Ranged income statement sums period movements, not closing balances (skeptic refutation on PR #1909): with from_date > period_start the trial balance rolls pre-range P&L activity into opening columns, so closing-column sums are year-to-date mislabeled as the range (July revenue reported as Jan-Jul). generateIncomeStatement now passes periodMovements to buildIncomeStatementFromRows whenever fromDate is set, matching the resultatrapport convention; full-period behavior is byte-identical. from_date was also dropped from the v1 balance-sheet routes (a balansräkning is a cumulative position; ÅRL 3 kap): as_of/to_date only, matching the MCP tool.
[2026-08-25] Issue #1870: skattekonto AGI seed reverted 2730 -> 2731 (salary side kept on 2731), not the alternative of moving SALARY_ACCOUNTS.AVGIFTER_LIABILITY to 2730: BAS 2026 defines 2731 as exactly the reported-but-unpaid arbetsgivaravgift liability (the accrual account is 2940), and the salary module's whole-krona/ore-residual logic (PR #1609, 2026-08-14 decisions) is built around 2731. The 20260519160000 migration's rationale mislabeled 2731 as the accrual account; a one-sided flip either way reintroduces the split. Historical 2730 debits since 2026-05-19 are left for per-company reclass verifikat, not repaired in-migration.
[2026-08-25] The marketplace entry for the Accounted plugin is a git-subdir source (public repo URL + path claude-plugin), not the relative path ./claude-plugin: relative sources only resolve when the whole marketplace repo is cloned (Claude Code), while Claude.ai's Add-marketplace backend fetches the manifest and resolves each plugin source as a repository, which surfaced as 'Repository not accessible' on a public repo. git-subdir is also the form the plugin-directory catalog uses for monorepos.
+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 })
},
@@ -0,0 +1,166 @@
/**
* Custom date-range args on the report tools:
* gnubok_get_income_statement (from_date/to_date) and
* gnubok_get_balance_sheet (as_of_date). The generators are mocked; under
* test is the MCP layer: validation (format, inside-period, ordering), the
* options handoff, the effective-period echo, and the unknown-arg rejection
* that replaces the old silent ignoring.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { tools } from '../server'
vi.mock('@/lib/reports/income-statement', () => ({ generateIncomeStatement: vi.fn() }))
vi.mock('@/lib/reports/balance-sheet', () => ({ generateBalanceSheet: vi.fn() }))
const incomeStatement = tools.find((t) => t.name === 'gnubok_get_income_statement')!
const balanceSheet = tools.find((t) => t.name === 'gnubok_get_balance_sheet')!
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
const mockBalanceSheet = vi.mocked(generateBalanceSheet)
const PERIOD_ROW = {
id: 'fp-1',
name: '2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('gnubok_get_income_statement: from_date/to_date', () => {
it('passes the range to the generator and echoes the effective window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null }) // period info
mockIncomeStatement.mockResolvedValueOnce({ net_result: 42 } as never)
const result = (await incomeStatement.execute(
{ period_id: 'fp-1', from_date: '2026-01-01', to_date: '2026-07-31' },
'company-1',
'user-1',
supabase as never,
)) as { period: { start: string; end: string } }
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
fromDate: '2026-01-01',
toDate: '2026-07-31',
})
expect(result.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
})
it('rejects a malformed from_date instead of silently ignoring it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', from_date: '31/07/2026' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/from_date must be an ISO date/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('rejects a range outside the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', to_date: '2027-01-31' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/within the fiscal period/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('rejects from_date after to_date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', from_date: '2026-08-01', to_date: '2026-07-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/must not be after/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('rejects unknown args instead of silently ignoring them', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', fromdate: '2026-01-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Unknown parameter\(s\): fromdate/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
})
describe('gnubok_get_balance_sheet: as_of_date', () => {
it('maps as_of_date to the generator toDate and echoes the effective window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null }) // period info (most-recent lookup skipped: period_id given)
mockBalanceSheet.mockResolvedValueOnce({ total_assets: 0, total_equity_liabilities: 0 } as never)
const result = (await balanceSheet.execute(
{ period_id: 'fp-1', as_of_date: '2026-07-31' },
'company-1',
'user-1',
supabase as never,
)) as { period: { start: string; end: string } }
expect(mockBalanceSheet).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
toDate: '2026-07-31',
})
expect(result.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
})
it('rejects an as_of_date outside the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
balanceSheet.execute(
{ period_id: 'fp-1', as_of_date: '2025-12-31' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/within the fiscal period/)
expect(mockBalanceSheet).not.toHaveBeenCalled()
})
it('rejects unknown args instead of silently ignoring them', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
balanceSheet.execute(
{ period_id: 'fp-1', to_date: '2026-07-31' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Unknown parameter\(s\): to_date/)
expect(mockBalanceSheet).not.toHaveBeenCalled()
})
})
+67 -6
View File
@@ -2671,6 +2671,49 @@ const REPORT_DIMENSIONS_FILTER_SCHEMA = {
description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. P&L view only: opening balances are excluded when set.',
} as const
// Optional custom date range on the report tools. Historically from_date /
// to_date were silently dropped (the inputSchema said additionalProperties:
// false but nothing enforced it), so an agent asking for January-July got the
// full fiscal year back with no signal. Validate loudly instead.
function parseReportRangeArgs(
args: Record<string, unknown>,
period: { period_start: string; period_end: string },
keys: { from?: string; to: string },
): { fromDate?: string; toDate?: string } {
const rawFrom = keys.from ? (args[keys.from] as string | undefined) : undefined
const rawTo = args[keys.to] as string | undefined
for (const [key, value] of [[keys.from, rawFrom], [keys.to, rawTo]] as const) {
if (value === undefined || key === undefined) continue
if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) {
throw new Error(`${key} must be an ISO date (YYYY-MM-DD).`)
}
if (value < period.period_start || value > period.period_end) {
throw new Error(
`${key} must be within the fiscal period (${period.period_start} to ${period.period_end}). ` +
`For another year, pass that year's period_id.`,
)
}
}
if (rawFrom && rawTo && rawFrom > rawTo) {
throw new Error(`${keys.from} must not be after ${keys.to}.`)
}
return { fromDate: rawFrom, toDate: rawTo }
}
// Reject unknown args on tools that opt in, instead of silently ignoring
// them: a misspelled parameter (fromdate=) must not degrade to a full-period
// report the agent mistakes for the range it asked for.
function rejectUnknownArgs(args: Record<string, unknown>, allowed: readonly string[]): void {
const unknown = Object.keys(args).filter((k) => !allowed.includes(k))
if (unknown.length > 0) {
throw new Error(
`Unknown parameter(s): ${unknown.join(', ')}. Allowed: ${allowed.join(', ')}. ` +
`Unknown parameters are rejected rather than silently ignored.`,
)
}
}
// Output-schema fragments for the echo fields (never in `required`).
const DIMENSION_FILTER_OUTPUT_PROPS = {
dimension_filter: {
@@ -6323,12 +6366,14 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_income_statement',
title: 'Income Statement (Resultaträkning)',
description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).',
description: 'Income statement (resultaträkning) for a fiscal period or a from_date/to_date range inside it: revenue, expenses, net result. Optional dimensions filter (kostnadsställe/projekt).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
from_date: { type: 'string', description: 'Start YYYY-MM-DD inside the period' },
to_date: { type: 'string', description: 'End YYYY-MM-DD inside the period' },
dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA,
},
},
@@ -6369,15 +6414,24 @@ export const tools: McpTool[] = [
if (!period) throw new Error('Fiscal period not found.')
rejectUnknownArgs(args, ['period_id', 'from_date', 'to_date', 'dimensions'])
const range = parseReportRangeArgs(args, period, { from: 'from_date', to: 'to_date' })
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
const result = await generateIncomeStatement(
supabase,
companyId,
periodId!,
dimFilter.filter ? { dimensions: dimFilter.filter } : undefined,
{
...range,
...(dimFilter.filter ? { dimensions: dimFilter.filter } : {}),
},
)
result.period = { start: period.period_start, end: period.period_end }
// Echo the effective range, not the fiscal-period bounds.
result.period = {
start: range.fromDate ?? period.period_start,
end: range.toDate ?? period.period_end,
}
return {
period_name: period.name,
@@ -8207,12 +8261,13 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_balance_sheet',
title: 'Balance Sheet (Balansräkning)',
description: 'Balance sheet (balansräkning) for a fiscal period: assets, equity, and liabilities sections with totals + balance check.',
description: 'Balance sheet (balansräkning) for a fiscal period or as of as_of_date: assets, equity, liabilities with totals + balance check.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default period end)' },
},
},
outputSchema: { type: 'object' },
@@ -8247,12 +8302,18 @@ export const tools: McpTool[] = [
if (!period) throw new Error('Fiscal period not found.')
const result = await generateBalanceSheet(supabase, companyId, periodId!)
rejectUnknownArgs(args, ['period_id', 'as_of_date'])
const range = parseReportRangeArgs(args, period, { to: 'as_of_date' })
const result = await generateBalanceSheet(supabase, companyId, periodId!, {
toDate: range.toDate,
})
return {
period_name: period.name,
...result,
period: { start: period.period_start, end: period.period_end },
// Echo the effective window: cumulative from period start to as_of_date.
period: { start: period.period_start, end: range.toDate ?? period.period_end },
}
},
},
@@ -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",
+2
View File
@@ -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'
+95
View File
@@ -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
+4
View File
@@ -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)
})
})
+5 -2
View File
@@ -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.' }
}
+194
View File
@@ -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))
}
+31 -6
View File
@@ -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,
+7 -5
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 136 endpoints.
pagination, scopes), and all 138 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -140,7 +140,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 136 operations. Paths are shown without
API version `2026-05-12`, 138 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (5)
@@ -326,17 +326,19 @@ POST /companies/{companyId}/salary-runs/{id}/mark-paid : Mark an approved salary
GET /companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf : Download one employee's payslip as PDF [scope:payroll:read risk:low idempotent]
```
### Reports (14)
### Reports (16)
Full detail: [references/reports.md](references/reports.md)
```text
GET /companies/{companyId}/reports/ar-ledger : AR ledger: unpaid customer invoices with aging [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/avgifter-basis : Annual arbetsgivaravgifter basis per employee [scope:payroll:read risk:low idempotent]
GET /companies/{companyId}/reports/balance-sheet : Balance sheet (balansräkning) for a fiscal period [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/balance-sheet : Balance sheet (balansräkning) for a fiscal period or as of a custom date [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/balance-sheet/pdf : Balance sheet (balansräkning) as a PDF [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/continuity-check : IB/UB continuity check: opening balances match prior closing [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/general-ledger : General ledger (huvudbok) for a fiscal period [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/income-statement : Income statement (resultatrapport) for a fiscal period [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/income-statement : Income statement (resultatrapport) for a fiscal period or a custom date range [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/income-statement/pdf : Income statement (resultaträkning) as a PDF [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/journal-register : Journal register (verifikationsregister) for a fiscal period [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/monthly-breakdown : Income statement broken down by month for a fiscal period [scope:reports:read risk:low idempotent]
GET /companies/{companyId}/reports/salary-journal : Salary journal (lönejournal) for a year and optional month range [scope:payroll:read risk:low idempotent]
+57 -8
View File
@@ -77,16 +77,17 @@ Response `200`:
### `GET /api/v1/companies/{companyId}/reports/balance-sheet`
**Balance sheet (balansräkning) for a fiscal period.**
**Balance sheet (balansräkning) for a fiscal period or as of a custom date.**
`scope:reports:read · risk:low · idempotent`
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.
**Use when:** You need the company's balance position at period end: typically for management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.
**Use when:** 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.
**Do not use for:** 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).
| Parameter | In | Type | Required | Notes |
@@ -109,6 +110,30 @@ Response `200`:
---
### `GET /api/v1/companies/{companyId}/reports/balance-sheet/pdf`
**Balance sheet (balansräkning) as a PDF.**
`scope:reports:read · risk:low · idempotent`
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.
**Use when:** You need a presentable PDF of the balance position at period end or a custom date: bank requests, board packs, or sharing outside Accounted.
**Do not use for:** 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
Response `200` (`application/pdf`).
---
### `GET /api/v1/companies/{companyId}/reports/continuity-check`
**IB/UB continuity check: opening balances match prior closing.**
@@ -180,16 +205,17 @@ Response `200`:
### `GET /api/v1/companies/{companyId}/reports/income-statement`
**Income statement (resultatrapport) for a fiscal period.**
**Income statement (resultatrapport) for a fiscal period or a custom date range.**
`scope:reports:read · risk:low · idempotent`
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.
**Use when:** You need the company's profit/loss for a period: month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.
**Use when:** 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.
**Do not use for:** 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.
| Parameter | In | Type | Required | Notes |
@@ -212,6 +238,29 @@ Response `200`:
---
### `GET /api/v1/companies/{companyId}/reports/income-statement/pdf`
**Income statement (resultaträkning) as a PDF.**
`scope:reports:read · risk:low · idempotent`
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).
**Use when:** You need a presentable PDF of the profit/loss for a period or partial period: bank requests, board packs, or sharing outside Accounted.
**Do not use for:** 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
Response `200` (`application/pdf`).
---
### `GET /api/v1/companies/{companyId}/reports/journal-register`
**Journal register (verifikationsregister) for a fiscal period.**