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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -2671,6 +2671,49 @@ const REPORT_DIMENSIONS_FILTER_SCHEMA = {
|
||||
description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. P&L view only: opening balances are excluded when set.',
|
||||
} as const
|
||||
|
||||
// Optional custom date range on the report tools. Historically from_date /
|
||||
// to_date were silently dropped (the inputSchema said additionalProperties:
|
||||
// false but nothing enforced it), so an agent asking for January-July got the
|
||||
// full fiscal year back with no signal. Validate loudly instead.
|
||||
function parseReportRangeArgs(
|
||||
args: Record<string, unknown>,
|
||||
period: { period_start: string; period_end: string },
|
||||
keys: { from?: string; to: string },
|
||||
): { fromDate?: string; toDate?: string } {
|
||||
const rawFrom = keys.from ? (args[keys.from] as string | undefined) : undefined
|
||||
const rawTo = args[keys.to] as string | undefined
|
||||
|
||||
for (const [key, value] of [[keys.from, rawFrom], [keys.to, rawTo]] as const) {
|
||||
if (value === undefined || key === undefined) continue
|
||||
if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) {
|
||||
throw new Error(`${key} must be an ISO date (YYYY-MM-DD).`)
|
||||
}
|
||||
if (value < period.period_start || value > period.period_end) {
|
||||
throw new Error(
|
||||
`${key} must be within the fiscal period (${period.period_start} to ${period.period_end}). ` +
|
||||
`For another year, pass that year's period_id.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rawFrom && rawTo && rawFrom > rawTo) {
|
||||
throw new Error(`${keys.from} must not be after ${keys.to}.`)
|
||||
}
|
||||
return { fromDate: rawFrom, toDate: rawTo }
|
||||
}
|
||||
|
||||
// Reject unknown args on tools that opt in, instead of silently ignoring
|
||||
// them: a misspelled parameter (fromdate=) must not degrade to a full-period
|
||||
// report the agent mistakes for the range it asked for.
|
||||
function rejectUnknownArgs(args: Record<string, unknown>, allowed: readonly string[]): void {
|
||||
const unknown = Object.keys(args).filter((k) => !allowed.includes(k))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown parameter(s): ${unknown.join(', ')}. Allowed: ${allowed.join(', ')}. ` +
|
||||
`Unknown parameters are rejected rather than silently ignored.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Output-schema fragments for the echo fields (never in `required`).
|
||||
const DIMENSION_FILTER_OUTPUT_PROPS = {
|
||||
dimension_filter: {
|
||||
@@ -6323,12 +6366,14 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_income_statement',
|
||||
title: 'Income Statement (Resultaträkning)',
|
||||
description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).',
|
||||
description: 'Income statement (resultaträkning) for a fiscal period or a from_date/to_date range inside it: revenue, expenses, net result. Optional dimensions filter (kostnadsställe/projekt).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
from_date: { type: 'string', description: 'Start YYYY-MM-DD inside the period' },
|
||||
to_date: { type: 'string', description: 'End YYYY-MM-DD inside the period' },
|
||||
dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA,
|
||||
},
|
||||
},
|
||||
@@ -6369,15 +6414,24 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
rejectUnknownArgs(args, ['period_id', 'from_date', 'to_date', 'dimensions'])
|
||||
const range = parseReportRangeArgs(args, period, { from: 'from_date', to: 'to_date' })
|
||||
const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions)
|
||||
|
||||
const result = await generateIncomeStatement(
|
||||
supabase,
|
||||
companyId,
|
||||
periodId!,
|
||||
dimFilter.filter ? { dimensions: dimFilter.filter } : undefined,
|
||||
{
|
||||
...range,
|
||||
...(dimFilter.filter ? { dimensions: dimFilter.filter } : {}),
|
||||
},
|
||||
)
|
||||
result.period = { start: period.period_start, end: period.period_end }
|
||||
// Echo the effective range, not the fiscal-period bounds.
|
||||
result.period = {
|
||||
start: range.fromDate ?? period.period_start,
|
||||
end: range.toDate ?? period.period_end,
|
||||
}
|
||||
|
||||
return {
|
||||
period_name: period.name,
|
||||
@@ -8207,12 +8261,13 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_balance_sheet',
|
||||
title: 'Balance Sheet (Balansräkning)',
|
||||
description: 'Balance sheet (balansräkning) for a fiscal period: assets, equity, and liabilities sections with totals + balance check.',
|
||||
description: 'Balance sheet (balansräkning) for a fiscal period or as of as_of_date: assets, equity, liabilities with totals + balance check.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default period end)' },
|
||||
},
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
@@ -8247,12 +8302,18 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
const result = await generateBalanceSheet(supabase, companyId, periodId!)
|
||||
rejectUnknownArgs(args, ['period_id', 'as_of_date'])
|
||||
const range = parseReportRangeArgs(args, period, { to: 'as_of_date' })
|
||||
|
||||
const result = await generateBalanceSheet(supabase, companyId, periodId!, {
|
||||
toDate: range.toDate,
|
||||
})
|
||||
|
||||
return {
|
||||
period_name: period.name,
|
||||
...result,
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
// Echo the effective window: cumulative from period start to as_of_date.
|
||||
period: { start: period.period_start, end: range.toDate ?? period.period_end },
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user