feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API (#1909)
* feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API
Requested by a v1/MCP user: the web UI can produce resultat- and
balansrapport for a custom period with PDF export, but REST v1 and the
MCP tools only served whole fiscal years and silently ignored
from_date/to_date.
- v1 income-statement: optional from_date/to_date (validated against the
fiscal period via the same parseReportDateRange the dashboard uses)
- v1 balance-sheet: same, plus as_of as the natural alias for to_date
(mutually exclusive with it)
- Unknown query params on these report routes now return
VALIDATION_ERROR with the unknown and allowed names instead of being
silently dropped (scoped to these routes, not a global v1 change)
- MCP gnubok_get_income_statement: from_date/to_date;
gnubok_get_balance_sheet: as_of_date; both validate format, in-period
and ordering, and reject unknown args (tools/list payload bench held
under the ceiling by trimming the same tools' descriptions)
- New v1 PDF endpoints reports/{income-statement,balance-sheet}/pdf,
byte-equivalent to the dashboard export: the K2/K3 grouping and the
balance gate moved to lib/reports/financial-statement-pdf.ts, shared
by both surfaces
- Both JSON endpoints echo the effective range in data.period
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): range semantics, empty-date validation, and review findings on PR #1909
Consolidated resolution of the skeptic refutations, CI failures, and
CodeRabbit findings:
- Ranged income statement summed closing balances, so from_date after
period start returned year-to-date figures mislabeled as the range
(July revenue reported as Jan-Jul on JSON, PDF, and MCP). The trial
balance rolls pre-range P&L activity into opening columns, so
generateIncomeStatement now builds from period movements whenever
fromDate is set, matching the resultatrapport convention. Full-period
behavior is unchanged; generator-level regression tests added.
- from_date dropped from the v1 balance-sheet routes (JSON + PDF): a
balansraking is a cumulative position, not a flow over a window
(ÅRL 3 kap); matches the MCP tool's as_of_date-only surface.
- Empty date values (from_date=) now fail validation instead of
silently producing a full-period report with an empty period echo
(null-check instead of truthiness in parseReportDateRange).
- dry_run, read by the withApiV1 wrapper on every request, is tolerated
by the strict param check instead of being rejected as unknown.
- Unbalanced balansrakning on the v1 PDF route returns 400 (caller-data
condition), matching the dashboard export, instead of 500.
- skills/accounted-api regenerated (apiskill:check gate).
- Removed the ISO_DATE_RE import that collided with the pre-existing
local declaration in the MCP server (TS2440 on core build).
- CodeRabbit: 401 tests for both PDF endpoints; event bus cleared in
the new MCP test's beforeEach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a83baede72
commit
85e039035d
@@ -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(),
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStatementSection, type FinancialStatementSummaryRow } from '@/lib/reports/financial-statement-pdf-template'
|
||||
import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
|
||||
import { buildIncomeStatementPdfModel } from '@/lib/reports/financial-statement-pdf'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8
|
||||
// into three named blocks with subtotals:
|
||||
// 80-84 → Finansiella poster (followed by "Resultat efter finansiella poster")
|
||||
// 88 → Bokslutsdispositioner
|
||||
// 89 → Skatt på årets resultat
|
||||
// The generator lumps these together under financial_sections, so we split
|
||||
// here by the first row's account prefix.
|
||||
const FINANSIELLA_POSTER_PREFIXES = ['80', '81', '82', '83', '84']
|
||||
const BOKSLUTSDISPOSITIONER_PREFIXES = ['88']
|
||||
const SKATT_PREFIXES = ['89']
|
||||
const KNOWN_CLASS_8_PREFIXES = [
|
||||
...FINANSIELLA_POSTER_PREFIXES,
|
||||
...BOKSLUTSDISPOSITIONER_PREFIXES,
|
||||
...SKATT_PREFIXES,
|
||||
]
|
||||
|
||||
function sectionPrefix(section: FinancialStatementSection, prefixes: string[]): boolean {
|
||||
if (section.rows.length === 0) return false
|
||||
const acc = section.rows[0].account_number
|
||||
return prefixes.some((p) => acc.startsWith(p))
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('report.income_statement.pdf', async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
@@ -84,115 +63,7 @@ export const GET = withRouteContext('report.income_statement.pdf', async (reques
|
||||
})
|
||||
report.period = { start: effectiveStart, end: effectiveEnd }
|
||||
|
||||
const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100
|
||||
|
||||
// Split class 8 into its three K2/K3 blocks plus a catch-all for any
|
||||
// prefix the generator emits but we haven't explicitly mapped. If a future
|
||||
// generator change adds sections for 85/86/87 or similar, this keeps them
|
||||
// visible and arithmetically accounted for rather than silently dropped.
|
||||
const finansiellaPosterSections = report.financial_sections.filter((s) =>
|
||||
sectionPrefix(s, FINANSIELLA_POSTER_PREFIXES),
|
||||
)
|
||||
const bokslutsdispositionerSections = report.financial_sections.filter((s) =>
|
||||
sectionPrefix(s, BOKSLUTSDISPOSITIONER_PREFIXES),
|
||||
)
|
||||
const skattSections = report.financial_sections.filter((s) =>
|
||||
sectionPrefix(s, SKATT_PREFIXES),
|
||||
)
|
||||
const ovrigaFinansiellaPosterSections = report.financial_sections.filter(
|
||||
(s) => !sectionPrefix(s, KNOWN_CLASS_8_PREFIXES),
|
||||
)
|
||||
|
||||
const totalFinansiellaPoster = Math.round(
|
||||
finansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
|
||||
) / 100
|
||||
const totalBokslutsdispositioner = Math.round(
|
||||
bokslutsdispositionerSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
|
||||
) / 100
|
||||
const totalSkatt = Math.round(
|
||||
skattSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
|
||||
) / 100
|
||||
const totalOvrigaFinansiellaPoster = Math.round(
|
||||
ovrigaFinansiellaPosterSections.reduce((sum, s) => sum + s.subtotal, 0) * 100,
|
||||
) / 100
|
||||
// Catch-all is treated as part of "finansiella poster" for the subtotal:
|
||||
// 85-87 accounts in BAS are financial-adjacent (not tax, not bokslut).
|
||||
const resultatEfterFinansiellaPoster = Math.round(
|
||||
(operatingResult + totalFinansiellaPoster + totalOvrigaFinansiellaPoster) * 100,
|
||||
) / 100
|
||||
|
||||
const groups: FinancialStatementGroup[] = [
|
||||
{
|
||||
heading: 'Rörelseintäkter',
|
||||
sections: report.revenue_sections,
|
||||
totalLabel: 'Summa rörelseintäkter',
|
||||
total: report.total_revenue,
|
||||
},
|
||||
{
|
||||
heading: 'Rörelsekostnader',
|
||||
sections: report.expense_sections,
|
||||
totalLabel: 'Summa rörelsekostnader',
|
||||
total: report.total_expenses,
|
||||
negate: true,
|
||||
},
|
||||
]
|
||||
|
||||
if (finansiellaPosterSections.length > 0) {
|
||||
groups.push({
|
||||
heading: 'Finansiella poster',
|
||||
sections: finansiellaPosterSections,
|
||||
totalLabel: 'Summa finansiella poster',
|
||||
total: totalFinansiellaPoster,
|
||||
})
|
||||
}
|
||||
if (ovrigaFinansiellaPosterSections.length > 0) {
|
||||
groups.push({
|
||||
heading: 'Övriga finansiella poster',
|
||||
sections: ovrigaFinansiellaPosterSections,
|
||||
totalLabel: 'Summa övriga finansiella poster',
|
||||
total: totalOvrigaFinansiellaPoster,
|
||||
})
|
||||
}
|
||||
if (bokslutsdispositionerSections.length > 0) {
|
||||
groups.push({
|
||||
heading: 'Bokslutsdispositioner',
|
||||
sections: bokslutsdispositionerSections,
|
||||
totalLabel: 'Summa bokslutsdispositioner',
|
||||
total: totalBokslutsdispositioner,
|
||||
})
|
||||
}
|
||||
if (skattSections.length > 0) {
|
||||
groups.push({
|
||||
heading: 'Skatter',
|
||||
sections: skattSections,
|
||||
totalLabel: 'Summa skatter',
|
||||
total: totalSkatt,
|
||||
})
|
||||
}
|
||||
|
||||
// K2/K3 uppställningsform (ÅRL bilaga 2) summary structure:
|
||||
// Rörelseresultat
|
||||
// Resultat efter finansiella poster (only if finansiella poster present)
|
||||
// Bokslutsdispositioner (only if present)
|
||||
// Skatt på årets resultat (always, so the reader can verify the tax calc)
|
||||
// Årets resultat
|
||||
const summary: FinancialStatementSummaryRow[] = [
|
||||
{ label: 'Rörelseresultat', amount: operatingResult },
|
||||
]
|
||||
if (
|
||||
finansiellaPosterSections.length > 0 ||
|
||||
ovrigaFinansiellaPosterSections.length > 0
|
||||
) {
|
||||
summary.push({
|
||||
label: 'Resultat efter finansiella poster',
|
||||
amount: resultatEfterFinansiellaPoster,
|
||||
})
|
||||
}
|
||||
if (bokslutsdispositionerSections.length > 0) {
|
||||
summary.push({ label: 'Bokslutsdispositioner', amount: totalBokslutsdispositioner })
|
||||
}
|
||||
summary.push({ label: 'Skatt på årets resultat', amount: totalSkatt })
|
||||
summary.push({ label: 'Årets resultat', amount: report.net_result, emphasis: true })
|
||||
const { groups, summary } = buildIncomeStatementPdfModel(report)
|
||||
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
FinancialStatementPDF({
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Route-layer tests for the v1 report PDF endpoints (income-statement/pdf,
|
||||
* balance-sheet/pdf). Rendering is mocked: what is under test is the route
|
||||
* contract: strict query params, range validation, company-settings guard,
|
||||
* the balance gate, and the binary response headers.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`reports pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
generateBalanceSheet: vi.fn(),
|
||||
generateIncomeStatement: vi.fn(),
|
||||
renderToBuffer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/balance-sheet', () => ({
|
||||
generateBalanceSheet: mocks.generateBalanceSheet,
|
||||
}))
|
||||
vi.mock('@/lib/reports/income-statement', () => ({
|
||||
generateIncomeStatement: mocks.generateIncomeStatement,
|
||||
}))
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: mocks.renderToBuffer,
|
||||
}))
|
||||
vi.mock('@/lib/reports/financial-statement-pdf-template', () => ({
|
||||
FinancialStatementPDF: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as incomeStatementPdf } from '../income-statement/pdf/route'
|
||||
import { GET as balanceSheetPdf } from '../balance-sheet/pdf/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const PERIOD_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
const PERIOD_ROW = {
|
||||
id: PERIOD_ID,
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
}
|
||||
|
||||
const INCOME_STATEMENT_REPORT = {
|
||||
revenue_sections: [],
|
||||
total_revenue: 100,
|
||||
expense_sections: [],
|
||||
total_expenses: 40,
|
||||
financial_sections: [],
|
||||
total_financial: 0,
|
||||
net_result: 60,
|
||||
period: { start: '2026-01-01', end: '2026-12-31' },
|
||||
}
|
||||
|
||||
const BALANCED_BALANCE_SHEET = {
|
||||
asset_sections: [],
|
||||
total_assets: 500,
|
||||
equity_liability_sections: [],
|
||||
total_equity_liabilities: 500,
|
||||
period: { start: '2026-01-01', end: '2026-12-31' },
|
||||
}
|
||||
|
||||
function makeReq(url: string): Request {
|
||||
return new Request(url, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
function mockClientWith(settings: TableResp = { data: { company_id: COMPANY_ID, company_name: 'Testbolaget AB' }, error: null }) {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
fiscal_periods: { data: PERIOD_ROW, error: null },
|
||||
company_settings: settings,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['reports:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mocks.renderToBuffer.mockResolvedValue(Buffer.from('%PDF-fixture'))
|
||||
})
|
||||
|
||||
describe('GET /reports/income-statement/pdf', () => {
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockClientWith()
|
||||
|
||||
const res = await incomeStatementPdf(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a PDF for a custom range with the range in the filename', async () => {
|
||||
mockClientWith()
|
||||
mocks.generateIncomeStatement.mockResolvedValue({ ...INCOME_STATEMENT_REPORT })
|
||||
|
||||
const res = await incomeStatementPdf(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}&from_date=2026-01-01&to_date=2026-07-31`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Content-Disposition')).toContain(
|
||||
'resultatrakning-2026-01-01--2026-07-31-utkast.pdf',
|
||||
)
|
||||
expect(mocks.generateIncomeStatement).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
{ fromDate: '2026-01-01', toDate: '2026-07-31' },
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown query parameters', async () => {
|
||||
mockClientWith()
|
||||
|
||||
const res = await incomeStatementPdf(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}&locale=sv`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.unknown_params).toEqual(['locale'])
|
||||
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when company settings are missing', async () => {
|
||||
mockClientWith({ data: null, error: null })
|
||||
|
||||
const res = await incomeStatementPdf(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement/pdf?period_id=${PERIOD_ID}`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /reports/balance-sheet/pdf', () => {
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockClientWith()
|
||||
|
||||
const res = await balanceSheetPdf(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet/pdf?period_id=${PERIOD_ID}`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the balance position as of a custom date', async () => {
|
||||
mockClientWith()
|
||||
mocks.generateBalanceSheet.mockResolvedValue({ ...BALANCED_BALANCE_SHEET })
|
||||
|
||||
const res = await balanceSheetPdf(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet/pdf?period_id=${PERIOD_ID}&as_of=2026-07-31`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Content-Disposition')).toContain(
|
||||
'balansrakning-2026-01-01--2026-07-31-utkast.pdf',
|
||||
)
|
||||
expect(mocks.generateBalanceSheet).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
{ fromDate: undefined, toDate: '2026-07-31' },
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to render an unbalanced balansräkning', async () => {
|
||||
mockClientWith()
|
||||
mocks.generateBalanceSheet.mockResolvedValue({
|
||||
...BALANCED_BALANCE_SHEET,
|
||||
total_equity_liabilities: 400,
|
||||
})
|
||||
|
||||
const res = await balanceSheetPdf(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet/pdf?period_id=${PERIOD_ID}`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
|
||||
expect(mocks.renderToBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -299,6 +299,103 @@ describe('GET /reports/balance-sheet', () => {
|
||||
const body = await res.json()
|
||||
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('maps as_of to the generator toDate and echoes the effective window', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
fiscal_periods: {
|
||||
data: {
|
||||
id: PERIOD_ID,
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
mocks.generateBalanceSheet.mockResolvedValue({ sections: [], totals: {} })
|
||||
|
||||
const res = await balanceSheet(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}&as_of=2026-07-31`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mocks.generateBalanceSheet).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
{ fromDate: undefined, toDate: '2026-07-31' },
|
||||
)
|
||||
const body = await res.json()
|
||||
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
|
||||
})
|
||||
|
||||
it('rejects from_date as an unknown parameter (a balance sheet is a position, not a flow)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
fiscal_periods: {
|
||||
data: {
|
||||
id: PERIOD_ID,
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await balanceSheet(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}&from_date=2026-07-01`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.unknown_params).toEqual(['from_date'])
|
||||
expect(mocks.generateBalanceSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when both as_of and to_date are passed', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
fiscal_periods: {
|
||||
data: {
|
||||
id: PERIOD_ID,
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await balanceSheet(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}&as_of=2026-07-31&to_date=2026-06-30`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.generateBalanceSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /reports/income-statement', () => {
|
||||
@@ -331,6 +428,141 @@ describe('GET /reports/income-statement', () => {
|
||||
const body = await res.json()
|
||||
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
function mockPeriodClient() {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
fiscal_periods: {
|
||||
data: {
|
||||
id: PERIOD_ID,
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('passes from_date/to_date to the generator and echoes the effective range', async () => {
|
||||
mockPeriodClient()
|
||||
mocks.generateIncomeStatement.mockResolvedValue({ sections: [], grossMargin: 0, netResult: 0 })
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=2026-01-01&to_date=2026-07-31`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mocks.generateIncomeStatement).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
PERIOD_ID,
|
||||
{ fromDate: '2026-01-01', toDate: '2026-07-31' },
|
||||
)
|
||||
const body = await res.json()
|
||||
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR for a malformed from_date', async () => {
|
||||
mockPeriodClient()
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=07%2F31%2F2026`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when the range is outside the fiscal period', async () => {
|
||||
mockPeriodClient()
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&to_date=2027-01-31`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when from_date is after to_date', async () => {
|
||||
mockPeriodClient()
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=2026-08-01&to_date=2026-07-01`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR for an empty from_date value', async () => {
|
||||
mockPeriodClient()
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&from_date=`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates the wrapper-level dry_run parameter', async () => {
|
||||
mockPeriodClient()
|
||||
mocks.generateIncomeStatement.mockResolvedValue({ sections: [], grossMargin: 0, netResult: 0 })
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&dry_run=true`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects unknown query parameters instead of silently ignoring them', async () => {
|
||||
mockPeriodClient()
|
||||
|
||||
const res = await incomeStatement(
|
||||
makeReq(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}&fromdate=2026-01-01`,
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.unknown_params).toEqual(['fromdate'])
|
||||
expect(mocks.generateIncomeStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /reports/sie-export', () => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/reports/balance-sheet/pdf
|
||||
*
|
||||
* Render the balansräkning as application/pdf, byte-equivalent to the
|
||||
* dashboard's PDF export. Supports the same optional `as_of` (alias for
|
||||
* `to_date`) / `from_date` / `to_date` range as the JSON endpoint, so an
|
||||
* agent can fetch the balance position at e.g. the latest month-end.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import {
|
||||
assertKnownQueryParams,
|
||||
loadPeriodFromQuery,
|
||||
loadRangeFromQuery,
|
||||
safeGenerate,
|
||||
} from '@/lib/api/v1/report-period'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
|
||||
import {
|
||||
buildBalanceSheetPdfModel,
|
||||
balanceSheetImbalanceKronor,
|
||||
} from '@/lib/reports/financial-statement-pdf'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
// No from_date: a balansräkning is a cumulative position, not a flow over a
|
||||
// window (see the JSON route). as_of is the natural spelling; to_date is
|
||||
// accepted as its synonym for consistency with the income statement.
|
||||
const ALLOWED_PARAMS = ['period_id', 'to_date', 'as_of'] as const
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'reports.balance-sheet.pdf',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/reports/balance-sheet/pdf',
|
||||
summary: 'Balance sheet (balansräkning) as a PDF.',
|
||||
description:
|
||||
'Renders the balansräkning as application/pdf, byte-equivalent to the dashboard export. Optional `as_of` (alias for `to_date`, YYYY-MM-DD inside the fiscal period) returns the balance position at that date, e.g. the latest month-end for bank reporting. Refuses to render when tillgångar and eget kapital + skulder differ by a full krona or more.',
|
||||
useWhen:
|
||||
'You need a presentable PDF of the balance position at period end or a custom date: bank requests, board packs, or sharing outside Accounted.',
|
||||
doNotUseFor:
|
||||
'Machine-readable figures (use the JSON endpoint without /pdf). The formal K2/K3 årsredovisning document (use the year-end flow).',
|
||||
pitfalls: [
|
||||
'`period_id` is required; `as_of` (alias: `to_date`, pass at most one) is optional and must lie within that fiscal period. `from_date` is not accepted: a balance sheet is a cumulative position, not a flow over a window.',
|
||||
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
|
||||
'An unbalanced balansräkning (>= 1 kr difference) returns REPORT_GENERATION_FAILED instead of a PDF: fix the imbalance first.',
|
||||
'The PDF is marked "utkast": it is a working report, not a fastställd årsredovisning.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
_note: 'Returns application/pdf binary stream.',
|
||||
},
|
||||
},
|
||||
scope: 'reports:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: {
|
||||
success: z.unknown(), // Marker: binary response, see contentType.
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'reports.balance-sheet.pdf',
|
||||
async (request, ctx) => {
|
||||
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
|
||||
if (!params.ok) return params.response
|
||||
|
||||
const period = await loadPeriodFromQuery(request, {
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId!,
|
||||
requestId: ctx.requestId,
|
||||
log: ctx.log,
|
||||
})
|
||||
if (!period.ok) return period.response
|
||||
|
||||
const rangeResult = await loadRangeFromQuery(request, period.period, ctx, { asOfAlias: true })
|
||||
if (!rangeResult.ok) return rangeResult.response
|
||||
const range = rangeResult.range
|
||||
|
||||
const { data: company, error: companyErr } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
|
||||
if (companyErr) {
|
||||
return v1ErrorResponse(companyErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!company) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'company_settings' },
|
||||
})
|
||||
}
|
||||
|
||||
const gen = await safeGenerate(
|
||||
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id, range),
|
||||
{ log: ctx.log, requestId: ctx.requestId, reportName: 'balance-sheet-pdf' },
|
||||
)
|
||||
if (!gen.ok) return gen.response
|
||||
|
||||
const report = gen.result
|
||||
report.period = {
|
||||
start: range.fromDate ?? period.period.period_start,
|
||||
end: range.toDate ?? period.period.period_end,
|
||||
}
|
||||
|
||||
// Same gate as the dashboard export: ÅRL 3 kap requires balansräkningen
|
||||
// to balance; a PDF of an unbalanced one would misrepresent the books.
|
||||
if (balanceSheetImbalanceKronor(report) >= 1) {
|
||||
// 400, matching the dashboard export: the imbalance is a condition in
|
||||
// the caller's books, not a server fault; a 5xx would put retry loops
|
||||
// and error monitoring on a request that will never succeed unchanged.
|
||||
return v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
status: 400,
|
||||
details: {
|
||||
report: 'balance-sheet-pdf',
|
||||
reason:
|
||||
'Balansräkningen balanserar inte (tillgångar och eget kapital + skulder skiljer sig med minst 1 kr). Åtgärda differensen innan du genererar PDF.',
|
||||
total_assets: report.total_assets,
|
||||
total_equity_liabilities: report.total_equity_liabilities,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let pdfBuffer: Buffer
|
||||
try {
|
||||
pdfBuffer = await renderToBuffer(
|
||||
FinancialStatementPDF({
|
||||
title: 'Balansräkning',
|
||||
groups: buildBalanceSheetPdfModel(report).groups,
|
||||
period: report.period,
|
||||
company: company as CompanySettings,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
ctx.log.error('reports.balance-sheet.pdf: render failed', err as Error, {
|
||||
companyId: ctx.companyId,
|
||||
periodId: period.period.id,
|
||||
})
|
||||
return v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { report: 'balance-sheet-pdf', reason: 'PDF rendering failed.' },
|
||||
})
|
||||
}
|
||||
|
||||
// "-utkast" suffix keeps the draft status visible even after the file
|
||||
// leaves the client: complements the in-document ÅRL 2:7 disclaimer.
|
||||
const filename = `balansrakning-${report.period.start}--${report.period.end}-utkast.pdf`
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': contentDisposition('attachment', filename),
|
||||
'Content-Length': String(pdfBuffer.length),
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Request-Id': ctx.requestId,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -10,9 +10,19 @@ import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
|
||||
import {
|
||||
assertKnownQueryParams,
|
||||
loadPeriodFromQuery,
|
||||
loadRangeFromQuery,
|
||||
safeGenerate,
|
||||
} from '@/lib/api/v1/report-period'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
|
||||
// No from_date here: a balansräkning is a cumulative position, not a flow
|
||||
// over a window; a from_date would only mislabel the echoed period. Matches
|
||||
// the MCP tool, which exposes as_of_date alone.
|
||||
const ALLOWED_PARAMS = ['period_id', 'to_date', 'as_of'] as const
|
||||
|
||||
// Use z.unknown for the rich nested shape: the lib types are stable and
|
||||
// callers consume via `data.sections[…]`. Strict Zod schemas here would
|
||||
// require importing every BAS-section type, which adds maintenance with no
|
||||
@@ -23,15 +33,16 @@ registerEndpoint({
|
||||
operation: 'reports.balance-sheet',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/reports/balance-sheet',
|
||||
summary: 'Balance sheet (balansräkning) for a fiscal period.',
|
||||
summary: 'Balance sheet (balansräkning) for a fiscal period or as of a custom date.',
|
||||
description:
|
||||
'Returns assets / liabilities / equity grouped into BAS sections, with the period\'s opening and closing balances. Sums match the income statement for the same period; the closing equity flows into next period\'s opening balance.',
|
||||
'Returns assets / liabilities / equity grouped into BAS sections, with the period\'s opening and closing balances. Optional `as_of` (alias for `to_date`, YYYY-MM-DD inside the fiscal period) returns the balance position at that date, e.g. the latest month-end for bank reporting. Sums match the income statement for the same period; the closing equity flows into next period\'s opening balance.',
|
||||
useWhen:
|
||||
'You need the company\'s balance position at period end: typically for management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.',
|
||||
'You need the company\'s balance position at period end or at a custom date: typically management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.',
|
||||
doNotUseFor:
|
||||
'Per-account drill-down (use /reports/general-ledger). Net result for the period (use /reports/income-statement).',
|
||||
pitfalls: [
|
||||
'`period_id` is required.',
|
||||
'`period_id` is required; `as_of` (alias: `to_date`, pass at most one) is optional and must lie within that fiscal period. `from_date` is not accepted: a balance sheet is a cumulative position, not a flow over a window.',
|
||||
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
|
||||
'Balance sheet equity includes the period\'s computed result: recalculation happens on every call, so a freshly-posted entry is reflected immediately (no caching).',
|
||||
],
|
||||
example: {
|
||||
@@ -55,6 +66,9 @@ registerEndpoint({
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'reports.balance-sheet',
|
||||
async (request, ctx) => {
|
||||
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
|
||||
if (!params.ok) return params.response
|
||||
|
||||
const period = await loadPeriodFromQuery(request, {
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId!,
|
||||
@@ -63,8 +77,12 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
})
|
||||
if (!period.ok) return period.response
|
||||
|
||||
const rangeResult = await loadRangeFromQuery(request, period.period, ctx, { asOfAlias: true })
|
||||
if (!rangeResult.ok) return rangeResult.response
|
||||
const range = rangeResult.range
|
||||
|
||||
const gen = await safeGenerate(
|
||||
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id),
|
||||
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id, range),
|
||||
{ log: ctx.log, requestId: ctx.requestId, reportName: 'balance-sheet' },
|
||||
)
|
||||
if (!gen.ok) return gen.response
|
||||
@@ -73,9 +91,12 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
// The cast through `unknown` is the standard pattern for adding an
|
||||
// ad-hoc field to a structurally-typed lib return (BalanceSheetReport
|
||||
// doesn't formally include `period`, but the dashboard's behavior
|
||||
// attaches it).
|
||||
// attaches it). Echo the effective range, not the fiscal-period bounds.
|
||||
const result = gen.result as unknown as Record<string, unknown>
|
||||
result.period = { start: period.period.period_start, end: period.period.period_end }
|
||||
result.period = {
|
||||
start: range.fromDate ?? period.period.period_start,
|
||||
end: range.toDate ?? period.period.period_end,
|
||||
}
|
||||
|
||||
return ok(result, { requestId: ctx.requestId })
|
||||
},
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/reports/income-statement/pdf
|
||||
*
|
||||
* Render the resultaträkning as application/pdf, byte-equivalent to the
|
||||
* dashboard's PDF export. Supports the same optional `from_date` / `to_date`
|
||||
* range as the JSON endpoint, so an agent can fetch e.g. a January-July
|
||||
* report for bank requests without touching the web UI.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import {
|
||||
assertKnownQueryParams,
|
||||
loadPeriodFromQuery,
|
||||
loadRangeFromQuery,
|
||||
safeGenerate,
|
||||
} from '@/lib/api/v1/report-period'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template'
|
||||
import { buildIncomeStatementPdfModel } from '@/lib/reports/financial-statement-pdf'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
const ALLOWED_PARAMS = ['period_id', 'from_date', 'to_date'] as const
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'reports.income-statement.pdf',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/reports/income-statement/pdf',
|
||||
summary: 'Income statement (resultaträkning) as a PDF.',
|
||||
description:
|
||||
'Renders the resultaträkning as application/pdf, byte-equivalent to the dashboard export. Optional `from_date` / `to_date` (YYYY-MM-DD, inside the fiscal period) narrow the report to a custom range. The filename carries the effective date range and an "utkast" suffix (the document is a working report, not a signed årsredovisning).',
|
||||
useWhen:
|
||||
'You need a presentable PDF of the profit/loss for a period or partial period: bank requests, board packs, or sharing outside Accounted.',
|
||||
doNotUseFor:
|
||||
'Machine-readable figures (use the JSON endpoint without /pdf). The formal K2/K3 årsredovisning document (use the year-end flow).',
|
||||
pitfalls: [
|
||||
'`period_id` is required; `from_date`/`to_date` are optional and must lie within that fiscal period.',
|
||||
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
|
||||
'The PDF is marked "utkast": it is a working report, not a fastställd årsredovisning.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
_note: 'Returns application/pdf binary stream.',
|
||||
},
|
||||
},
|
||||
scope: 'reports:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: {
|
||||
success: z.unknown(), // Marker: binary response, see contentType.
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'reports.income-statement.pdf',
|
||||
async (request, ctx) => {
|
||||
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
|
||||
if (!params.ok) return params.response
|
||||
|
||||
const period = await loadPeriodFromQuery(request, {
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId!,
|
||||
requestId: ctx.requestId,
|
||||
log: ctx.log,
|
||||
})
|
||||
if (!period.ok) return period.response
|
||||
|
||||
const rangeResult = await loadRangeFromQuery(request, period.period, ctx)
|
||||
if (!rangeResult.ok) return rangeResult.response
|
||||
const range = rangeResult.range
|
||||
|
||||
const { data: company, error: companyErr } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
|
||||
if (companyErr) {
|
||||
return v1ErrorResponse(companyErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!company) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'company_settings' },
|
||||
})
|
||||
}
|
||||
|
||||
const gen = await safeGenerate(
|
||||
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id, range),
|
||||
{ log: ctx.log, requestId: ctx.requestId, reportName: 'income-statement-pdf' },
|
||||
)
|
||||
if (!gen.ok) return gen.response
|
||||
|
||||
const report = gen.result
|
||||
report.period = {
|
||||
start: range.fromDate ?? period.period.period_start,
|
||||
end: range.toDate ?? period.period.period_end,
|
||||
}
|
||||
|
||||
const { groups, summary } = buildIncomeStatementPdfModel(report)
|
||||
|
||||
let pdfBuffer: Buffer
|
||||
try {
|
||||
pdfBuffer = await renderToBuffer(
|
||||
FinancialStatementPDF({
|
||||
title: 'Resultaträkning',
|
||||
groups,
|
||||
summary,
|
||||
period: report.period,
|
||||
company: company as CompanySettings,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
ctx.log.error('reports.income-statement.pdf: render failed', err as Error, {
|
||||
companyId: ctx.companyId,
|
||||
periodId: period.period.id,
|
||||
})
|
||||
return v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { report: 'income-statement-pdf', reason: 'PDF rendering failed.' },
|
||||
})
|
||||
}
|
||||
|
||||
// "-utkast" suffix keeps the draft status visible even after the file
|
||||
// leaves the client: complements the in-document ÅRL 2:7 disclaimer.
|
||||
const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf`
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': contentDisposition('attachment', filename),
|
||||
'Content-Length': String(pdfBuffer.length),
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Request-Id': ctx.requestId,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -10,24 +10,32 @@ import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
|
||||
import {
|
||||
assertKnownQueryParams,
|
||||
loadPeriodFromQuery,
|
||||
loadRangeFromQuery,
|
||||
safeGenerate,
|
||||
} from '@/lib/api/v1/report-period'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
|
||||
const ALLOWED_PARAMS = ['period_id', 'from_date', 'to_date'] as const
|
||||
|
||||
const IncomeStatementResponse = z.unknown()
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'reports.income-statement',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/reports/income-statement',
|
||||
summary: 'Income statement (resultatrapport) for a fiscal period.',
|
||||
summary: 'Income statement (resultatrapport) for a fiscal period or a custom date range.',
|
||||
description:
|
||||
'Returns the period\'s revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). The net result flows into the balance-sheet equity for the same period.',
|
||||
'Returns the period\'s revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). Optional `from_date` / `to_date` (YYYY-MM-DD, inside the fiscal period) narrow the report to a custom range, e.g. January 1 to July 31 for month-end bank reporting. The net result flows into the balance-sheet equity for the same period.',
|
||||
useWhen:
|
||||
'You need the company\'s profit/loss for a period: month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.',
|
||||
'You need the company\'s profit/loss for a period or partial period: month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.',
|
||||
doNotUseFor:
|
||||
'Per-account drill (use /reports/general-ledger). VAT figures (use /reports/vat-declaration). Balance position (use /reports/balance-sheet).',
|
||||
pitfalls: [
|
||||
'`period_id` is required.',
|
||||
'`period_id` is required; `from_date`/`to_date` are optional and must lie within that fiscal period.',
|
||||
'Unknown query parameters are rejected with VALIDATION_ERROR, not silently ignored.',
|
||||
'Net result on the income statement equals the period\'s equity-line delta on the balance sheet: they\'re derived from the same posted entries.',
|
||||
],
|
||||
example: {
|
||||
@@ -47,6 +55,9 @@ registerEndpoint({
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'reports.income-statement',
|
||||
async (request, ctx) => {
|
||||
const params = await assertKnownQueryParams(request, ALLOWED_PARAMS, ctx)
|
||||
if (!params.ok) return params.response
|
||||
|
||||
const period = await loadPeriodFromQuery(request, {
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId!,
|
||||
@@ -55,14 +66,23 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
})
|
||||
if (!period.ok) return period.response
|
||||
|
||||
const rangeResult = await loadRangeFromQuery(request, period.period, ctx)
|
||||
if (!rangeResult.ok) return rangeResult.response
|
||||
const range = rangeResult.range
|
||||
|
||||
const gen = await safeGenerate(
|
||||
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id),
|
||||
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id, range),
|
||||
{ log: ctx.log, requestId: ctx.requestId, reportName: 'income-statement' },
|
||||
)
|
||||
if (!gen.ok) return gen.response
|
||||
|
||||
const result = gen.result as unknown as Record<string, unknown>
|
||||
result.period = { start: period.period.period_start, end: period.period.period_end }
|
||||
// Echo the effective range, not the fiscal-period bounds, so the caller
|
||||
// sees exactly which window the numbers cover.
|
||||
result.period = {
|
||||
start: range.fromDate ?? period.period.period_start,
|
||||
end: range.toDate ?? period.period.period_end,
|
||||
}
|
||||
|
||||
return ok(result, { requestId: ctx.requestId })
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user