From 85e039035df70fe9d3643744ec02502e333d28c0 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:34:21 +0200 Subject: [PATCH] feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + app/api/reports/balance-sheet/pdf/route.ts | 28 +- app/api/reports/income-statement/pdf/route.ts | 135 +-------- .../reports/__tests__/reports-pdf.test.ts | 283 ++++++++++++++++++ .../reports/__tests__/reports.test.ts | 232 ++++++++++++++ .../reports/balance-sheet/pdf/route.ts | 170 +++++++++++ .../reports/balance-sheet/route.ts | 37 ++- .../reports/income-statement/pdf/route.ts | 147 +++++++++ .../reports/income-statement/route.ts | 34 ++- .../__tests__/report-date-range.test.ts | 166 ++++++++++ extensions/general/mcp-server/server.ts | 73 ++++- .../__snapshots__/spec-snapshot.test.ts.snap | 4 +- lib/api/v1/load-routes.ts | 2 + lib/api/v1/report-period.ts | 95 ++++++ lib/auth/scopes.ts | 4 + .../__tests__/income-statement.test.ts | 64 ++++ lib/reports/date-range.ts | 7 +- lib/reports/financial-statement-pdf.ts | 194 ++++++++++++ lib/reports/income-statement.ts | 37 ++- skills/accounted-api/SKILL.md | 12 +- skills/accounted-api/references/reports.md | 65 +++- 21 files changed, 1591 insertions(+), 200 deletions(-) create mode 100644 app/api/v1/companies/[companyId]/reports/__tests__/reports-pdf.test.ts create mode 100644 app/api/v1/companies/[companyId]/reports/balance-sheet/pdf/route.ts create mode 100644 app/api/v1/companies/[companyId]/reports/income-statement/pdf/route.ts create mode 100644 extensions/general/mcp-server/__tests__/report-date-range.test.ts create mode 100644 lib/reports/financial-statement-pdf.ts diff --git a/DECISIONS.md b/DECISIONS.md index 899c084e..9042a11f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1238,5 +1238,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/reports/balance-sheet/pdf/route.ts b/app/api/reports/balance-sheet/pdf/route.ts index e79734ab..78d15e06 100644 --- a/app/api/reports/balance-sheet/pdf/route.ts +++ b/app/api/reports/balance-sheet/pdf/route.ts @@ -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(), diff --git a/app/api/reports/income-statement/pdf/route.ts b/app/api/reports/income-statement/pdf/route.ts index 9115f23a..25b4729c 100644 --- a/app/api/reports/income-statement/pdf/route.ts +++ b/app/api/reports/income-statement/pdf/route.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/reports/__tests__/reports-pdf.test.ts b/app/api/v1/companies/[companyId]/reports/__tests__/reports-pdf.test.ts new file mode 100644 index 00000000..678938d4 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/__tests__/reports-pdf.test.ts @@ -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('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@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 +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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() + }) +}) diff --git a/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts b/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts index 276e3b5f..2be33608 100644 --- a/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts +++ b/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts @@ -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', () => { diff --git a/app/api/v1/companies/[companyId]/reports/balance-sheet/pdf/route.ts b/app/api/v1/companies/[companyId]/reports/balance-sheet/pdf/route.ts new file mode 100644 index 00000000..194985fd --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/balance-sheet/pdf/route.ts @@ -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, + }, + }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts b/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts index 44aba94b..6e490c3e 100644 --- a/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts +++ b/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts @@ -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 - 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 }) }, diff --git a/app/api/v1/companies/[companyId]/reports/income-statement/pdf/route.ts b/app/api/v1/companies/[companyId]/reports/income-statement/pdf/route.ts new file mode 100644 index 00000000..8a561b4b --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/income-statement/pdf/route.ts @@ -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, + }, + }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/income-statement/route.ts b/app/api/v1/companies/[companyId]/reports/income-statement/route.ts index dd425806..3b0fe40f 100644 --- a/app/api/v1/companies/[companyId]/reports/income-statement/route.ts +++ b/app/api/v1/companies/[companyId]/reports/income-statement/route.ts @@ -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 - 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 }) }, diff --git a/extensions/general/mcp-server/__tests__/report-date-range.test.ts b/extensions/general/mcp-server/__tests__/report-date-range.test.ts new file mode 100644 index 00000000..191b9e81 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/report-date-range.test.ts @@ -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() + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 9b521cfe..f817f71e 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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, + 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, 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 }, } }, }, diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index c066a402..5295dbe6 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -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", diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 82fb25d3..c2aa41c0 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -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' diff --git a/lib/api/v1/report-period.ts b/lib/api/v1/report-period.ts index 66dadeba..4f50e174 100644 --- a/lib/api/v1/report-period.ts +++ b/lib/api/v1/report-period.ts @@ -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 { + 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 { + 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 diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 4b72398d..7e4c8a70 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -161,6 +161,10 @@ export const V1_ENDPOINT_SCOPES: Record = { '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', diff --git a/lib/reports/__tests__/income-statement.test.ts b/lib/reports/__tests__/income-statement.test.ts index a5e25300..211e7a94 100644 --- a/lib/reports/__tests__/income-statement.test.ts +++ b/lib/reports/__tests__/income-statement.test.ts @@ -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) + }) +}) diff --git a/lib/reports/date-range.ts b/lib/reports/date-range.ts index e9ef6983..3b67491c 100644 --- a/lib/reports/date-range.ts +++ b/lib/reports/date-range.ts @@ -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.' } } diff --git a/lib/reports/financial-statement-pdf.ts b/lib/reports/financial-statement-pdf.ts new file mode 100644 index 00000000..6463c1eb --- /dev/null +++ b/lib/reports/financial-statement-pdf.ts @@ -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)) +} diff --git a/lib/reports/income-statement.ts b/lib/reports/income-statement.ts index be959fb1..206f297d 100644 --- a/lib/reports/income-statement.ts +++ b/lib/reports/income-statement.ts @@ -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, 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, diff --git a/skills/accounted-api/SKILL.md b/skills/accounted-api/SKILL.md index 9ed75e26..a11ab554 100644 --- a/skills/accounted-api/SKILL.md +++ b/skills/accounted-api/SKILL.md @@ -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. --- @@ -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] diff --git a/skills/accounted-api/references/reports.md b/skills/accounted-api/references/reports.md index b482ab64..f877b185 100644 --- a/skills/accounted-api/references/reports.md +++ b/skills/accounted-api/references/reports.md @@ -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.**