From 01dbef40154b84955d1e647925fc91d53f35c70c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:20:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(dimensions):=20PR4=20reports=20=E2=80=94?= =?UTF-8?q?=20dimension-filtered=20P&L=20+=20Resultat=20per=20projekt/kost?= =?UTF-8?q?nadsst=C3=A4lle=20(#862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dimensions): PR4 reports — dimension-filtered P&L everywhere + Resultat per projekt/kostnadsställe The Project P&L milestone of the dimensions plan (dev_docs §7 PR4). One choke point lights up everything: generateTrialBalance gains options.dimensions (SIE dim → code map) pushed down as jsonb containment (dimensions @>, served by idx_jel_dimensions_gin) on both line queries, with company-wide opening balances dropped when filtered (they cannot be dimension-scoped; P&L-safe by whitelist). Resultatrapport, resultaträkning, huvudbok, monthly-breakdown and the TB drill-down inherit the filter; the KPI route filters only its P&L-side inputs (income statement, months, expense composition) — never cash/VAT. New report lib/reports/dimension-pnl.ts — "Resultat per projekt/ kostnadsställe" (Fortnox Resultatrapport projekt): value-as-column matrix over one dimension with an explicit "(Utan dimension)" bucket computed as the residual against the same trial-balance pass resultatrapport uses, so every row and the Totalt column reconcile with the unfiltered resultatrapport by construction. Registered in REPORT_CATALOG (visible only when dimensions_enabled), slug-routed view + xlsx export. UI: DimensionFilter (dimension + value picker, persistent "Filtrerad — ej fullständig rapport" chip) mounts in FocusedReport for catalog entries flagged dimensions: true; huvudbok rows show line dim codes. Statutory exclusion pinned by TEST, not convention: lib/reports/__tests__/dimension-statutory-guard.test.ts fails if the filter parser leaks into balance sheet, balansrapport, kassaflöde, VAT, SIE or full-archive routes/generators, or if the catalog whitelist widens. MCP: new gnubok_get_dimension_pnl (reports:read); dimensions filter arg on get_trial_balance/get_income_statement/get_general_ledger with resolve-don't-select (names → registry codes, resolution echoes); query_journal totals fixed to aggregate the FULL match set (was silently slice-scoped while claiming otherwise) with an honest totals_scope field, plus group_by / group_by_dimension aggregation. Also: voucher-detail dim-6 badge now uses the registry name instead of the non-standard "PR" abbreviation (#859 review follow-up). Co-Authored-By: Claude Fable 5 * fix(dimensions): address #862 review — export disclosure, prior-column suppression, period-label honesty, route hardening - Filtered XLSX/PDF exports now carry the partial-view disclosure past the file boundary (BFNAR 2013:2): filename suffix (-dim6-p001), a "Filtrerad … — ej fullständig rapport" row on every sheet, and a header note/title line in the PDFs. - Resultatrapport drops the prior-year column when a dimension filter is active — project codes are time-limited under K2/K3, so "this code last year" may be a different project (same rule as narrowed date ranges). - dimension-pnl no longer accepts fromDate: the matrix is cumulative from period_start by design (closing-balance semantics), and the period label now states exactly that instead of echoing a lower bound that was never applied. Routes/MCP tool updated to toDate-only. - dimension-pnl routes 404 on an unknown/foreign period id and cap dim_no to 4 digits (matching the MCP tool's PostgREST-path guard, which the generator now also enforces itself). - Statutory-guard test's generateTrialBalance call-site scan is paren-aware instead of a 300-char window; added fully-untagged and injection-guard test cases. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/bookkeeping/[id]/page.tsx | 9 +- app/(dashboard)/reports/page.tsx | 3 + app/api/reports/dimension-pnl/route.ts | 56 +++ app/api/reports/dimension-pnl/xlsx/route.ts | 132 ++++++ app/api/reports/general-ledger/route.ts | 10 +- app/api/reports/general-ledger/xlsx/route.ts | 30 +- app/api/reports/income-statement/pdf/route.ts | 18 +- app/api/reports/income-statement/route.ts | 11 +- .../reports/income-statement/xlsx/route.ts | 28 +- app/api/reports/kpi/route.ts | 25 +- app/api/reports/monthly-breakdown/route.ts | 10 +- app/api/reports/resultatrapport/pdf/route.ts | 15 +- app/api/reports/resultatrapport/route.ts | 11 +- app/api/reports/resultatrapport/xlsx/route.ts | 26 +- .../account/[accountNumber]/sources/route.ts | 28 +- components/reports/DimensionFilter.tsx | 125 +++++ components/reports/FocusedReport.tsx | 20 +- components/reports/ReportLibrary.tsx | 4 +- components/reports/views/index.tsx | 260 ++++++++++- .../__tests__/dimension-pnl-tool.test.ts | 146 ++++++ .../__tests__/payload-size.bench.test.ts | 9 +- .../__tests__/query-journal.test.ts | 151 +++++- .../__tests__/report-dimension-filter.test.ts | 194 ++++++++ extensions/general/mcp-server/server.ts | 431 ++++++++++++++++-- lib/auth/api-keys.ts | 1 + lib/reports/__tests__/dimension-pnl.test.ts | 251 ++++++++++ .../dimension-statutory-guard.test.ts | 123 +++++ .../trial-balance-dimensions.test.ts | 160 +++++++ lib/reports/catalog.ts | 45 +- lib/reports/dimension-filter.ts | 69 +++ lib/reports/dimension-pnl.ts | 260 +++++++++++ lib/reports/general-ledger.ts | 30 +- lib/reports/income-statement.ts | 8 +- lib/reports/monthly-breakdown.ts | 24 +- .../operational-report-pdf-template.tsx | 13 +- lib/reports/resultatrapport.ts | 14 +- lib/reports/source-lines.ts | 2 + lib/reports/trial-balance.ts | 34 +- messages/en.json | 2 + messages/sv.json | 2 + types/index.ts | 33 ++ 41 files changed, 2694 insertions(+), 129 deletions(-) create mode 100644 app/api/reports/dimension-pnl/route.ts create mode 100644 app/api/reports/dimension-pnl/xlsx/route.ts create mode 100644 components/reports/DimensionFilter.tsx create mode 100644 extensions/general/mcp-server/__tests__/dimension-pnl-tool.test.ts create mode 100644 extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts create mode 100644 lib/reports/__tests__/dimension-pnl.test.ts create mode 100644 lib/reports/__tests__/dimension-statutory-guard.test.ts create mode 100644 lib/reports/__tests__/trial-balance-dimensions.test.ts create mode 100644 lib/reports/dimension-filter.ts create mode 100644 lib/reports/dimension-pnl.ts diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 6a8ec432..aea16e04 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -272,9 +272,12 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i // Include current entry in the chain for the visualization const fullChain = [entry, ...chain] - // SIE dimension badge prefixes — statutory Swedish abbreviations - // (kostnadsställe/projekt); stays Swedish per .claude/rules/i18n.md. - const DIM_BADGE_PREFIX: Record = { '1': 'KS', '6': 'PR' } + // SIE dimension badge prefixes. 'KS' is the market-standard abbreviation + // for kostnadsställe; projekt has no standard abbreviation (Fortnox/Visma + // show the dimension name, and 'PR' collides with prisnivå in some BAS + // setups — flagged in the #859 compliance review), so dim 6 falls through + // to the registry name below. Stays Swedish per .claude/rules/i18n.md. + const DIM_BADGE_PREFIX: Record = { '1': 'KS' } // Display-only dimension badges for a line (e.g. 'KS: Butik', 'PR: P001'). // Names resolve through the registry when loaded; raw codes otherwise. diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 3d5f94f8..748bd367 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card' import { PageHeader } from '@/components/ui/page-header' import { EmptyState } from '@/components/ui/empty-state' import { useCompany } from '@/contexts/CompanyContext' +import { useCompanySettings } from '@/components/settings/useSettings' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { ReportLibrary } from '@/components/reports/ReportLibrary' import { RecentReportsShelf } from '@/components/reports/RecentReportsShelf' @@ -25,6 +26,7 @@ export default function ReportsPage() { const [selectedPeriod, setSelectedPeriod] = useState('') const [isLoadingInit, setIsLoadingInit] = useState(true) const { company } = useCompany() + const { settings } = useCompanySettings() const t = useTranslations('reports') const { recents, pushRecent } = useRecentReports(company?.id) @@ -85,6 +87,7 @@ export default function ReportsPage() { /> diff --git a/app/api/reports/dimension-pnl/route.ts b/app/api/reports/dimension-pnl/route.ts new file mode 100644 index 00000000..6a13b7d9 --- /dev/null +++ b/app/api/reports/dimension-pnl/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server' +import { generateDimensionPnl } from '@/lib/reports/dimension-pnl' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { parseReportDateRange } from '@/lib/reports/date-range' + +// Resultat per projekt/kostnadsställe — value-as-column P&L matrix over one +// SIE dimension. ?dim_no picks the dimension (default 6, projekt). +export const GET = withRouteContext( + 'report.dimension_pnl', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + const dimNo = searchParams.get('dim_no') ?? '6' + + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) + } + if (!/^[1-9]\d{0,3}$/.test(dimNo)) { + return NextResponse.json({ error: 'dim_no must be an SIE dimension number' }, { status: 400 }) + } + + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single() + + if (!period) { + return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) + } + + const parsed = parseReportDateRange(searchParams, period) + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }) + } + + try { + // Only toDate — the matrix is cumulative from period_start by design + // (closing-balance semantics; see lib/reports/dimension-pnl.ts). + const data = await generateDimensionPnl(supabase, companyId!, periodId, dimNo, { + toDate: parsed.range.toDate, + }) + return NextResponse.json({ data }) + } catch (err) { + log.error('dimension pnl generation failed', err as Error, { periodId, dimNo }) + return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/dimension-pnl/xlsx/route.ts b/app/api/reports/dimension-pnl/xlsx/route.ts new file mode 100644 index 00000000..eb7e659d --- /dev/null +++ b/app/api/reports/dimension-pnl/xlsx/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from 'next/server' +import { generateDimensionPnl } from '@/lib/reports/dimension-pnl' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { parseReportDateRange } from '@/lib/reports/date-range' +import { + reportToWorkbook, + textColumn, + currencyColumn, + xlsxFilename, +} from '@/lib/reports/xlsx-export' +import type { DimensionPnlReport } from '@/types' + +// One row per account; the dimension values are dynamic columns, exactly as +// the on-screen matrix renders. Column labels stay Swedish (report surface). +type FlatRow = { + group: string + account_number: string + account_name: string + values: number[] + total: number +} + +export const GET = withRouteContext( + 'report.dimension_pnl_xlsx', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + const dimNo = searchParams.get('dim_no') ?? '6' + + if (!periodId) { + return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) + } + if (!/^[1-9]\d{0,3}$/.test(dimNo)) { + return NextResponse.json({ error: 'dim_no must be an SIE dimension number' }, { status: 400 }) + } + + const [{ data: companyRow }, { data: period }] = await Promise.all([ + supabase + .from('company_settings') + .select('company_name') + .eq('company_id', companyId) + .single(), + supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single(), + ]) + + if (!period) { + return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) + } + + const parsed = parseReportDateRange(searchParams, period) + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }) + } + + try { + // Only toDate — the matrix is cumulative from period_start by design. + const report: DimensionPnlReport = await generateDimensionPnl( + supabase, + companyId!, + periodId, + dimNo, + { toDate: parsed.range.toDate }, + ) + + const valueHeaders = report.columns.map((c) => + c.code === null ? '(Utan dimension)' : c.name ? `${c.code} ${c.name}` : c.code, + ) + + const rows: FlatRow[] = [] + for (const g of report.groups) { + for (const r of g.rows) { + rows.push({ + group: g.class_label, + account_number: r.account_number, + account_name: r.account_name, + values: r.values, + total: r.total, + }) + } + } + rows.push({ + group: 'Resultat', + account_number: '', + account_name: 'Beräknat resultat', + values: report.net_per_column, + total: report.net_total, + }) + + const workbook = reportToWorkbook([ + { + name: `Resultat per ${report.dimension.name}`.slice(0, 31), + columns: [ + textColumn('Grupp'), + textColumn('Konto'), + textColumn('Benämning'), + ...valueHeaders.map((h) => currencyColumn(h)), + currencyColumn('Totalt'), + ], + rows, + mapRow: (r) => [r.group, r.account_number, r.account_name, ...r.values, r.total], + }, + ]) + + const filename = xlsxFilename( + 'resultat-per-dimension', + companyRow?.company_name ?? '', + report.period.end, + ) + + return new NextResponse(new Uint8Array(workbook), { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + log.error('dimension pnl xlsx failed', err as Error, { periodId, dimNo }) + return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts index d169b599..233e8cb3 100644 --- a/app/api/reports/general-ledger/route.ts +++ b/app/api/reports/general-ledger/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' export const GET = withRouteContext( 'report.general_ledger', @@ -17,8 +18,15 @@ export const GET = withRouteContext( return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) } + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo) + const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo, { + dimensions: dimFilter.dimensions, + }) return NextResponse.json({ data }) } catch (err) { log.error('general ledger generation failed', err as Error, { periodId }) diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts index 9ba4dc69..40f0f12c 100644 --- a/app/api/reports/general-ledger/xlsx/route.ts +++ b/app/api/reports/general-ledger/xlsx/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { requireCompanyId } from '@/lib/company/context' +import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' import { reportToWorkbook, textColumn, @@ -54,8 +55,15 @@ export async function GET(request: Request) { .eq('company_id', companyId) .single() + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo) + const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo, { + dimensions: dimFilter.dimensions, + }) // Flatten accounts + their lines into a single sheet. Each account contributes // an opening-balance row, its lines (with running balance), and a closing @@ -99,6 +107,24 @@ export async function GET(request: Request) { }) } + // Partial-view disclosure: a filtered huvudbok starts balance accounts + // at zero IB (opening balances cannot be dimension-scoped) — the export + // must say so or a project-filtered ledger reads as a full one. + const disclosure = dimensionFilterDisclosure(dimFilter.dimensions) + if (disclosure) { + rows.unshift({ + account_number: disclosure, + account_name: '', + date: null as unknown as Date, + voucher: '', + description: 'Ingående balanser ingår inte i filtrerad vy', + source_type: '', + debit: null as unknown as number, + credit: null as unknown as number, + balance: null as unknown as number, + }) + } + const buffer = reportToWorkbook([ { name: 'Huvudbok', @@ -128,7 +154,7 @@ export async function GET(request: Request) { }, ]) - const filename = xlsxFilename('huvudbok', companyRow?.company_name ?? '', report.period.end) + const filename = xlsxFilename(`huvudbok${dimensionFilterFileSuffix(dimFilter.dimensions)}`, companyRow?.company_name ?? '', report.period.end) return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', diff --git a/app/api/reports/income-statement/pdf/route.ts b/app/api/reports/income-statement/pdf/route.ts index dcb2bd77..646a1ad6 100644 --- a/app/api/reports/income-statement/pdf/route.ts +++ b/app/api/reports/income-statement/pdf/route.ts @@ -6,6 +6,7 @@ import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStat import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' +import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' // K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8 // into three named blocks with subtotals: @@ -80,8 +81,16 @@ export async function GET(request: Request) { const effectiveStart = range.fromDate ?? period.period_start const effectiveEnd = range.toDate ?? period.period_end + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const report = await generateIncomeStatement(supabase, companyId, periodId, range) + const report = await generateIncomeStatement(supabase, companyId, periodId, { + ...range, + dimensions: dimFilter.dimensions, + }) report.period = { start: effectiveStart, end: effectiveEnd } const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100 @@ -196,7 +205,10 @@ export async function GET(request: Request) { const pdfBuffer = await renderToBuffer( FinancialStatementPDF({ - title: 'Resultaträkning', + // Partial-view disclosure in the document title (BFNAR 2013:2). + title: dimensionFilterDisclosure(dimFilter.dimensions) + ? `Resultaträkning — ${dimensionFilterDisclosure(dimFilter.dimensions)}` + : 'Resultaträkning', groups, summary, period: report.period, @@ -207,7 +219,7 @@ export async function GET(request: Request) { // "-utkast" suffix keeps the draft status visible even after the file // leaves the browser — complements the in-document ÅRL 2:7 disclaimer. - const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf` + const filename = `resultatrakning${dimensionFilterFileSuffix(dimFilter.dimensions)}-${report.period.start}--${report.period.end}-utkast.pdf` return new Response(new Uint8Array(pdfBuffer), { headers: { diff --git a/app/api/reports/income-statement/route.ts b/app/api/reports/income-statement/route.ts index c582fe44..c5b020e5 100644 --- a/app/api/reports/income-statement/route.ts +++ b/app/api/reports/income-statement/route.ts @@ -3,6 +3,7 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { parseReportDateRange } from '@/lib/reports/date-range' +import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' export const GET = withRouteContext( 'report.income_statement', @@ -34,8 +35,16 @@ export const GET = withRouteContext( range = parsed.range } + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const result = await generateIncomeStatement(supabase, companyId!, periodId, range) + const result = await generateIncomeStatement(supabase, companyId!, periodId, { + ...range, + dimensions: dimFilter.dimensions, + }) if (period) { result.period = { diff --git a/app/api/reports/income-statement/xlsx/route.ts b/app/api/reports/income-statement/xlsx/route.ts index d46615c9..695533e8 100644 --- a/app/api/reports/income-statement/xlsx/route.ts +++ b/app/api/reports/income-statement/xlsx/route.ts @@ -10,6 +10,7 @@ import { xlsxFilename, } from '@/lib/reports/xlsx-export' import type { IncomeStatementSection } from '@/types' +import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' interface FlatRow { section: string @@ -92,8 +93,16 @@ export async function GET(request: Request) { const range = parsedRange.range const effectiveEnd = range.toDate ?? period.period_end + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const report = await generateIncomeStatement(supabase, companyId, periodId, range) + const report = await generateIncomeStatement(supabase, companyId, periodId, { + ...range, + dimensions: dimFilter.dimensions, + }) const revenueRows = flatten( report.revenue_sections, @@ -137,6 +146,21 @@ export async function GET(request: Request) { ] const mapRow = (r: FlatRow) => [r.section, r.account_number, r.account_name, r.amount] + // Partial-view disclosure on every sheet — any tab opened alone must + // still identify the export as filtered (BFNAR 2013:2). + const disclosure = dimensionFilterDisclosure(dimFilter.dimensions) + if (disclosure) { + const note: FlatRow = { + section: disclosure, + account_number: '', + account_name: '', + amount: null as unknown as number, + } + for (const sheetRows of [revenueRows, expenseRows, financialRows, summaryRows]) { + sheetRows.unshift(note) + } + } + const buffer = reportToWorkbook([ { name: 'Intäkter', columns, rows: revenueRows, mapRow }, { name: 'Kostnader', columns, rows: expenseRows, mapRow }, @@ -145,7 +169,7 @@ export async function GET(request: Request) { ]) const filename = xlsxFilename( - 'resultatrakning', + `resultatrakning${dimensionFilterFileSuffix(dimFilter.dimensions)}`, companyRow?.company_name ?? '', effectiveEnd, ) diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index b1833cb7..14563004 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -13,6 +13,7 @@ import { } from '@/lib/reports/kpi' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' import { requireCompanyId } from '@/lib/company/context' +import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { KPIReport, KPIPreferences } from '@/types' export async function GET(request: Request) { @@ -39,6 +40,17 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 }) } + // Dimension filter applies to the P&L-side KPIs only (net result, revenue/ + // expenses, months, expense composition). Balance-side KPIs (cash, VAT, + // receivables) and supplier/invoice aggregates stay company-wide — a + // dimension-scoped "cash position" would be silently wrong, not filtered. + // The KPI view hides those tiles when a filter is active. + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + const dimensions = dimFilter.dimensions + // Load user preferences for account overrides const { data: prefsData } = await supabase .from('extension_data') @@ -59,11 +71,12 @@ export async function GET(request: Request) { monthlyBreakdown, paidInvoicesResult, topSuppliersResult, + filteredTrialBalance, ] = await Promise.all([ - generateIncomeStatement(supabase, companyId, periodId), + generateIncomeStatement(supabase, companyId, periodId, { dimensions }), generateTrialBalance(supabase, companyId, periodId), generateARLedger(supabase, companyId), - generateMonthlyBreakdown(supabase, companyId, periodId), + generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }), supabase .from('invoices') .select('invoice_date, paid_at') @@ -77,6 +90,12 @@ export async function GET(request: Request) { .gte('invoice_date', period.period_start) .lte('invoice_date', period.period_end) .neq('status', 'credited'), + // Second, dimension-scoped TB only when filtered — feeds the expense + // composition (classes 4–7, P&L) without touching the unfiltered TB the + // balance-side KPIs read. + dimensions + ? generateTrialBalance(supabase, companyId, periodId, { dimensions }) + : Promise.resolve(null), ]) // Cash position — use account overrides if set @@ -109,7 +128,7 @@ export async function GET(request: Request) { // normal balance, so amount = closing_debit - closing_credit. Negative // values (rare reclassifications) are clamped to 0 so the donut renders // sensibly. - const expenseComposition = trialBalanceResult.rows.reduce( + const expenseComposition = (filteredTrialBalance ?? trialBalanceResult).rows.reduce( (acc, r) => { if (r.account_class < 4 || r.account_class > 7) return acc const amount = r.closing_debit - r.closing_credit diff --git a/app/api/reports/monthly-breakdown/route.ts b/app/api/reports/monthly-breakdown/route.ts index 295cc375..92a88772 100644 --- a/app/api/reports/monthly-breakdown/route.ts +++ b/app/api/reports/monthly-breakdown/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' import { requireCompanyId } from '@/lib/company/context' +import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' export async function GET(request: Request) { const supabase = await createClient() @@ -20,8 +21,15 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) } + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const data = await generateMonthlyBreakdown(supabase, companyId, periodId) + const data = await generateMonthlyBreakdown(supabase, companyId, periodId, { + dimensions: dimFilter.dimensions, + }) return NextResponse.json({ data }) } catch { return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 }) diff --git a/app/api/reports/resultatrapport/pdf/route.ts b/app/api/reports/resultatrapport/pdf/route.ts index 165c5824..015a1bf4 100644 --- a/app/api/reports/resultatrapport/pdf/route.ts +++ b/app/api/reports/resultatrapport/pdf/route.ts @@ -6,6 +6,7 @@ import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-templat import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' +import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' export async function GET(request: Request) { const supabase = await createClient() @@ -55,18 +56,28 @@ export async function GET(request: Request) { return NextResponse.json({ error: parsedRange.error }, { status: 400 }) } + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const report = await generateResultatrapport(supabase, companyId, periodId, parsedRange.range) + const report = await generateResultatrapport(supabase, companyId, periodId, { + ...parsedRange.range, + dimensions: dimFilter.dimensions, + }) const pdfBuffer = await renderToBuffer( ResultatrapportPDF({ report, company: companyRow as CompanySettings, generatedAt: new Date().toISOString(), + // Partial-view disclosure in the document header (BFNAR 2013:2). + filterNote: dimensionFilterDisclosure(dimFilter.dimensions) ?? undefined, }) ) - const filename = `resultatrapport-${report.period.start}--${report.period.end}.pdf` + const filename = `resultatrapport${dimensionFilterFileSuffix(dimFilter.dimensions)}-${report.period.start}--${report.period.end}.pdf` return new Response(new Uint8Array(pdfBuffer), { headers: { diff --git a/app/api/reports/resultatrapport/route.ts b/app/api/reports/resultatrapport/route.ts index e41a7b0b..0e28218e 100644 --- a/app/api/reports/resultatrapport/route.ts +++ b/app/api/reports/resultatrapport/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { generateResultatrapport } from '@/lib/reports/resultatrapport' import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' +import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' export async function GET(request: Request) { const supabase = await createClient() @@ -37,8 +38,16 @@ export async function GET(request: Request) { range = parsed.range } + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const result = await generateResultatrapport(supabase, companyId, periodId, range) + const result = await generateResultatrapport(supabase, companyId, periodId, { + ...range, + dimensions: dimFilter.dimensions, + }) return NextResponse.json({ data: result }) } catch (err) { return NextResponse.json( diff --git a/app/api/reports/resultatrapport/xlsx/route.ts b/app/api/reports/resultatrapport/xlsx/route.ts index c8984257..4546f31a 100644 --- a/app/api/reports/resultatrapport/xlsx/route.ts +++ b/app/api/reports/resultatrapport/xlsx/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { generateResultatrapport } from '@/lib/reports/resultatrapport' import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' +import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' import { reportToWorkbook, textColumn, @@ -58,8 +59,16 @@ export async function GET(request: Request) { range = parsed.range } + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + try { - const report = await generateResultatrapport(supabase, companyId, periodId, range) + const report = await generateResultatrapport(supabase, companyId, periodId, { + ...range, + dimensions: dimFilter.dimensions, + }) const rows: FlatRow[] = [] for (const g of report.groups) { @@ -88,6 +97,19 @@ export async function GET(request: Request) { prior_period: report.net_result_prior, }) + // Partial-view disclosure survives the file boundary: a filtered export + // must never be mistakable for the authoritative report (BFNAR 2013:2). + const disclosure = dimensionFilterDisclosure(dimFilter.dimensions) + if (disclosure) { + rows.unshift({ + group: disclosure, + account_number: '', + account_name: '', + current_period: null as unknown as number, + prior_period: null as unknown as number, + }) + } + const buffer = reportToWorkbook([ { name: 'Resultatrapport', @@ -110,7 +132,7 @@ export async function GET(request: Request) { ]) const filename = xlsxFilename( - 'resultatrapport', + `resultatrapport${dimensionFilterFileSuffix(dimFilter.dimensions)}`, companyRow?.company_name ?? '', report.period.end, ) diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts index 73b2e476..f3fa6d2f 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { requireCompanyId } from '@/lib/company/context' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { ReportSourceLine } from '@/lib/reports/source-lines' /** @@ -41,6 +42,14 @@ export async function GET( ) } + // Same filter as the parent report, so drill-down totals match the row the + // user expanded (a filtered resultatrapport row must not expand to + // unfiltered lines). + const dimFilter = parseDimensionFilterParams(searchParams) + if (!dimFilter.ok) { + return NextResponse.json({ error: dimFilter.error }, { status: 400 }) + } + // Look up account name (and verify account belongs to the company) const { data: account } = await supabase .from('chart_of_accounts') @@ -85,15 +94,17 @@ export async function GET( id: string debit_amount: number credit_amount: number + dimensions: Record | null // eslint-disable-next-line @typescript-eslint/no-explicit-any journal_entries: any - }>(({ from, to }) => - supabase + }>(({ from, to }) => { + let query = supabase .from('journal_entry_lines') .select(` id, debit_amount, credit_amount, + dimensions, journal_entry_id, journal_entries!inner( id, @@ -110,8 +121,14 @@ export async function GET( .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .in('journal_entries.status', ['posted', 'reversed']) - .order('id', { ascending: true }) - .range(from, to), { dedupeBy: (r) => r.id }) + + if (dimFilter.dimensions) { + // jsonb containment (@>) — served by idx_jel_dimensions_gin. + query = query.contains('dimensions', dimFilter.dimensions) + } + + return query.order('id', { ascending: true }).range(from, to) + }, { dedupeBy: (r) => r.id }) // Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as // a final deterministic tiebreak for lines sharing a date and voucher number @@ -124,6 +141,9 @@ export async function GET( description: row.journal_entries.description || '', debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100, credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100, + ...(row.dimensions && Object.keys(row.dimensions).length > 0 + ? { dimensions: row.dimensions } + : {}), })) allMapped.sort((a, b) => { const dateComp = a.date.localeCompare(b.date) diff --git a/components/reports/DimensionFilter.tsx b/components/reports/DimensionFilter.tsx new file mode 100644 index 00000000..8b61e47b --- /dev/null +++ b/components/reports/DimensionFilter.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useEffect, useState } from 'react' +import { X } from 'lucide-react' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import DimensionCombobox from '@/components/dimensions/DimensionCombobox' +import { useCompanySettings } from '@/components/settings/useSettings' +import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types' + +export type DimensionFilterValue = { + /** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */ + dimNo: string + /** Selected object code. */ + code: string +} + +interface Props { + value: DimensionFilterValue | null + onChange: (next: DimensionFilterValue | null) => void +} + +/** + * Per-dimension value filter for the P&L-safe reports (resultatrapport, + * resultaträkning, huvudbok, KPI). Mounted by FocusedReport next to + * ReportDateRange, only for catalog entries flagged `dimensions: true`. + * + * Renders nothing unless company_settings.dimensions_enabled — companies + * that never activated dimensions see literally nothing changed. + * + * When active it shows a persistent "Filtrerad … — ej fullständig rapport" + * chip: a dimension-scoped view is a partial view and must never be read as + * the complete report. Strings are hardcoded Swedish per the report-surface + * convention (same as DimensionCombobox). + */ +export function DimensionFilter({ value, onChange }: Props) { + const { settings } = useCompanySettings() + const [dims, setDims] = useState([]) + // Which dimension the picker targets while no value is selected yet; + // once a value is picked, `value.dimNo` is the source of truth. + const [pendingDimNo, setPendingDimNo] = useState('6') + const enabled = settings?.dimensions_enabled === true + + useEffect(() => { + if (!enabled) return + let cancelled = false + fetchDimensions() + .then((rows) => { + if (!cancelled) setDims(rows) + }) + .catch(() => { + // Best-effort: without the registry the filter simply doesn't render. + }) + return () => { + cancelled = true + } + }, [enabled]) + + if (!enabled || dims.length === 0) return null + + const activeDimNo = value?.dimNo ?? pendingDimNo + const activeDim = dims.find((d) => String(d.sie_dim_no) === activeDimNo) + + return ( +
+ +
+ +
+ + onChange(code ? { dimNo: activeDimNo, code } : null) + } + /> +
+ {value && ( + + )} +
+ {value && ( + + Filtrerad: {activeDim?.name ?? `Dim ${value.dimNo}`} {value.code} — ej fullständig rapport + + )} +
+ ) +} diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 9f2b0c47..d714ce0e 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -12,7 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton' import { useCompany } from '@/contexts/CompanyContext' import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange' -import { DATE_RANGE_SLUGS, getReport } from '@/lib/reports/catalog' +import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter' +import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog' import { NEDeclarationView } from '@/components/reports/NEDeclarationView' import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView' import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView' @@ -28,6 +29,7 @@ import { GeneralLedgerView, JournalRegisterView, ARLedgerView, + DimensionPnlView, } from '@/components/reports/views' /** @@ -46,6 +48,7 @@ function FocusedReportInner({ slug }: { slug: string }) { const [selectedPeriod, setSelectedPeriod] = useState('') const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null) const [dateRange, setDateRange] = useState({}) + const [dimensionFilter, setDimensionFilter] = useState(null) const [isReady, setIsReady] = useState(false) const report = getReport(slug) @@ -101,6 +104,10 @@ function FocusedReportInner({ slug }: { slug: string }) { /> )} + {DIMENSION_FILTER_SLUGS.has(slug) && selectedPeriod && ( + + )} + {!isReady && !isPeriodless ? ( @@ -114,6 +121,7 @@ function FocusedReportInner({ slug }: { slug: string }) { periodId={selectedPeriod} periodBounds={selectedPeriodBounds} dateRange={dateRange} + dimensionFilter={dimensionFilter} accountFilter={accountFilter} isEnskildFirma={isEnskildFirma} isAktiebolag={isAktiebolag} @@ -136,6 +144,7 @@ function FocusedView({ periodId, periodBounds, dateRange, + dimensionFilter, accountFilter, isEnskildFirma, isAktiebolag, @@ -145,6 +154,7 @@ function FocusedView({ periodId: string periodBounds: { start: string; end: string } | null dateRange: DateRangeValue + dimensionFilter: DimensionFilterValue | null accountFilter: string | null isEnskildFirma: boolean isAktiebolag: boolean @@ -152,13 +162,15 @@ function FocusedView({ }) { switch (slug) { case 'resultatrapport': - return + return + case 'dimension-pnl': + return case 'balansrapport': return case 'trial-balance': return case 'income-statement': - return + return case 'balance-sheet': return case 'vat-declaration': @@ -170,7 +182,7 @@ function FocusedView({ case 'ink2-declaration': return isAktiebolag ? : null case 'huvudbok': - return + return case 'grundbok': return case 'kundreskontra': diff --git a/components/reports/ReportLibrary.tsx b/components/reports/ReportLibrary.tsx index fb3b63ae..ae0f09db 100644 --- a/components/reports/ReportLibrary.tsx +++ b/components/reports/ReportLibrary.tsx @@ -20,14 +20,16 @@ import type { EntityType } from '@/types' export function ReportLibrary({ entityType, hasEmployees, + dimensionsEnabled, onOpen, }: { entityType?: EntityType hasEmployees?: boolean + dimensionsEnabled?: boolean onOpen: (slug: string) => void }) { const t = useTranslations('reports') - const sections = getLibrarySections(entityType, hasEmployees) + const sections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled) return (
diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 6aeeeda9..9f72dfef 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -29,12 +29,14 @@ import type { } from '@/lib/reports/source-lines' import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' import type { DateRangeValue } from '@/components/common/ReportDateRange' +import type { DimensionFilterValue } from '@/components/reports/DimensionFilter' import type { TrialBalanceRow, IncomeStatementReport, BalanceSheetReport, ResultatrapportReport, BalansrapportReport, + DimensionPnlReport, VatDeclaration, VatPeriodType, } from '@/types' @@ -43,10 +45,18 @@ function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -function reportQuery(periodId: string, range?: DateRangeValue): string { +function reportQuery( + periodId: string, + range?: DateRangeValue, + dimensionFilter?: DimensionFilterValue | null, +): string { const params = new URLSearchParams({ period_id: periodId }) if (range?.fromDate) params.set('from_date', range.fromDate) if (range?.toDate) params.set('to_date', range.toDate) + if (dimensionFilter) { + params.set('dim_no', dimensionFilter.dimNo) + params.set('dim_code', dimensionFilter.code) + } return params.toString() } @@ -369,13 +379,13 @@ function TrialBalanceDetailedRow({ ) } -export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { +export function IncomeStatementView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState(null) const [monthlyData, setMonthlyData] = useState([]) const [monthlyLoading, setMonthlyLoading] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const reportQs = reportQuery(periodId, dateRange) + const reportQs = reportQuery(periodId, dateRange, dimensionFilter) useEffect(() => { setLoading(true) @@ -399,7 +409,9 @@ export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount } // Monthly breakdown is full-period by design (it IS the per-month view), // so the date range only affects the headline numbers above the chart. - fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`) + // The dimension filter DOES apply — a dimension-scoped view must not + // silently chart company-wide months. + fetch(`/api/reports/monthly-breakdown?${reportQuery(periodId, undefined, dimensionFilter)}`) .then((res) => res.json()) .then((result) => { if (result.data?.months) { @@ -648,11 +660,11 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { ) } -export function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { +export function ResultatrapportView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const reportQs = reportQuery(periodId, dateRange) + const reportQs = reportQuery(periodId, dateRange, dimensionFilter) useEffect(() => { setLoading(true) @@ -1775,6 +1787,7 @@ interface GeneralLedgerData { debit: number credit: number balance: number + dimensions?: Record }[] closing_balance: number total_debit: number @@ -1783,7 +1796,7 @@ interface GeneralLedgerData { period: { start: string; end: string } } -export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: string; initialAccountFilter: string | null }) { +export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -1799,6 +1812,10 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId const params = new URLSearchParams({ period_id: periodId }) if (from) params.set('account_from', from) if (to) params.set('account_to', to) + if (dimensionFilter) { + params.set('dim_no', dimensionFilter.dimNo) + params.set('dim_code', dimensionFilter.code) + } const res = await fetch(`/api/reports/general-ledger?${params}`) const result = await res.json() if (result.error) { @@ -1811,7 +1828,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId } finally { setLoading(false) } - }, [periodId, accountFrom, accountTo]) + }, [periodId, accountFrom, accountTo, dimensionFilter]) // When initialAccountFilter changes (drill-down from another report), apply it useEffect(() => { @@ -1822,7 +1839,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId } else { fetchData() } - }, [periodId, initialAccountFilter]) + }, [periodId, initialAccountFilter, dimensionFilter]) if (loading) { return ( @@ -1857,7 +1874,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId return (
- + {/* Account range filter */} @@ -1931,7 +1948,17 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId {formatDate(line.date)} - {line.description} + + {line.description} + {line.dimensions && Object.keys(line.dimensions).length > 0 && ( + + {Object.entries(line.dimensions) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([, code]) => code) + .join(' · ')} + + )} + {line.debit > 0 ? formatAmount(line.debit) : ''} @@ -2505,3 +2532,214 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
) } + +// --- Resultat per projekt/kostnadsställe (dimension P&L matrix) --- + +export function DimensionPnlView({ periodId, dateRange }: { periodId: string; dateRange: DateRangeValue }) { + // Loading is DERIVED (result key ≠ current query string) instead of a + // setState at effect start — keeps react-hooks/set-state-in-effect clean + // and is race-safe when the pivot/date changes mid-flight. + const [result, setResult] = useState<{ + qs: string + data: DimensionPnlReport | null + error: string | null + } | null>(null) + const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([]) + const [dimNo, setDimNo] = useState('6') + const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}` + + // Registered dimensions for the pivot picker (best-effort; the report + // defaults to projekt if the registry read fails). + useEffect(() => { + fetch('/api/dimensions') + .then((res) => res.json()) + .then((payload) => { + if (Array.isArray(payload.data)) { + setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name }))) + } + }) + .catch(() => {}) + }, []) + + useEffect(() => { + let cancelled = false + fetch(`/api/reports/dimension-pnl?${reportQs}`) + .then((res) => res.json()) + .then((payload) => { + if (cancelled) return + if (payload.error) { + setResult({ + qs: reportQs, + data: null, + error: typeof payload.error === 'string' ? payload.error : 'Kunde inte hämta rapporten', + }) + } else { + setResult({ qs: reportQs, data: payload.data, error: null }) + } + }) + .catch(() => { + if (!cancelled) setResult({ qs: reportQs, data: null, error: 'Kunde inte hämta rapporten' }) + }) + return () => { + cancelled = true + } + }, [reportQs]) + + const loading = result?.qs !== reportQs + const error = loading ? null : result?.error ?? null + const data = loading ? null : result?.data ?? null + + const pivotPicker = dims.length > 1 && ( +
+ {dims.map((d) => { + const active = String(d.sie_dim_no) === dimNo + return ( + + ) + })} +
+ ) + + if (loading) { + return ( +
+ {pivotPicker} + + + Laddar rapport... + + +
+ ) + } + + if (error) { + return ( +
+ {pivotPicker} + + + + {error} + + +
+ ) + } + + if (!data || data.groups.length === 0) { + return ( +
+ {pivotPicker} + + + Inga taggade intäkter eller kostnader i denna period. + + +
+ ) + } + + const columnLabel = (c: DimensionPnlReport['columns'][number]) => + c.code === null ? '(Utan dimension)' : c.code + + const colCount = 2 + data.columns.length + 1 + + return ( +
+
+ {pivotPicker || } + +
+ + + +
+ + + + + + {data.columns.map((c, i) => ( + + ))} + + + + + {data.groups.map((group) => ( + + + + + {group.rows.map((row) => ( + + + + {row.values.map((v, i) => ( + + ))} + + + ))} + + + {group.subtotals.map((v, i) => ( + + ))} + + + + ))} + +
KontoKontonamn + {columnLabel(c)} + Totalt
+ {group.class_label} +
+ + {row.account_name} + {Math.abs(v) >= 0.005 ? formatAmount(v) : ''} + {formatAmount(row.total)}
+ Summa + {formatAmount(v)}{formatAmount(group.subtotal_total)}
+
+
+
+ + + +
+ + + + + + ))} + + + +
Beräknat resultat + {data.net_per_column.map((v, i) => ( + = 0 ? 'text-success' : 'text-destructive'}`}> + {formatAmount(v)} + = 0 ? 'text-success' : 'text-destructive'}`}> + {formatAmount(data.net_total)} kr +
+
+
+
+
+ ) +} diff --git a/extensions/general/mcp-server/__tests__/dimension-pnl-tool.test.ts b/extensions/general/mcp-server/__tests__/dimension-pnl-tool.test.ts new file mode 100644 index 00000000..891078ed --- /dev/null +++ b/extensions/general/mcp-server/__tests__/dimension-pnl-tool.test.ts @@ -0,0 +1,146 @@ +/** + * Dimensions PR4 — gnubok_get_dimension_pnl MCP surface tests. + * + * Covers registration (schema conventions + scope map) and the execute path: + * default-period lookup, explicit period + date-window passthrough, and the + * sie_dim_no guard. The matrix math itself is covered by the generator's own + * tests in lib/reports/__tests__/ — here generateDimensionPnl is mocked. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' +import { generateDimensionPnl } from '@/lib/reports/dimension-pnl' +import { tools } from '../server' + +vi.mock('@/lib/reports/dimension-pnl', () => ({ + generateDimensionPnl: vi.fn(), +})) + +const tool = tools.find((t) => t.name === 'gnubok_get_dimension_pnl')! +const mockGenerate = vi.mocked(generateDimensionPnl) + +function makeReport() { + return { + dimension: { sie_dim_no: '6', name: 'Projekt' }, + columns: [ + { code: 'P001', name: 'Villa Almgren takrenovering' }, + { code: null, name: null }, + ], + groups: [ + { + class: 3, + class_label: '3 Rörelsens inkomster/intäkter', + rows: [ + { account_number: '3010', account_name: 'Försäljning', values: [1000, 250], total: 1250 }, + ], + subtotals: [1000, 250], + subtotal_total: 1250, + }, + ], + net_per_column: [1000, 250], + net_total: 1250, + period: { start: '2026-01-01', end: '2026-12-31' }, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ── Registration ───────────────────────────────────────────────────────────── + +describe('gnubok_get_dimension_pnl — registration', () => { + it('is registered, read-only, and mapped to reports:read (unmapped = any key)', () => { + expect(tool).toBeDefined() + expect(tool.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }) + expect(TOOL_SCOPE_MAP.gnubok_get_dimension_pnl).toBe('reports:read') + }) + + it('declares a strict inputSchema and a tight description', () => { + const input = tool.inputSchema as { additionalProperties?: boolean; required?: string[] } + expect(input.additionalProperties).toBe(false) + expect(input.required).toEqual(['sie_dim_no']) + expect(tool.description.length).toBeLessThanOrEqual(280) + }) + + it('mirrors the DimensionPnlReport contract in its outputSchema', () => { + const output = tool.outputSchema as { required?: string[]; properties?: Record } + expect(output.required).toEqual(['dimension', 'columns', 'groups', 'net_per_column', 'net_total']) + expect(output.properties?.period).toBeDefined() + }) +}) + +// ── Execute ────────────────────────────────────────────────────────────────── + +describe('gnubok_get_dimension_pnl — execute', () => { + it('passes an explicit period + date window straight to the generator (no period lookup)', async () => { + const { supabase } = createQueuedMockSupabase() + const report = makeReport() + mockGenerate.mockResolvedValueOnce(report as never) + + const result = await tool.execute( + { sie_dim_no: '6', period_id: 'fp-1', to_date: '2026-03-31' }, + 'company-1', + 'user-1', + supabase as never, + ) + + expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', '6', { + toDate: '2026-03-31', + }) + expect(result).toEqual(report) + // Explicit period_id → no fiscal_periods default lookup. + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('defaults to the most recent fiscal period when period_id is omitted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'fp-latest', name: '2026' }, error: null }) // fiscal_periods lookup + mockGenerate.mockResolvedValueOnce(makeReport() as never) + + await tool.execute({ sie_dim_no: '1' }, 'company-1', 'user-1', supabase as never) + + expect(supabase.from).toHaveBeenCalledWith('fiscal_periods') + expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-latest', '1', { + toDate: undefined, + }) + }) + + it('errors when no fiscal period exists', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: null }) + + await expect( + tool.execute({ sie_dim_no: '6' }, 'company-1', 'user-1', supabase as never), + ).rejects.toThrow(/No fiscal periods found/) + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('rejects a non-numeric sie_dim_no before touching the database', async () => { + const { supabase } = createQueuedMockSupabase() + + for (const bad of ['projekt', '', '0', '6; drop']) { + await expect( + tool.execute({ sie_dim_no: bad }, 'company-1', 'user-1', supabase as never), + ).rejects.toThrow(/positive SIE dimension number/) + } + expect(supabase.from).not.toHaveBeenCalled() + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('accepts a numeric sie_dim_no by coercing it to string (lenient hosts)', async () => { + const { supabase } = createQueuedMockSupabase() + mockGenerate.mockResolvedValueOnce(makeReport() as never) + + await tool.execute({ sie_dim_no: 6, period_id: 'fp-1' }, 'company-1', 'user-1', supabase as never) + + expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', '6', { + toDate: undefined, + }) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index cb22341a..dec89e87 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -71,9 +71,16 @@ describe('tools/list payload size guard', () => { // agent-briefing dimensions block. Descriptions were trimmed first // (~200 tokens recovered); the remainder is schema structure agents // depend on for resolve-don't-select, not trimmable prose. + // * 42K → 43K with dimensions PR4 reports: gnubok_get_dimension_pnl (the + // value-as-column matrix outputSchema is the wire contract agents read + // the report through), the shared `dimensions` filter arg + echo props + // on trial balance / income statement / general ledger, and + // group_by/group_by_dimension + totals_scope + groups on + // gnubok_query_journal. Descriptions trimmed first (~100 tokens + // recovered); the ~55-token remainder is schema structure. // Long-term answer to growth is leaning harder on gnubok_search_tools — if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(42_000) + expect(approxTokens).toBeLessThan(43_000) }) }) diff --git a/extensions/general/mcp-server/__tests__/query-journal.test.ts b/extensions/general/mcp-server/__tests__/query-journal.test.ts index 9fb83874..ccfcfa8a 100644 --- a/extensions/general/mcp-server/__tests__/query-journal.test.ts +++ b/extensions/general/mcp-server/__tests__/query-journal.test.ts @@ -1,9 +1,12 @@ /** * Unit tests for gnubok_query_journal. * - * Verifies tool registration and the post-fetch amount filter + totals - * computation. The supabase query-builder chain is exercised by the live - * MCP smoke test; here we just check the result-shape pipeline. + * Verifies tool registration, the post-fetch amount filter, the full-match + * aggregate pass (totals/groups over ALL matching lines via fetchAllRows, + * totals_scope='full_match'), and the slice-scoped free-text path + * (totals_scope='returned_slice'). The supabase query-builder chain is + * exercised by the live MCP smoke test; here we check the result-shape + * pipeline. */ import { describe, it, expect, vi } from 'vitest' import { tools } from '../server' @@ -19,10 +22,12 @@ describe('gnubok_query_journal — registration', () => { it('declares the expected output fields', () => { const tool = tools.find((t) => t.name === 'gnubok_query_journal')! - const schema = tool.outputSchema as { required?: string[] } + const schema = tool.outputSchema as { required?: string[]; properties?: Record } expect(schema.required).toContain('lines') expect(schema.required).toContain('totals') expect(schema.required).toContain('total_lines') + expect(schema.required).toContain('totals_scope') + expect(schema.properties?.groups).toBeDefined() }) it('is mapped to reports:read scope', () => { @@ -33,7 +38,9 @@ describe('gnubok_query_journal — registration', () => { /** * Build a minimal supabase mock that returns a fixed line set when the chain * is awaited. Uses a chainable proxy whose every method returns itself, with - * the terminal awaitable resolving to { data, error, count }. + * the terminal awaitable resolving to { data, error, count }. Every .from() + * call sees the SAME rows, so on the non-text path both the display query and + * the fetchAllRows full-match aggregate pass read one identical match set. */ function makeChainMock(lines: unknown[], count: number) { const result = { data: lines, error: null, count } @@ -183,9 +190,11 @@ describe('gnubok_query_journal — execute', () => { )) as { lines: { line_id: string }[] totals: { debit: number; credit: number; net: number } + totals_scope: string truncated: boolean total_lines: number returned_lines: number + db_matched_pre_amount_filter: number | null } // amount_min: 1000 should filter out the 50-line @@ -194,6 +203,10 @@ describe('gnubok_query_journal — execute', () => { expect(result.totals.debit).toBe(5000) expect(result.totals.credit).toBe(0) expect(result.totals.net).toBe(5000) + // Non-text path: totals come from the full-match aggregate pass. + expect(result.totals_scope).toBe('full_match') + expect(result.total_lines).toBe(1) + expect(result.db_matched_pre_amount_filter).toBe(2) }) it('caps accounts list at 50', async () => { @@ -206,33 +219,125 @@ describe('gnubok_query_journal — execute', () => { ).rejects.toThrow(/capped at 50/) }) - it('marks truncated=true when count exceeds returned', async () => { + it('marks truncated=true and computes totals over the FULL match set when the slice is capped', async () => { + // Regression for the slice-totals bug: the display query is capped at + // `limit`, but totals/total_lines must come from the fetchAllRows + // aggregate pass over ALL matching lines (totals_scope='full_match'). const tool = tools.find((t) => t.name === 'gnubok_query_journal')! - const lines = [ - { - id: 'l1', account_number: '1930', - debit_amount: 100, credit_amount: 0, - currency: 'SEK', line_description: null, project: null, cost_center: null, sort_order: 0, - journal_entries: { - id: 'e1', voucher_number: 1, voucher_series: 'A', - entry_date: '2026-01-01', description: 'Inbetalning', - source_type: 'bank_transaction', status: 'posted', - }, - }, + const displaySlice = [makeLineRow({ id: 'l1', account_number: '1930', debit_amount: 100 })] + const fullMatchSet = [ + makeLineRow({ id: 'l1', account_number: '1930', debit_amount: 100 }), + makeLineRow({ id: 'l2', account_number: '1930', debit_amount: 200 }), + makeLineRow({ id: 'l3', account_number: '1930', debit_amount: 300 }), ] - // count=999 simulates "many more matched than were returned" - const supabase = makeChainMock(lines, 999) + + // .from() call order: display query first, then the aggregate pass + // (single page — 3 rows < PAGE_SIZE ends the fetchAllRows loop). + const { supabase, callCount } = makeQueueMock([ + { data: displaySlice, count: 1 }, + { data: fullMatchSet, count: 3 }, + ]) const result = (await tool.execute( { accounts: ['1930'], limit: 1 }, 'company-1', 'user-1', supabase, - )) as { truncated: boolean; total_lines: number; returned_lines: number } + )) as { + truncated: boolean + total_lines: number + returned_lines: number + totals: { debit: number; credit: number; net: number } + totals_scope: string + } + expect(callCount()).toBe(2) expect(result.truncated).toBe(true) - expect(result.total_lines).toBe(999) + expect(result.total_lines).toBe(3) expect(result.returned_lines).toBe(1) + expect(result.totals).toEqual({ debit: 600, credit: 0, net: 600 }) + expect(result.totals_scope).toBe('full_match') + }) + + it('rejects group_by + group_by_dimension together', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const supabase = makeChainMock([], 0) + + await expect( + tool.execute( + { group_by: 'account_number', group_by_dimension: '6' }, + 'company-1', + 'user-1', + supabase, + ), + ).rejects.toThrow(/either group_by or group_by_dimension/) + }) + + it('group_by buckets the full match set and sorts by |net| descending', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const rows = [ + makeLineRow({ id: 'l1', account_number: '4010', debit_amount: 100 }), + makeLineRow({ id: 'l2', account_number: '4010', debit_amount: 50 }), + makeLineRow({ id: 'l3', account_number: '5010', debit_amount: 0, credit_amount: 30 }), + ] + // makeChainMock feeds the SAME rows to display + aggregate passes. + const supabase = makeChainMock(rows, 3) + + const result = (await tool.execute( + { group_by: 'account_number', limit: 100 }, + 'company-1', + 'user-1', + supabase, + )) as { + groups: Array<{ key: string; debit: number; credit: number; net: number; line_count: number }> + totals_scope: string + applied_filters: { group_by: string | null; group_by_dimension: string | null } + } + + expect(result.totals_scope).toBe('full_match') + expect(result.groups).toEqual([ + { key: '4010', debit: 150, credit: 0, net: 150, line_count: 2 }, + { key: '5010', debit: 0, credit: 30, net: -30, line_count: 1 }, + ]) + expect(result.applied_filters.group_by).toBe('account_number') + expect(result.applied_filters.group_by_dimension).toBeNull() + }) + + it('group_by_dimension buckets by the dimensions jsonb with an untagged fallback', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const rows = [ + { ...makeLineRow({ id: 'l1', account_number: '4010', debit_amount: 100 }), dimensions: { '6': 'P001' } }, + { ...makeLineRow({ id: 'l2', account_number: '4011', debit_amount: 50 }), dimensions: { '6': 'P001', '1': 'KS01' } }, + { ...makeLineRow({ id: 'l3', account_number: '5010', debit_amount: 0, credit_amount: 30 }), dimensions: null }, + ] + const supabase = makeChainMock(rows, 3) + + const result = (await tool.execute( + { group_by_dimension: '6', limit: 100 }, + 'company-1', + 'user-1', + supabase, + )) as { + groups: Array<{ key: string; debit: number; credit: number; net: number; line_count: number }> + totals_scope: string + applied_filters: { group_by: string | null; group_by_dimension: string | null } + } + + expect(result.totals_scope).toBe('full_match') + expect(result.groups).toEqual([ + { key: 'P001', debit: 150, credit: 0, net: 150, line_count: 2 }, + { key: '(utan dimension)', debit: 0, credit: 30, net: -30, line_count: 1 }, + ]) + expect(result.applied_filters.group_by_dimension).toBe('6') + }) + + it('rejects a non-numeric group_by_dimension', async () => { + const tool = tools.find((t) => t.name === 'gnubok_query_journal')! + const supabase = makeChainMock([], 0) + + await expect( + tool.execute({ group_by_dimension: 'projekt' }, 'company-1', 'user-1', supabase), + ).rejects.toThrow(/positive SIE dimension number/) }) }) @@ -264,12 +369,14 @@ describe('gnubok_query_journal — free-text search', () => { 'company-1', 'user-1', supabase, - )) as { lines: Array<{ line_id: string }>; returned_lines: number } + )) as { lines: Array<{ line_id: string }>; returned_lines: number; totals_scope: string } expect(callCount()).toBe(2) expect(result.returned_lines).toBe(2) const ids = result.lines.map((l) => l.line_id).sort() expect(ids).toEqual(['L1', 'L2']) + // Free-text path never runs the full aggregate pass — the output says so. + expect(result.totals_scope).toBe('returned_slice') }) it('deduplicates rows returned by both query legs', async () => { diff --git a/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts b/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts new file mode 100644 index 00000000..22905309 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/report-dimension-filter.test.ts @@ -0,0 +1,194 @@ +/** + * Dimensions PR4 — the shared `dimensions` filter arg on the report tools + * (gnubok_get_trial_balance / gnubok_get_income_statement / + * gnubok_get_general_ledger). + * + * The report generators are mocked; what is under test is the MCP layer: + * resolve-don't-select (names → registry codes via the real + * resolveDimensionBags against a queued supabase mock), the options handoff + * to each generator, and the dimension_filter / dimension_resolutions echoes. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { generateTrialBalance } from '@/lib/reports/trial-balance' +import { generateIncomeStatement } from '@/lib/reports/income-statement' +import { generateGeneralLedger } from '@/lib/reports/general-ledger' +import { tools } from '../server' + +vi.mock('@/lib/reports/trial-balance', () => ({ generateTrialBalance: vi.fn() })) +vi.mock('@/lib/reports/income-statement', () => ({ generateIncomeStatement: vi.fn() })) +vi.mock('@/lib/reports/general-ledger', () => ({ generateGeneralLedger: vi.fn() })) + +const trialBalance = tools.find((t) => t.name === 'gnubok_get_trial_balance')! +const incomeStatement = tools.find((t) => t.name === 'gnubok_get_income_statement')! +const generalLedger = tools.find((t) => t.name === 'gnubok_get_general_ledger')! + +const mockTrialBalance = vi.mocked(generateTrialBalance) +const mockIncomeStatement = vi.mocked(generateIncomeStatement) +const mockGeneralLedger = vi.mocked(generateGeneralLedger) + +const PERIOD_ROW = { + id: 'fp-1', + name: '2026', + period_start: '2026-01-01', + period_end: '2026-12-31', +} + +/** Registry fixtures matching dimension-tools.test.ts conventions. */ +function enqueueRegistry(enqueue: (r: { data?: unknown; error?: unknown }) => void) { + enqueue({ data: { dimensions_enabled: true }, error: null }) // company_settings + enqueue({ data: null, error: null }) // ensure_company_dimensions rpc + enqueue({ + data: [ + { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }, + ], + error: null, + }) + enqueue({ + data: [ + { id: 'v1', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null }, + ], + error: null, + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('report tools declare the dimensions filter arg', () => { + it('is present with string-map shape on all three tools', () => { + for (const tool of [trialBalance, incomeStatement, generalLedger]) { + const props = (tool.inputSchema as { properties: Record }).properties + expect(props.dimensions, tool.name).toBeDefined() + expect(props.dimensions.type, tool.name).toBe('object') + expect(props.dimensions.additionalProperties, tool.name).toEqual({ type: 'string' }) + } + }) + + it('echo fields are declared but never required', () => { + for (const tool of [trialBalance, incomeStatement, generalLedger]) { + const schema = tool.outputSchema as { properties?: Record; required?: string[] } + expect(schema.properties?.dimension_filter, tool.name).toBeDefined() + expect(schema.properties?.dimension_resolutions, tool.name).toBeDefined() + expect(schema.required ?? [], tool.name).not.toContain('dimension_filter') + expect(schema.required ?? [], tool.name).not.toContain('dimension_resolutions') + } + }) +}) + +describe('gnubok_get_trial_balance — dimensions filter', () => { + it('resolves a value NAME to its registry code, filters, and echoes both', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD_ROW, error: null }) // period info + enqueueRegistry(enqueue) + mockTrialBalance.mockResolvedValueOnce({ rows: [], totalDebit: 0, totalCredit: 0 } as never) + + const result = (await trialBalance.execute( + { period_id: 'fp-1', dimensions: { '6': 'villa almgren tak' } }, + 'company-1', + 'user-1', + supabase as never, + )) as { + dimension_filter?: Record + dimension_resolutions?: Array<{ resolved_code: string; input: string }> + } + + expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', { + dimensions: { '6': 'P001' }, + }) + expect(result.dimension_filter).toEqual({ '6': 'P001' }) + expect(result.dimension_resolutions).toHaveLength(1) + expect(result.dimension_resolutions![0]).toMatchObject({ + input: 'villa almgren tak', + resolved_code: 'P001', + }) + }) + + it('passes undefined options and omits echoes when no filter is given', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD_ROW, error: null }) + mockTrialBalance.mockResolvedValueOnce({ rows: [], totalDebit: 0, totalCredit: 0 } as never) + + const result = (await trialBalance.execute( + { period_id: 'fp-1' }, + 'company-1', + 'user-1', + supabase as never, + )) as Record + + expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', undefined) + expect(result).not.toHaveProperty('dimension_filter') + expect(result).not.toHaveProperty('dimension_resolutions') + // Zero registry queries when nothing is tagged. + expect(supabase.rpc).not.toHaveBeenCalled() + }) + + it('lets DimensionResolutionError propagate with the create-first hint', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD_ROW, error: null }) + enqueueRegistry(enqueue) + + await expect( + trialBalance.execute( + { period_id: 'fp-1', dimensions: { '6': 'Bryggeriet ombyggnad' } }, + 'company-1', + 'user-1', + supabase as never, + ), + ).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/) + expect(mockTrialBalance).not.toHaveBeenCalled() + }) +}) + +describe('gnubok_get_income_statement — dimensions filter', () => { + it('accepts an exact code without echoing resolutions', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD_ROW, error: null }) // period info + enqueueRegistry(enqueue) + mockIncomeStatement.mockResolvedValueOnce({ net_result: 42 } as never) + + const result = (await incomeStatement.execute( + { period_id: 'fp-1', dimensions: { '6': 'P001' } }, + 'company-1', + 'user-1', + supabase as never, + )) as { + net_result: number + period: { start: string; end: string } + dimension_filter?: Record + dimension_resolutions?: unknown + } + + expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', { + dimensions: { '6': 'P001' }, + }) + expect(result.net_result).toBe(42) + expect(result.period).toEqual({ start: '2026-01-01', end: '2026-12-31' }) + expect(result.dimension_filter).toEqual({ '6': 'P001' }) + // Exact code match is not a resolution — no echo. + expect(result.dimension_resolutions).toBeUndefined() + }) +}) + +describe('gnubok_get_general_ledger — dimensions filter', () => { + it('passes the bag through verbatim when dimensions_enabled is false (free-text passthrough)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { dimensions_enabled: false }, error: null }) // company_settings + mockGeneralLedger.mockResolvedValueOnce({ accounts: [] } as never) + + const result = (await generalLedger.execute( + { period_id: 'fp-1', dimensions: { '6': 'fritext-projekt' } }, + 'company-1', + 'user-1', + supabase as never, + )) as { dimension_filter?: Record } + + expect(mockGeneralLedger).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', undefined, undefined, { + dimensions: { '6': 'fritext-projekt' }, + }) + expect(result.dimension_filter).toEqual({ '6': 'fritext-projekt' }) + // Disabled → no ensure RPC, no registry reads. + expect(supabase.rpc).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index b4272282..3a4e90eb 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -43,7 +43,9 @@ import { parseDimensionsArg, mergeLineDimensions, resolveDimensionBags, + type DimensionResolution, } from './dimensions' +import { generateDimensionPnl } from '@/lib/reports/dimension-pnl' import Fuse from 'fuse.js' import { z } from 'zod' import { @@ -1546,6 +1548,47 @@ function previousPeriodArgs( return null } +// Shared by the report tools' optional `dimensions` filter arg: parse the raw +// bag, then resolve value NAMES → registry codes in one pass (resolve-don't- +// select — the exact contract gnubok_create_voucher uses, incl. free-text +// passthrough while dimensions_enabled is off). A DimensionResolutionError +// propagates to the caller with ranked candidates. The resolved bag is echoed +// back as `dimension_filter` so the agent can verify what a name attached to. +async function resolveReportDimensionFilter( + supabase: SupabaseClient, + companyId: string, + raw: unknown, +): Promise<{ filter?: Record; resolutions: DimensionResolution[] }> { + if (!raw || typeof raw !== 'object' || Object.keys(raw as object).length === 0) { + return { resolutions: [] } + } + const parsed = parseDimensionsArg(raw, 'dimensions') + const { bags, resolutions } = await resolveDimensionBags(supabase, companyId, [parsed]) + return { filter: bags[0], resolutions } +} + +// Input-schema fragment for that arg — identical shape on trial balance, +// income statement, and general ledger. +const REPORT_DIMENSIONS_FILTER_SCHEMA = { + type: 'object', + additionalProperties: { type: 'string' }, + 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 + +// Output-schema fragments for the echo fields (never in `required`). +const DIMENSION_FILTER_OUTPUT_PROPS = { + dimension_filter: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'Echo of the applied filter, resolved to registry codes.', + }, + dimension_resolutions: { + type: 'array', + items: { type: 'object' }, + description: 'Non-exact name→code resolution echoes (resolve-don\'t-select).', + }, +} as const + // ── Tools ──────────────────────────────────────────────────── export const tools: McpTool[] = [ @@ -3467,12 +3510,13 @@ export const tools: McpTool[] = [ { name: 'gnubok_get_trial_balance', title: 'Trial Balance (Råbalans)', - description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period.', + description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, + dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, outputSchema: { @@ -3487,6 +3531,7 @@ export const tools: McpTool[] = [ period_start: { type: 'string' }, period_end: { type: 'string' }, account_count: { type: 'number' }, + ...DIMENSION_FILTER_OUTPUT_PROPS, }, required: ['rows', 'total_debit', 'total_credit', 'is_balanced'], }, @@ -3525,12 +3570,22 @@ export const tools: McpTool[] = [ if (!period) throw new Error('Fiscal period not found.') + // Optional dimensions filter — names resolve to registry codes first + // (resolve-don't-select), then flow into the generator's jsonb + // containment filter. + const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) + // Delegate to the canonical, paginated trial-balance builder. The // previous inline query had no pagination, so PostgREST's 1000-row // default silently truncated any period with >1000 entry lines (wrong // sums, false "not balanced"), and it ignored opening balances. // generateTrialBalance paginates and rolls IB forward. - const trialBalance = await generateTrialBalance(supabase, companyId, periodId!) + const trialBalance = await generateTrialBalance( + supabase, + companyId, + periodId!, + dimFilter.filter ? { dimensions: dimFilter.filter } : undefined, + ) const rows = trialBalance.rows .map((r) => { @@ -3558,6 +3613,8 @@ export const tools: McpTool[] = [ period_start: period.period_start, period_end: period.period_end, account_count: rows.length, + ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), + ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), } }, }, @@ -3776,15 +3833,19 @@ 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.', + 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).', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, + dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, - outputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + properties: { ...DIMENSION_FILTER_OUTPUT_PROPS }, + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -3818,12 +3879,21 @@ export const tools: McpTool[] = [ if (!period) throw new Error('Fiscal period not found.') - const result = await generateIncomeStatement(supabase, companyId, periodId!) + const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) + + const result = await generateIncomeStatement( + supabase, + companyId, + periodId!, + dimFilter.filter ? { dimensions: dimFilter.filter } : undefined, + ) result.period = { start: period.period_start, end: period.period_end } return { period_name: period.name, ...result, + ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), + ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), } }, }, @@ -4761,6 +4831,115 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_get_dimension_pnl', + title: 'P&L per Dimension (Resultat per projekt)', + description: 'Resultat per projekt/kostnadsställe: P&L matrix over one SIE dimension — each value with activity becomes a column plus an untagged bucket, and the Totalt column reconciles exactly with the resultatrapport. sie_dim_no: 1 = kostnadsställe, 6 = projekt.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + sie_dim_no: { type: 'string', description: "SIE dimension number: '1' = kostnadsställe, '6' = projekt, or a custom dim from gnubok_list_dimensions." }, + period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, + to_date: { type: 'string', description: 'Optional end date (YYYY-MM-DD); the matrix is always cumulative from period start (closing-balance semantics, reconciles with resultatrapport)' }, + }, + required: ['sie_dim_no'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + dimension: { + type: 'object', + properties: { + sie_dim_no: { type: 'string' }, + name: { type: 'string' }, + }, + }, + columns: { + type: 'array', + description: 'One per value with activity; code null = the "(Utan dimension)" residual bucket.', + items: { + type: 'object', + properties: { + code: { type: ['string', 'null'] }, + name: { type: ['string', 'null'] }, + }, + }, + }, + groups: { + type: 'array', + description: 'BAS class groups (3–8); each row\'s values[] aligns with columns[].', + items: { + type: 'object', + properties: { + class: { type: 'number' }, + class_label: { type: 'string' }, + rows: { + type: 'array', + items: { + type: 'object', + properties: { + account_number: { type: 'string' }, + account_name: { type: 'string' }, + values: { type: 'array', items: { type: 'number' } }, + total: { type: 'number' }, + }, + }, + }, + subtotals: { type: 'array', items: { type: 'number' } }, + subtotal_total: { type: 'number' }, + }, + }, + }, + net_per_column: { type: 'array', items: { type: 'number' } }, + net_total: { type: 'number', description: 'Matches resultatrapport net result for the same window.' }, + period: { + type: 'object', + properties: { start: { type: 'string' }, end: { type: 'string' } }, + }, + }, + required: ['dimension', 'columns', 'groups', 'net_per_column', 'net_total'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, _userId, supabase) { + const sieDimNo = String(args.sie_dim_no ?? '').trim() + // Positive-integer guard — the value is interpolated into a PostgREST + // jsonb path expression downstream, so free-form strings are rejected. + if (!/^[1-9]\d{0,3}$/.test(sieDimNo)) { + throw new Error("sie_dim_no must be a positive SIE dimension number, e.g. '1' (kostnadsställe) or '6' (projekt).") + } + + let periodId = args.period_id as string | undefined + + // If no period specified, find the most recent one (same default as + // gnubok_get_trial_balance). + if (!periodId) { + const { data: periods } = await supabase + .from('fiscal_periods') + .select('id, name') + .eq('company_id', companyId) + .order('period_start', { ascending: false }) + .limit(1) + .single() + + if (!periods) { + throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.') + } + periodId = periods.id + } + + const toDate = args.to_date as string | undefined + + return await generateDimensionPnl(supabase, companyId, periodId!, sieDimNo, { toDate }) + }, + }, + // ── Reports ────────────────────────────────────────────────── { @@ -4819,7 +4998,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_get_general_ledger', title: 'General Ledger (Huvudbok)', - description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range filter. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.', + description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range + dimensions filters. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.', inputSchema: { type: 'object', additionalProperties: false, @@ -4827,9 +5006,13 @@ export const tools: McpTool[] = [ period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, account_from: { type: 'string', description: 'Starting account number filter' }, account_to: { type: 'string', description: 'Ending account number filter' }, + dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, - outputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + properties: { ...DIMENSION_FILTER_OUTPUT_PROPS }, + }, annotations: { readOnlyHint: true, destructiveHint: false, @@ -4855,14 +5038,28 @@ export const tools: McpTool[] = [ const accountFrom = args.account_from as string | undefined const accountTo = args.account_to as string | undefined - return await generateGeneralLedger(supabase, companyId, periodId!, accountFrom, accountTo) + const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) + + const report = await generateGeneralLedger( + supabase, + companyId, + periodId!, + accountFrom, + accountTo, + dimFilter.filter ? { dimensions: dimFilter.filter } : undefined, + ) + return { + ...report, + ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), + ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), + } }, }, { name: 'gnubok_query_journal', title: 'Query Journal Lines', - description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Returns lines with voucher metadata + totals.", + description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Optional group_by aggregation. Returns lines + totals over the full match set (see totals_scope).", inputSchema: { type: 'object', additionalProperties: false, @@ -4882,7 +5079,9 @@ export const tools: McpTool[] = [ status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' }, project: { type: 'string', description: 'Filter by project code' }, cost_center: { type: 'string', description: 'Filter by cost center' }, - limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' }, + group_by: { type: 'string', enum: ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'], description: 'Aggregate matching lines into groups by this field. Mutually exclusive with group_by_dimension.' }, + group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' }, + limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1–500 (default 100). Totals/groups cover the FULL match set even when truncated, except under free-text search (see totals_scope).' }, }, }, outputSchema: { @@ -4903,9 +5102,28 @@ export const tools: McpTool[] = [ net: { type: 'number', description: 'debit minus credit (positive = net debit)' }, }, }, + totals_scope: { + type: 'string', + enum: ['full_match', 'returned_slice'], + description: 'full_match: totals/groups aggregate ALL matching lines regardless of limit. returned_slice: free-text search aggregates only the returned window.', + }, + groups: { + type: 'array', + items: { + type: 'object', + properties: { + key: { type: 'string' }, + debit: { type: 'number' }, + credit: { type: 'number' }, + net: { type: 'number' }, + line_count: { type: 'number' }, + }, + }, + description: 'Present when group_by/group_by_dimension is set; sorted by |net| desc. Scope follows totals_scope.', + }, applied_filters: { type: 'object' }, }, - required: ['lines', 'total_lines', 'returned_lines', 'totals'], + required: ['lines', 'total_lines', 'returned_lines', 'totals', 'totals_scope'], }, annotations: { readOnlyHint: true, @@ -4933,16 +5151,44 @@ export const tools: McpTool[] = [ const project = args.project as string | undefined const costCenter = args.cost_center as string | undefined - // Each text-search leg needs its own builder instance — PostgREST - // query builders are not reusable across awaits. The factory closes - // over the resolved filter values above. - const buildBaseQuery = () => { + const GROUP_BY_FIELDS = ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'] as const + const groupBy = args.group_by as (typeof GROUP_BY_FIELDS)[number] | undefined + const groupByDimension = + args.group_by_dimension !== undefined && args.group_by_dimension !== null + ? String(args.group_by_dimension).trim() + : undefined + + if (groupBy && groupByDimension) { + throw new Error('Use either group_by or group_by_dimension, not both') + } + if (groupBy && !GROUP_BY_FIELDS.includes(groupBy)) { + throw new Error(`group_by must be one of: ${GROUP_BY_FIELDS.join(', ')}`) + } + // Positive-integer guard — the schema says string but hosts don't always + // validate, and the value keys into the dimensions jsonb bag. + if (groupByDimension && !/^[1-9]\d{0,3}$/.test(groupByDimension)) { + throw new Error('group_by_dimension must be a positive SIE dimension number, e.g. "6" (projekt)') + } + const wantsGroups = Boolean(groupBy || groupByDimension) + + // The dimensions jsonb only rides along when a group needs it — it is + // the widest column on the line and the aggregate pass fetches ALL rows. + const dimsSelect = groupByDimension ? ', dimensions' : '' + const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)` + // Lean projection for the full-match aggregate pass — only what totals + // and group buckets need. journal_entries stays embedded (!inner) + // because the entry-level filters bind to it. + const AGGREGATE_SELECT = `id, account_number, debit_amount, credit_amount, project, cost_center${dimsSelect}, journal_entries!inner(voucher_series, source_type, company_id)` + + // Each query pass needs its own builder instance — PostgREST query + // builders are not reusable across awaits. The factory closes over the + // resolved filter values above and applies IDENTICAL filters for every + // projection, so display, text legs, and the aggregate pass always see + // the same match set. + const buildFilteredQuery = (select: string) => { let q = supabase .from('journal_entry_lines') - .select( - 'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)', - { count: 'exact' } - ) + .select(select) .eq('journal_entries.company_id', companyId) if (status === 'all') { @@ -4973,7 +5219,7 @@ export const tools: McpTool[] = [ return q } - const applyOrderAndLimit = >(q: T): T => + const applyOrderAndLimit = >(q: T): T => q .order('entry_date', { foreignTable: 'journal_entries', ascending: false }) .order('voucher_number', { foreignTable: 'journal_entries', ascending: false }) @@ -4989,6 +5235,7 @@ export const tools: McpTool[] = [ line_description: string | null project: string | null cost_center: string | null + dimensions?: Record | null sort_order: number journal_entries: { id: string @@ -5001,6 +5248,20 @@ export const tools: McpTool[] = [ } } + // Lean row shape of the full-match aggregate pass. Field-compatible + // with LineRow everywhere groupKey/totals read, so both can feed the + // same aggregation code. + type AggregateRow = { + id: string + account_number: string + debit_amount: number + credit_amount: number + project: string | null + cost_center: string | null + dimensions?: Record | null + journal_entries: { voucher_series: string; source_type: string } + } + // Free-text search runs as two parallel .ilike() queries — one against // line_description (base table) and one against journal_entries.description // (embedded resource). PostgREST's flat .or() filter cannot span a base @@ -5043,7 +5304,7 @@ export const tools: McpTool[] = [ const legLimit = Math.min(limit * 2, 500) const buildLeg = (column: 'line_description' | 'journal_entries.description') => - buildBaseQuery() + buildFilteredQuery(DISPLAY_SELECT) .ilike(column, pattern) .order('entry_date', { foreignTable: 'journal_entries', ascending: false }) .order('voucher_number', { foreignTable: 'journal_entries', ascending: false }) @@ -5089,36 +5350,73 @@ export const tools: McpTool[] = [ (byLine.data?.length ?? 0) >= legLimit || (byEntry.data?.length ?? 0) >= legLimit } else { - const res = await applyOrderAndLimit(buildBaseQuery()) + const res = await applyOrderAndLimit(buildFilteredQuery(DISPLAY_SELECT)) if (res.error) { log.warn('query_journal failed', { companyId, userId, error: res.error.message }) throw new Error('Database error while running journal query') } data = (res.data ?? []) as unknown as LineRow[] - dbMatched = res.count ?? data.length + dbMatched = data.length + } + + // Full-match aggregate pass (non-text path only): re-run the same + // filters with a lean projection over ALL matching rows so totals and + // groups are exact regardless of `limit`. The free-text path stays + // slice-scoped (its per-leg windows make a full pass unbounded) and + // says so via totals_scope='returned_slice'. + let fullRows: AggregateRow[] | null = null + if (!text) { + try { + fullRows = await fetchAllRows( + ({ from, to }) => + buildFilteredQuery(AGGREGATE_SELECT) + // Stable total order on the line PK for correct paging. + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike<{ + data: AggregateRow[] | null + error: { message: string } | null + }>, + { dedupeBy: (r) => r.id }, + ) + } catch (err) { + log.warn('query_journal aggregate pass failed', { + companyId, + userId, + error: err instanceof Error ? err.message : String(err), + }) + throw new Error('Database error while running journal query') + } } // Apply amount filter post-fetch — PostgREST can't OR an abs(debit) >= n // with abs(credit) >= n cleanly. Lines are debit XOR credit, so checking - // max(debit, credit) works. + // max(debit, credit) works. The SAME predicate runs over the display + // slice and the full aggregate set so both describe one match set. const amountMin = args.amount_min as number | undefined const amountMax = args.amount_max as number | undefined const amountFilterApplied = typeof amountMin === 'number' || typeof amountMax === 'number' - const filtered = data.filter((r) => { + const passesAmountFilter = (r: { debit_amount: number; credit_amount: number }) => { const lineAmount = Math.max(Number(r.debit_amount) || 0, Number(r.credit_amount) || 0) if (typeof amountMin === 'number' && lineAmount < amountMin) return false if (typeof amountMax === 'number' && lineAmount > amountMax) return false return true - }) + } + const filtered = data.filter(passesAmountFilter) + const fullFiltered = fullRows ? fullRows.filter(passesAmountFilter) : null - // Compute totals on the fetched-and-filtered set. Note: when truncated, - // these are totals of the returned slice, not the full match. The - // truncated flag tells the agent whether to issue a narrower query. + // Totals aggregate over the full match set when available (non-text), + // else over the returned slice (free-text) — totals_scope tells the + // agent which one it got. + const totalsSource: Array<{ debit_amount: number; credit_amount: number }> = + fullFiltered ?? filtered let totalDebit = 0 let totalCredit = 0 - const lines = filtered.map((r) => { + for (const r of totalsSource) { totalDebit += Number(r.debit_amount) || 0 totalCredit += Number(r.credit_amount) || 0 + } + + const lines = filtered.map((r) => { return { line_id: r.id, journal_entry_id: r.journal_entries.id, @@ -5138,31 +5436,74 @@ export const tools: McpTool[] = [ } }) - // PostgREST's `count` is computed before the post-fetch amount filter, - // so when amount_min/amount_max is set it reflects the wider DB-side - // match — not the lines actually returned. Reporting that as - // `total_lines` would mislead an agent into chasing a truncated tail - // that has already been filtered out client-side. When the amount - // filter ran, anchor `total_lines` and `truncated` to the filtered - // result, and surface the pre-filter count + a flag separately so an - // agent can still tell the DB matched more (it just didn't pass the - // amount predicate). - const total_lines = amountFilterApplied ? lines.length : dbMatched - const truncated = amountFilterApplied - ? data.length >= limit && lines.length === limit - : dbMatched > lines.length || legCapHit + // Optional group_by aggregation — over the same set totals used, so + // group sums always reconcile with `totals`. + let groups: + | Array<{ key: string; debit: number; credit: number; net: number; line_count: number }> + | undefined + if (wantsGroups) { + const groupSource: Array = fullFiltered ?? filtered + const keyOf = (r: AggregateRow | LineRow): string => { + if (groupByDimension) return r.dimensions?.[groupByDimension] ?? '(utan dimension)' + switch (groupBy) { + case 'voucher_series': return r.journal_entries.voucher_series + case 'source_type': return r.journal_entries.source_type + case 'cost_center': return r.cost_center ?? '(utan dimension)' + case 'project': return r.project ?? '(utan dimension)' + default: return r.account_number + } + } + const bucketMap = new Map() + for (const r of groupSource) { + const key = keyOf(r) + const bucket = bucketMap.get(key) ?? { debit: 0, credit: 0, count: 0 } + bucket.debit += Number(r.debit_amount) || 0 + bucket.credit += Number(r.credit_amount) || 0 + bucket.count += 1 + bucketMap.set(key, bucket) + } + groups = [...bucketMap.entries()] + .map(([key, bucket]) => ({ + key, + debit: roundOre(bucket.debit), + credit: roundOre(bucket.credit), + net: roundOre(bucket.debit - bucket.credit), + line_count: bucket.count, + })) + .sort((a, b) => Math.abs(b.net) - Math.abs(a.net)) + } + + // Non-text path: the aggregate pass IS the full match set, so + // total_lines / truncated / pre-amount count all anchor to it. Text + // path: no full pass exists — total_lines stays slice-anchored exactly + // as before (amount filter → post-filter slice; otherwise the merged + // distinct count), and legCapHit keeps `truncated` honest. + const total_lines = fullFiltered + ? fullFiltered.length + : amountFilterApplied + ? lines.length + : dbMatched + const truncated = fullFiltered + ? fullFiltered.length > lines.length + : amountFilterApplied + ? data.length >= limit && lines.length === limit + : dbMatched > lines.length || legCapHit return { lines, truncated, total_lines, returned_lines: lines.length, amount_filter_applied_post_fetch: amountFilterApplied, - db_matched_pre_amount_filter: amountFilterApplied ? dbMatched : null, + db_matched_pre_amount_filter: amountFilterApplied + ? (fullRows ? fullRows.length : dbMatched) + : null, totals: { debit: Math.round(totalDebit * 100) / 100, credit: Math.round(totalCredit * 100) / 100, net: Math.round((totalDebit - totalCredit) * 100) / 100, }, + totals_scope: fullFiltered ? 'full_match' : 'returned_slice', + ...(groups ? { groups } : {}), applied_filters: { account_from: accountFrom ?? null, account_to: accountTo ?? null, @@ -5179,6 +5520,8 @@ export const tools: McpTool[] = [ status, project: project ?? null, cost_center: costCenter ?? null, + group_by: groupBy ?? null, + group_by_dimension: groupByDimension ?? null, }, } }, diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 61996126..e502206f 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -209,6 +209,7 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_list_dimensions: 'reports:read', gnubok_list_dimension_values: 'reports:read', gnubok_create_dimension_value: 'bookkeeping:write', + gnubok_get_dimension_pnl: 'reports:read', // Document inbox gnubok_upload_document: 'transactions:write', gnubok_list_inbox_items: 'transactions:read', diff --git a/lib/reports/__tests__/dimension-pnl.test.ts b/lib/reports/__tests__/dimension-pnl.test.ts new file mode 100644 index 00000000..580edf54 --- /dev/null +++ b/lib/reports/__tests__/dimension-pnl.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { TrialBalanceRow } from '@/types' + +// ============================================================ +// Resultat per projekt/kostnadsställe (dimensions PR4). +// +// generateTrialBalance is mocked (post-processor pattern, like +// resultatrapport.test.ts); the registry + tagged-line queries use a +// table-keyed FIFO mock. +// ============================================================ + +vi.mock('../trial-balance', () => ({ + generateTrialBalance: vi.fn(), +})) + +type MockResult = { data?: unknown; error?: unknown } +let mockResults: Record + +function makeBuilder(tableName: string) { + const b: Record = {} + for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'not', 'contains', 'order', 'range']) { + b[m] = vi.fn().mockReturnValue(b) + } + const consume = (): MockResult => { + const queue = mockResults[tableName] + if (!queue || queue.length === 0) return { data: null, error: null } + return queue.shift()! + } + b.single = vi.fn().mockImplementation(async () => consume()) + b.maybeSingle = vi.fn().mockImplementation(async () => consume()) + b.then = (resolve: (v: unknown) => void) => resolve(consume()) + return b +} + +function makeClient() { + return { + from: vi.fn().mockImplementation((table: string) => makeBuilder(table)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +import { generateDimensionPnl } from '../dimension-pnl' +import { generateTrialBalance } from '../trial-balance' + +const mockTrialBalance = vi.mocked(generateTrialBalance) + +function tbRow(partial: Partial): TrialBalanceRow { + return { + account_number: '3001', + account_name: 'Försäljning', + account_class: 3, + opening_debit: 0, + opening_credit: 0, + period_debit: 0, + period_credit: 0, + closing_debit: 0, + closing_credit: 0, + ...partial, + } +} + +function tb(rows: TrialBalanceRow[]) { + return { rows, totalDebit: 0, totalCredit: 0, isBalanced: true } +} + +let supabase: ReturnType + +beforeEach(() => { + vi.clearAllMocks() + mockResults = {} + supabase = makeClient() +}) + +const PERIOD = { period_start: '2026-01-01', period_end: '2026-12-31' } + +describe('generateDimensionPnl', () => { + it('builds the value-as-column matrix with an untagged residual that reconciles to the trial balance', async () => { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + dimensions: [ + { data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }, + ], + dimension_values: [ + { + data: [ + { code: 'P001', name: 'Villa Almgren' }, + { code: 'P002', name: 'Kontorsbygget' }, + ], + error: null, + }, + ], + journal_entry_lines: [ + { + data: [ + { id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 600, dimensions: { '6': 'P001' } }, + { id: 'l2', account_number: '3001', debit_amount: 0, credit_amount: 300, dimensions: { '6': 'P002' } }, + { id: 'l3', account_number: '4010', debit_amount: 400, credit_amount: 0, dimensions: { '6': 'P001' } }, + // Balance-account line — outside the P&L scope, must be ignored. + { id: 'l4', account_number: '1930', debit_amount: 0, credit_amount: 900, dimensions: { '6': 'P001' } }, + ], + error: null, + }, + ], + } + mockTrialBalance.mockResolvedValue( + tb([ + // 3001: 1000 total credit — only 900 of it is tagged → 100 untagged. + tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }), + tbRow({ account_number: '4010', account_name: 'Inköp', account_class: 4, closing_debit: 400 }), + tbRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 900 }), + tbRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 600 }), + ]), + ) + + const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6') + + expect(report.dimension).toEqual({ sie_dim_no: '6', name: 'Projekt' }) + expect(report.columns).toEqual([ + { code: 'P001', name: 'Villa Almgren' }, + { code: 'P002', name: 'Kontorsbygget' }, + { code: null, name: null }, // (Utan dimension) + ]) + + const revenue = report.groups.find((g) => g.class === 3)! + expect(revenue.rows).toEqual([ + { account_number: '3001', account_name: 'Försäljning', values: [600, 300, 100], total: 1000 }, + ]) + const costs = report.groups.find((g) => g.class === 4)! + expect(costs.rows).toEqual([ + { account_number: '4010', account_name: 'Inköp', values: [-400, 0, 0], total: -400 }, + ]) + + // Every row sums exactly to its Totalt (reconciliation by construction). + for (const g of report.groups) { + for (const r of g.rows) { + expect(r.values.reduce((s, v) => s + v, 0)).toBeCloseTo(r.total, 10) + } + } + + expect(report.net_per_column).toEqual([200, 300, 100]) + // net_total = resultatrapport semantics over classes 3–8 excl 8999: + // +1000 (3001) − 400 (4010) = 600. 1930 (class 1) and 8999 excluded. + expect(report.net_total).toBe(600) + expect(report.period).toEqual({ start: '2026-01-01', end: '2026-12-31' }) + }) + + it('drops the untagged column when every krona is tagged', async () => { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + dimensions: [{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }], + dimension_values: [{ data: [{ code: 'P001', name: 'Villa Almgren' }], error: null }], + journal_entry_lines: [ + { + data: [ + { id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 1000, dimensions: { '6': 'P001' } }, + ], + error: null, + }, + ], + } + mockTrialBalance.mockResolvedValue( + tb([tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 })]), + ) + + const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6') + + expect(report.columns).toEqual([{ code: 'P001', name: 'Villa Almgren' }]) + expect(report.groups[0].rows[0].values).toEqual([1000]) + expect(report.net_per_column).toEqual([1000]) + expect(report.net_total).toBe(1000) + }) + + it('falls back to seeded dimension names when the registry has no row', async () => { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + dimensions: [{ data: null, error: null }], + journal_entry_lines: [ + { + data: [ + { id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 100, dimensions: { '1': 'KS01' } }, + ], + error: null, + }, + ], + } + mockTrialBalance.mockResolvedValue( + tb([tbRow({ account_number: '3001', account_class: 3, closing_credit: 100 })]), + ) + + const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '1') + + expect(report.dimension.name).toBe('Kostnadsställe') + // Code column without a registry name. + expect(report.columns[0]).toEqual({ code: 'KS01', name: null }) + }) + + it('passes the caller date range to the trial balance (Totalt parity with resultatrapport)', async () => { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + dimensions: [{ data: null, error: null }], + journal_entry_lines: [{ data: [], error: null }], + } + mockTrialBalance.mockResolvedValue(tb([])) + + const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6', { + toDate: '2026-06-30', + }) + + expect(mockTrialBalance).toHaveBeenCalledWith(supabase, 'company-1', 'period-1', { + toDate: '2026-06-30', + }) + // The label reflects actual coverage: cumulative from period_start. + expect(report.period).toEqual({ start: '2026-01-01', end: '2026-06-30' }) + }) + + it('handles fully untagged periods — one residual column carrying the whole result', async () => { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + dimensions: [{ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt' }, error: null }], + dimension_values: [{ data: [], error: null }], + journal_entry_lines: [{ data: [], error: null }], + } + mockTrialBalance.mockResolvedValue( + tb([ + tbRow({ account_number: '3001', account_class: 3, closing_credit: 1000 }), + tbRow({ account_number: '4010', account_name: 'Inköp', account_class: 4, closing_debit: 250 }), + ]), + ) + + const report = await generateDimensionPnl(supabase, 'company-1', 'period-1', '6') + + expect(report.columns).toEqual([{ code: null, name: null }]) + expect(report.groups.find((g) => g.class === 3)?.rows[0].values).toEqual([1000]) + expect(report.groups.find((g) => g.class === 4)?.rows[0].values).toEqual([-250]) + expect(report.net_per_column).toEqual([750]) + expect(report.net_total).toBe(750) + }) + + it('rejects a non-numeric dimension number (PostgREST path guard)', async () => { + await expect( + generateDimensionPnl(supabase, 'company-1', 'period-1', '6,is.null'), + ).rejects.toThrow('positive SIE dimension number') + }) + + it('throws when the fiscal period does not exist', async () => { + mockResults = { fiscal_periods: [{ data: null, error: null }] } + await expect(generateDimensionPnl(supabase, 'company-1', 'missing', '6')).rejects.toThrow( + 'Fiscal period not found', + ) + }) +}) diff --git a/lib/reports/__tests__/dimension-statutory-guard.test.ts b/lib/reports/__tests__/dimension-statutory-guard.test.ts new file mode 100644 index 00000000..f2013a88 --- /dev/null +++ b/lib/reports/__tests__/dimension-statutory-guard.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { REPORT_CATALOG, DIMENSION_FILTER_SLUGS } from '../catalog' + +// ============================================================ +// Statutory exclusion guard (dimensions PR4). +// +// A dimension-filtered statutory output is a WRONG output: a filtered +// balance sheet doesn't balance, a filtered VAT declaration under-reports, +// a filtered SIE export is not the company's bokföring. The whitelist of +// filterable reports is therefore pinned by TEST, not by convention — this +// suite fails when the filter leaks into a statutory report route or +// generator, or when someone widens the catalog whitelist without touching +// this file. +// ============================================================ + +const ROOT = process.cwd() + +/** The only reports allowed to accept the dimension value filter. */ +const FILTERABLE_SLUGS = ['resultatrapport', 'income-statement', 'huvudbok', 'kpi'] + +/** Routes allowed to import the route-side filter parser. */ +const ALLOWED_PARSER_IMPORTERS = new Set([ + 'app/api/reports/resultatrapport/route.ts', + 'app/api/reports/resultatrapport/xlsx/route.ts', + 'app/api/reports/resultatrapport/pdf/route.ts', + 'app/api/reports/income-statement/route.ts', + 'app/api/reports/income-statement/xlsx/route.ts', + 'app/api/reports/income-statement/pdf/route.ts', + 'app/api/reports/general-ledger/route.ts', + 'app/api/reports/general-ledger/xlsx/route.ts', + 'app/api/reports/kpi/route.ts', + 'app/api/reports/monthly-breakdown/route.ts', + 'app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts', +]) + +/** Statutory generators that must never gain a containment filter. */ +const STATUTORY_GENERATORS = [ + 'lib/reports/balance-sheet.ts', + 'lib/reports/balansrapport.ts', + 'lib/reports/kassaflodesanalys.ts', + 'lib/reports/vat-declaration.ts', + 'lib/reports/sie-export.ts', + 'lib/reports/full-archive-export.ts', +] + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) walk(full, out) + else if (full.endsWith('.ts') || full.endsWith('.tsx')) out.push(full) + } + return out +} + +describe('dimension filter — statutory exclusion', () => { + it('the catalog whitelist is exactly the four P&L-safe reports', () => { + const flagged = REPORT_CATALOG.filter((r) => r.dimensions).map((r) => r.slug).sort() + expect(flagged).toEqual([...FILTERABLE_SLUGS].sort()) + expect([...DIMENSION_FILTER_SLUGS].sort()).toEqual([...FILTERABLE_SLUGS].sort()) + }) + + it('the dimension-pnl report is gated on dimensions being enabled, never entity/employees', () => { + const entry = REPORT_CATALOG.find((r) => r.slug === 'dimension-pnl') + expect(entry).toBeDefined() + expect(entry?.needsDimensions).toBe(true) + // Free tier for everyone (founder decision 2026-07-02) — no other gate. + expect(entry?.entityType).toBeUndefined() + expect(entry?.needsEmployees).toBeUndefined() + }) + + it('no statutory report route imports the dimension filter parser', () => { + const reportRoutes = walk(join(ROOT, 'app/api/reports')) + const importers = reportRoutes + .filter((f) => readFileSync(f, 'utf8').includes('lib/reports/dimension-filter')) + .map((f) => f.slice(ROOT.length + 1)) + .sort() + + // Exactly the P&L-safe routes — nothing more (statutory leak), nothing + // less (a whitelisted route silently dropping the filter would show an + // unfiltered report under a "Filtrerad" chip). + expect(importers).toEqual([...ALLOWED_PARSER_IMPORTERS].sort()) + }) + + it('statutory generators never apply a dimensions containment filter', () => { + for (const rel of STATUTORY_GENERATORS) { + const src = readFileSync(join(ROOT, rel), 'utf8') + expect(src, `${rel} must not filter on line dimensions`).not.toMatch( + /contains\(\s*['"]dimensions['"]/, + ) + expect(src, `${rel} must not accept a dimensionFilter/dimensions option`).not.toMatch( + /dimensionFilter|options\?\.dimensions/, + ) + } + }) + + it('statutory generators do not receive dimensions through generateTrialBalance', () => { + // They may call generateTrialBalance, but never with a dimensions option. + // The scan is paren-aware (walks to the call's closing paren), not a + // fixed character window — a long options object cannot slip the key + // past the guard (#862 review). + for (const rel of STATUTORY_GENERATORS) { + const src = readFileSync(join(ROOT, rel), 'utf8') + let idx = src.indexOf('generateTrialBalance(') + while (idx !== -1) { + const argsStart = idx + 'generateTrialBalance('.length + let depth = 1 + let end = argsStart + while (end < src.length && depth > 0) { + if (src[end] === '(') depth++ + else if (src[end] === ')') depth-- + end++ + } + const argList = src.slice(argsStart, end) + expect(argList, `${rel} passes dimensions to generateTrialBalance`).not.toContain( + 'dimensions', + ) + idx = src.indexOf('generateTrialBalance(', end) + } + } + }) +}) diff --git a/lib/reports/__tests__/trial-balance-dimensions.test.ts b/lib/reports/__tests__/trial-balance-dimensions.test.ts new file mode 100644 index 00000000..fd996421 --- /dev/null +++ b/lib/reports/__tests__/trial-balance-dimensions.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ============================================================ +// Dimension-filtered trial balance (dimensions PR4). +// +// Table-keyed FIFO mock like trial-balance.test.ts, extended with +// `contains`/`not` and per-table call capture so the jsonb containment +// pushdown is assertable. +// ============================================================ + +type MockResult = { data?: unknown; error?: unknown } +let mockResults: Record +let containsCalls: { table: string; column: string; value: unknown }[] + +function makeBuilder(tableName: string) { + const b: Record = {} + for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'not', 'order', 'range']) { + b[m] = vi.fn().mockReturnValue(b) + } + b.contains = vi.fn().mockImplementation((column: string, value: unknown) => { + containsCalls.push({ table: tableName, column, value }) + return b + }) + const consume = (): MockResult => { + const queue = mockResults[tableName] + if (!queue || queue.length === 0) return { data: null, error: null } + return queue.shift()! + } + b.single = vi.fn().mockImplementation(async () => consume()) + b.maybeSingle = vi.fn().mockImplementation(async () => consume()) + b.then = (resolve: (v: unknown) => void) => resolve(consume()) + return b +} + +function makeClient() { + return { + from: vi.fn().mockImplementation((table: string) => makeBuilder(table)), + rpc: vi.fn().mockResolvedValue({ data: [], error: null }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +vi.mock('../opening-balances', () => ({ + getOpeningBalances: vi.fn(), +})) + +import { generateTrialBalance } from '../trial-balance' +import { getOpeningBalances } from '../opening-balances' + +const mockOpeningBalances = vi.mocked(getOpeningBalances) + +let supabase: ReturnType + +beforeEach(() => { + vi.clearAllMocks() + mockResults = {} + containsCalls = [] + supabase = makeClient() +}) + +const PERIOD = { period_start: '2026-01-01', period_end: '2026-12-31', opening_balance_entry_id: null } + +function seedCommon() { + mockResults = { + fiscal_periods: [{ data: PERIOD, error: null }], + journal_entry_lines: [ + { + data: [ + { id: 'l1', account_number: '3001', debit_amount: 0, credit_amount: 500 }, + { id: 'l2', account_number: '1930', debit_amount: 500, credit_amount: 0 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, + { account_number: '3001', account_name: 'Försäljning', account_class: 3 }, + ], + error: null, + }, + ], + } +} + +describe('generateTrialBalance — dimensions option', () => { + it('pushes the filter down as jsonb containment on the line query', async () => { + seedCommon() + mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null }) + + await generateTrialBalance(supabase, 'company-1', 'period-1', { + dimensions: { '6': 'P001' }, + }) + + expect(containsCalls).toEqual([ + { table: 'journal_entry_lines', column: 'dimensions', value: { '6': 'P001' } }, + ]) + }) + + it('does not touch the query when no filter is passed (back-compat)', async () => { + seedCommon() + mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null }) + + await generateTrialBalance(supabase, 'company-1', 'period-1') + + expect(containsCalls).toEqual([]) + }) + + it('treats an empty filter object as no filter', async () => { + seedCommon() + mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null }) + + await generateTrialBalance(supabase, 'company-1', 'period-1', { dimensions: {} }) + + expect(containsCalls).toEqual([]) + }) + + it('drops company-wide opening balances when filtered — amounts are dimension-scoped activity only', async () => { + seedCommon() + // Company-wide IB on 1930 that CANNOT be dimension-scoped. + mockOpeningBalances.mockResolvedValue({ + balances: new Map([['1930', { debit: 9000, credit: 0 }]]), + obEntryId: null, + }) + + const filtered = await generateTrialBalance(supabase, 'company-1', 'period-1', { + dimensions: { '6': 'P001' }, + }) + + const bank = filtered.rows.find((r) => r.account_number === '1930') + expect(bank?.opening_debit).toBe(0) + expect(bank?.closing_debit).toBe(500) // period activity only + + // Unfiltered keeps the IB (control). + seedCommon() + const unfiltered = await generateTrialBalance(supabase, 'company-1', 'period-1') + const bank2 = unfiltered.rows.find((r) => r.account_number === '1930') + expect(bank2?.opening_debit).toBe(9000) + expect(bank2?.closing_debit).toBe(9500) + }) + + it('applies the filter to the IB roll-forward query too (fromDate sub-range)', async () => { + seedCommon() + // Roll-forward query consumes the first journal_entry_lines result; add a + // second for the period query. + mockResults.journal_entry_lines.push({ data: [], error: null }) + mockOpeningBalances.mockResolvedValue({ balances: new Map(), obEntryId: null }) + + await generateTrialBalance(supabase, 'company-1', 'period-1', { + fromDate: '2026-06-01', + dimensions: { '1': 'KS01' }, + }) + + // Both line queries (roll-forward + period) carry the containment filter. + expect(containsCalls).toHaveLength(2) + expect(containsCalls.every((c) => c.column === 'dimensions')).toBe(true) + expect(containsCalls.every((c) => JSON.stringify(c.value) === '{"1":"KS01"}')).toBe(true) + }) +}) diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 1ff1e4ab..b24dba80 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -59,6 +59,16 @@ export interface ReportDescriptor { * Used for reports that were never in the nav (KPI, payroll, archive…). */ libraryOnly?: boolean + /** + * Accepts the per-dimension value filter (?dim_no/&dim_code → jsonb @>). + * P&L-safe reports ONLY — statutory outputs (balance sheet, balansrapport, + * kassaflöde, årsredovisning, INK2, NE, VAT, SIE) must never carry this + * flag; a filtered filing is a wrong filing. The whitelist is pinned by + * lib/reports/__tests__/dimension-statutory-guard.test.ts. + */ + dimensions?: boolean + /** Only shown when company_settings.dimensions_enabled is true. */ + needsDimensions?: boolean } /** Categories shown in the legacy desktop rail, in order. */ @@ -101,6 +111,18 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ category: 'interim', params: 'fiscal-range', exports: ['pdf', 'xlsx'], + dimensions: true, + }, + { + // Resultat per projekt/kostnadsställe — value-as-column P&L matrix over + // one SIE dimension (Fortnox "Resultatrapport projekt"). + slug: 'dimension-pnl', + labelKey: 'name_dimension_pnl', + descKey: 'desc_dimension_pnl', + category: 'interim', + params: 'fiscal-range', + exports: ['xlsx'], + needsDimensions: true, }, { slug: 'balansrapport', @@ -126,6 +148,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ params: 'fiscal', route: '/kpi', libraryOnly: true, + dimensions: true, }, // --- Bokslut (year-end) --- @@ -147,6 +170,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ category: 'year_end', params: 'fiscal-range', exports: ['pdf', 'xlsx'], + dimensions: true, }, { slug: 'balance-sheet', @@ -215,6 +239,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ category: 'ledgers', params: 'fiscal', exports: ['xlsx'], + dimensions: true, }, { slug: 'grundbok', @@ -272,6 +297,11 @@ export const DATE_RANGE_SLUGS: ReadonlySet = new Set( REPORT_CATALOG.filter((r) => r.params === 'fiscal-range').map((r) => r.slug), ) +/** Reports that accept the per-dimension value filter (mounts DimensionFilter). */ +export const DIMENSION_FILTER_SLUGS: ReadonlySet = new Set( + REPORT_CATALOG.filter((r) => r.dimensions).map((r) => r.slug), +) + export function getReport(slug: string): ReportDescriptor | undefined { return REPORT_CATALOG.find((r) => r.slug === slug) } @@ -280,9 +310,11 @@ function isVisible( r: ReportDescriptor, entityType?: EntityType, hasEmployees?: boolean, + dimensionsEnabled?: boolean, ): boolean { if (r.entityType && r.entityType !== entityType) return false if (r.needsEmployees && !hasEmployees) return false + if (r.needsDimensions && !dimensionsEnabled) return false return true } @@ -293,12 +325,18 @@ export interface ReportSection { } /** Grouped reports for the legacy desktop rail (excludes library-only items). */ -export function getNavSections(entityType?: EntityType): ReportSection[] { +export function getNavSections( + entityType?: EntityType, + dimensionsEnabled?: boolean, +): ReportSection[] { return NAV_CATEGORIES.map((category) => ({ category, labelKey: CATEGORY_LABEL_KEY[category], items: REPORT_CATALOG.filter( - (r) => r.category === category && !r.libraryOnly && isVisible(r, entityType), + (r) => + r.category === category && + !r.libraryOnly && + isVisible(r, entityType, undefined, dimensionsEnabled), ), })).filter((s) => s.items.length > 0) } @@ -307,12 +345,13 @@ export function getNavSections(entityType?: EntityType): ReportSection[] { export function getLibrarySections( entityType?: EntityType, hasEmployees?: boolean, + dimensionsEnabled?: boolean, ): ReportSection[] { return LIBRARY_CATEGORIES.map((category) => ({ category, labelKey: CATEGORY_LABEL_KEY[category], items: REPORT_CATALOG.filter( - (r) => r.category === category && isVisible(r, entityType, hasEmployees), + (r) => r.category === category && isVisible(r, entityType, hasEmployees, dimensionsEnabled), ), })).filter((s) => s.items.length > 0) } diff --git a/lib/reports/dimension-filter.ts b/lib/reports/dimension-filter.ts new file mode 100644 index 00000000..cca64a84 --- /dev/null +++ b/lib/reports/dimension-filter.ts @@ -0,0 +1,69 @@ +import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' +import { slugifyCompanyName } from './xlsx-export' + +/** + * Parse the report-route dimension filter pair (?dim_no=6&dim_code=P001) + * into the `dimensions` option the report generators accept. + * + * Absent params are fine (unfiltered report). A half-provided pair or a + * value that fails DimensionsBagSchema (SIE framing charset, length) is a + * 400 — never silently ignored, or the user would read an unfiltered report + * as a filtered one. + * + * IMPORTANT: only the P&L-safe report routes may import this helper + * (resultatrapport, income-statement, general-ledger, kpi, dimension-pnl, + * monthly-breakdown). Statutory outputs (balance sheet, balansrapport, + * kassaflöde, årsredovisning, INK2, NE-bilaga, VAT declaration, SIE export) + * must never accept a dimension filter — a filtered filing is a wrong + * filing. The whitelist is pinned by lib/reports/__tests__/ + * dimension-statutory-guard.test.ts, which fails if this import shows up in + * a statutory route. + */ +export function parseDimensionFilterParams(searchParams: URLSearchParams): + | { ok: true; dimensions?: Record } + | { ok: false; error: string } { + const dimNo = searchParams.get('dim_no') + const dimCode = searchParams.get('dim_code') + + if (dimNo === null && dimCode === null) { + return { ok: true } + } + if (!dimNo || !dimCode) { + return { ok: false, error: 'dim_no and dim_code must be provided together' } + } + + const parsed = DimensionsBagSchema.safeParse({ [dimNo]: dimCode }) + if (!parsed.success) { + return { ok: false, error: 'Invalid dimension filter' } + } + return { ok: true, dimensions: parsed.data } +} + +/** + * Filename suffix for a dimension-filtered export ('' when unfiltered). + * A filtered file must not share its name with the authoritative report — + * BFL 5 kap / BFNAR 2013:2: what a report covers must be identifiable. + * Example: { "6": "P001" } → "-dim6-p001". + */ +export function dimensionFilterFileSuffix(dimensions?: Record): string { + if (!dimensions || Object.keys(dimensions).length === 0) return '' + return Object.entries(dimensions) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([dimNo, code]) => { + const slug = slugifyCompanyName(code) + return slug === 'foretag' ? `-dim${dimNo}` : `-dim${dimNo}-${slug}` + }) + .join('') +} + +/** + * Human-readable partial-view disclosure for inside exported files, or null + * when unfiltered. Swedish only — report surface. + */ +export function dimensionFilterDisclosure(dimensions?: Record): string | null { + if (!dimensions || Object.keys(dimensions).length === 0) return null + const parts = Object.entries(dimensions) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([dimNo, code]) => `dimension ${dimNo}: ${code}`) + return `Filtrerad (${parts.join(', ')}) — ej fullständig rapport` +} diff --git a/lib/reports/dimension-pnl.ts b/lib/reports/dimension-pnl.ts new file mode 100644 index 00000000..7cfaf8f9 --- /dev/null +++ b/lib/reports/dimension-pnl.ts @@ -0,0 +1,260 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { roundOre } from '@/lib/money' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { generateTrialBalance } from './trial-balance' +import type { + DimensionPnlColumn, + DimensionPnlGroup, + DimensionPnlReport, + DimensionPnlRow, + TrialBalanceRow, +} from '@/types' + +// Same class labels as resultatrapport — the report is its per-dimension +// sibling and must read identically. Stays-Swedish surface (report labels). +const CLASS_LABELS: Record = { + 3: '3 Rörelsens inkomster/intäkter', + 4: '4 Material- och varukostnader', + 5: '5 Övriga externa kostnader', + 6: '6 Övriga externa kostnader', + 7: '7 Personalkostnader', + 8: '8 Finansiella poster och bokslutsdispositioner', +} + +/** + * Resultat per projekt / kostnadsställe (Fortnox "Resultatrapport projekt"). + * + * Value-as-column P&L matrix over ONE SIE dimension: every registered value + * with activity becomes a column, plus an explicit "(Utan dimension)" bucket. + * + * Reconciliation is by construction, not by convention: the Totalt column + * comes from the SAME unfiltered generateTrialBalance pass resultatrapport + * uses (same options, same filterPnl scope, same sign convention), and the + * untagged bucket is the residual Totalt − tagged columns. Columns therefore + * always sum exactly to the unfiltered resultatrapport — including edge cases + * the line pass cannot see (e.g. P&L opening remnants when a prior year was + * never closed), which land in "(Utan dimension)" where they belong. + */ +export async function generateDimensionPnl( + supabase: SupabaseClient, + companyId: string, + fiscalPeriodId: string, + sieDimNo: string, + // No fromDate: the matrix uses closing-balance semantics (cumulative from + // period_start) to reconcile with resultatrapport, so a lower bound cannot + // be honoured — accepting one and labelling the report with it would be a + // lie (#862 review). toDate caps the window on both sides identically. + options?: { toDate?: string } +): Promise { + // The dim number is interpolated into a PostgREST jsonb path expression + // below (`dimensions->>N`). Both entry points (route, MCP tool) validate, + // but the generator is exported — guard here too so no future caller can + // smuggle filter syntax through. + if (!/^[1-9]\d{0,3}$/.test(sieDimNo)) { + throw new Error('sieDimNo must be a positive SIE dimension number') + } + + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (!period) { + throw new Error('Fiscal period not found') + } + + // ── Totalt column: identical inputs to resultatrapport ───────── + const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { + toDate: options?.toDate, + }) + const pnlRows = filterPnl(tb.rows) + const totalByAccount = new Map() + for (const r of pnlRows) totalByAccount.set(r.account_number, r) + + // ── Registry names for column headers (read-only — never seeds) ─ + const { data: dimRow } = await supabase + .from('dimensions') + .select('id, sie_dim_no, name') + .eq('company_id', companyId) + .eq('sie_dim_no', Number(sieDimNo)) + .maybeSingle() + + const valueNames = new Map() + if (dimRow) { + const values = await fetchAllRows<{ code: string; name: string }>(({ from, to }) => + supabase + .from('dimension_values') + .select('code, name') + .eq('company_id', companyId) + .eq('dimension_id', dimRow.id) + .order('code', { ascending: true }) + .range(from, to) + ) + for (const v of values) valueNames.set(v.code, v.name) + } + + // ── Tagged lines: one pass over lines carrying this dimension ── + // Mirrors trial-balance closing semantics: the fiscal_period_id join scopes + // to the period and toDate caps the window — both sides of the matrix + // cover period_start..toDate, so the buckets sum to the Totalt column. + const taggedLines = await fetchAllRows<{ + id: string + account_number: string + debit_amount: number + credit_amount: number + dimensions: Record + }>(({ from, to }) => { + let query = supabase + .from('journal_entry_lines') + .select( + 'id, account_number, debit_amount, credit_amount, dimensions, journal_entries!inner(company_id, fiscal_period_id, status, entry_date)' + ) + .eq('journal_entries.company_id', companyId) + .eq('journal_entries.fiscal_period_id', fiscalPeriodId) + .in('journal_entries.status', ['posted', 'reversed']) + // Key-existence via the extracted text field — dims 1/6 ride the partial + // expression indexes (idx_jel_dimensions_dim1/dim6). + .not(`dimensions->>${sieDimNo}`, 'is', null) + + if (options?.toDate) { + query = query.lte('journal_entries.entry_date', options.toDate) + } + + // Stable total order on the line PK for correct paging (see fetch-all.ts). + return query.order('id', { ascending: true }).range(from, to) + }, { dedupeBy: (r) => r.id }) + + // Bucket raw amounts per (account, code). Only accounts present in the P&L + // trial-balance scope count — anything else (balance accounts, 8999) is out. + const buckets = new Map>() + const codesSeen = new Set() + for (const line of taggedLines) { + if (!totalByAccount.has(line.account_number)) continue + const code = normalizeCode(line.dimensions?.[sieDimNo]) + if (!code) continue + codesSeen.add(code) + const byCode = buckets.get(line.account_number) ?? new Map() + const agg = byCode.get(code) ?? { debit: 0, credit: 0 } + agg.debit += Number(line.debit_amount) || 0 + agg.credit += Number(line.credit_amount) || 0 + byCode.set(code, agg) + buckets.set(line.account_number, byCode) + } + + const codes = [...codesSeen].sort((a, b) => a.localeCompare(b, 'sv')) + + // ── Matrix rows: tagged columns + untagged residual + Totalt ─── + // Per account: values[i] = round2(signed bucket), untagged = round2(total − + // Σ rounded tagged) so the row sums exactly; total = signedAmount(tb row), + // the very number resultatrapport renders for the account. + type AccountRow = DimensionPnlRow & { account_class: number } + const accountRows: AccountRow[] = [] + let anyUntagged = false + + for (const tbRow of pnlRows) { + const total = round2(signedAmount(tbRow)) + const byCode = buckets.get(tbRow.account_number) + const tagged = codes.map((code) => { + const agg = byCode?.get(code) + return agg ? round2(agg.credit - agg.debit) : 0 + }) + const untagged = round2(total - tagged.reduce((s, v) => s + v, 0)) + if (Math.abs(untagged) >= 0.005) anyUntagged = true + + const values = [...tagged, untagged] + if (Math.abs(total) < 0.005 && values.every((v) => Math.abs(v) < 0.005)) continue + + accountRows.push({ + account_number: tbRow.account_number, + account_name: tbRow.account_name, + account_class: tbRow.account_class, + values, + total, + }) + } + + // Drop the untagged column when everything is tagged. + const columnCount = codes.length + (anyUntagged ? 1 : 0) + if (!anyUntagged) { + for (const row of accountRows) row.values = row.values.slice(0, codes.length) + } + + const columns: DimensionPnlColumn[] = [ + ...codes.map((code) => ({ code, name: valueNames.get(code) ?? null })), + ...(anyUntagged ? [{ code: null, name: null }] : []), + ] + + // ── Groups by class, resultatrapport-style ────────────────────── + const groups: DimensionPnlGroup[] = [] + for (const klass of [3, 4, 5, 6, 7, 8] as const) { + const rows = accountRows + .filter((r) => r.account_class === klass) + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + if (rows.length === 0) continue + + const subtotals = Array.from({ length: columnCount }, (_, i) => + round2(rows.reduce((s, r) => s + r.values[i], 0)) + ) + groups.push({ + class: klass, + class_label: CLASS_LABELS[klass], + rows: rows.map(({ account_class: _klass, ...row }) => row), + subtotals, + subtotal_total: round2(rows.reduce((s, r) => s + r.total, 0)), + }) + } + + const netPerColumn = Array.from({ length: columnCount }, (_, i) => + round2(accountRows.reduce((s, r) => s + r.values[i], 0)) + ) + // Same aggregation as resultatrapport's net_result_current: sum of the + // per-account rounded signed amounts over the filterPnl scope. + const netTotal = round2(pnlRows.reduce((s, r) => s + round2(signedAmount(r)), 0)) + + return { + dimension: { + sie_dim_no: sieDimNo, + name: dimRow?.name ?? defaultDimensionName(sieDimNo), + }, + columns, + groups, + net_per_column: netPerColumn, + net_total: netTotal, + // The label reflects actual coverage: always cumulative from + // period_start (closing-balance semantics), capped at toDate. + period: { + start: period.period_start, + end: options?.toDate ?? period.period_end, + }, + } +} + +function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] { + return rows.filter( + (r) => r.account_class >= 3 && r.account_class <= 8 && r.account_number !== '8999' + ) +} + +// credit − debit: revenue positive, expenses negative — resultatrapport's +// exact sign convention, so cells compare 1:1 with that report. +function signedAmount(row: TrialBalanceRow): number { + return row.closing_credit - row.closing_debit +} + +// Canonical form matching normalizeLineDimensions: trimmed, non-empty. +function normalizeCode(raw: string | undefined): string | null { + const trimmed = typeof raw === 'string' ? raw.trim() : '' + return trimmed.length > 0 ? trimmed : null +} + +function defaultDimensionName(sieDimNo: string): string { + if (sieDimNo === '1') return 'Kostnadsställe' + if (sieDimNo === '6') return 'Projekt' + return `Dimension ${sieDimNo}` +} + +function round2(n: number): number { + return roundOre(n) +} diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts index 1d724d42..a67386e7 100644 --- a/lib/reports/general-ledger.ts +++ b/lib/reports/general-ledger.ts @@ -12,6 +12,8 @@ export interface GeneralLedgerLine { debit: number credit: number balance: number + /** SIE dim → code tags on the line; omitted when untagged. */ + dimensions?: Record } export interface GeneralLedgerAccount { @@ -49,8 +51,17 @@ export async function generateGeneralLedger( companyId: string, periodId: string, accountFrom?: string, - accountTo?: string + accountTo?: string, + options?: { + /** SIE dim → code filter ({"6":"P001"}). Opening balances are dropped + * when set — they are company-wide and cannot be dimension-scoped. */ + dimensions?: Record + } ): Promise { + const dimensionFilter = + options?.dimensions && Object.keys(options.dimensions).length > 0 + ? options.dimensions + : undefined // Get fiscal period dates and opening_balance_entry_id const { data: period } = await supabase @@ -71,8 +82,10 @@ export async function generateGeneralLedger( // Convert to net balance (debit - credit) for GL running balance const openingBalances = new Map() - for (const [accNum, { debit, credit }] of openingByAccount) { - openingBalances.set(accNum, debit - credit) + if (!dimensionFilter) { + for (const [accNum, { debit, credit }] of openingByAccount) { + openingBalances.set(accNum, debit - credit) + } } // ── Period lines via joined query (excluding OB entry) ───────── @@ -88,6 +101,7 @@ export async function generateGeneralLedger( debit_amount: number credit_amount: number journal_entry_id: string + dimensions: Record | null journal_entries: { entry_date: string voucher_number: number @@ -98,11 +112,16 @@ export async function generateGeneralLedger( }>(({ from, to }) => { let query = supabase .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)') + .select('id, account_number, debit_amount, credit_amount, journal_entry_id, dimensions, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', periodId) .in('journal_entries.status', ['posted', 'reversed']) + if (dimensionFilter) { + // jsonb containment (@>) — served by idx_jel_dimensions_gin. + query = query.contains('dimensions', dimensionFilter) + } + if (obEntryId) { query = query.neq('journal_entry_id', obEntryId) } @@ -144,6 +163,8 @@ export async function generateGeneralLedger( accountLines.set(accNum, []) } + const hasDims = line.dimensions && Object.keys(line.dimensions).length > 0 + accountLines.get(accNum)!.push({ date: entry.entry_date, voucher_series: entry.voucher_series || 'A', @@ -154,6 +175,7 @@ export async function generateGeneralLedger( debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100, credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100, balance: 0, // computed below + ...(hasDims ? { dimensions: line.dimensions as Record } : {}), }) } diff --git a/lib/reports/income-statement.ts b/lib/reports/income-statement.ts index 2f5e8e10..8db31ff4 100644 --- a/lib/reports/income-statement.ts +++ b/lib/reports/income-statement.ts @@ -15,7 +15,12 @@ export async function generateIncomeStatement( supabase: SupabaseClient, companyId: string, fiscalPeriodId: string, - options?: { fromDate?: string; toDate?: string } + options?: { + fromDate?: string + toDate?: string + /** SIE dim → code filter ({"6":"P001"}). P&L-safe: see trial-balance.ts. */ + dimensions?: Record + } ): Promise { // Exclude year-end closing entries: after closing, P&L accounts (3-8) are // zeroed by the closing verifikat (8999 → 2099). Including them collapses @@ -25,6 +30,7 @@ export async function generateIncomeStatement( excludeYearEndClosing: true, fromDate: options?.fromDate, toDate: options?.toDate, + dimensions: options?.dimensions, }) // Filter to income/expense accounts (class 3-8) diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts index 89a0b292..0ac091a0 100644 --- a/lib/reports/monthly-breakdown.ts +++ b/lib/reports/monthly-breakdown.ts @@ -27,7 +27,12 @@ const MONTH_LABELS = [ export async function generateMonthlyBreakdown( supabase: SupabaseClient, companyId: string, - fiscalPeriodId: string + fiscalPeriodId: string, + options?: { + /** SIE dim → code filter ({"6":"P001"}). Without it a dimension-scoped + * KPI view would silently chart company-wide months. */ + dimensions?: Record + } ): Promise { // Get the fiscal period date range @@ -46,8 +51,8 @@ export async function generateMonthlyBreakdown( // eslint-disable-next-line @typescript-eslint/no-explicit-any let lines: any[] try { - lines = await fetchAllRows(({ from, to }) => - supabase + lines = await fetchAllRows(({ from, to }) => { + let query = supabase .from('journal_entry_lines') .select(` account_number, @@ -63,10 +68,15 @@ export async function generateMonthlyBreakdown( .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .eq('journal_entries.company_id', companyId) .eq('journal_entries.status', 'posted') - // Stable total order for correct paging (see fetch-all.ts). - .order('id', { ascending: true }) - .range(from, to) - ) + + if (options?.dimensions && Object.keys(options.dimensions).length > 0) { + // jsonb containment (@>) — served by idx_jel_dimensions_gin. + query = query.contains('dimensions', options.dimensions) + } + + // Stable total order for correct paging (see fetch-all.ts). + return query.order('id', { ascending: true }).range(from, to) + }) } catch { return { months: [] } } diff --git a/lib/reports/operational-report-pdf-template.tsx b/lib/reports/operational-report-pdf-template.tsx index 44678bb3..907b8331 100644 --- a/lib/reports/operational-report-pdf-template.tsx +++ b/lib/reports/operational-report-pdf-template.tsx @@ -252,9 +252,11 @@ interface CommonHeaderProps { title: string company: CompanySettings period: { start: string; end: string } + /** Partial-view disclosure (dimension-filtered exports, BFNAR 2013:2). */ + filterNote?: string } -function HeaderBlock({ title, company, period }: CommonHeaderProps) { +function HeaderBlock({ title, company, period, filterNote }: CommonHeaderProps) { const companyDisplayName = company.company_name || '' const periodLabel = period.start && period.end ? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}` @@ -269,6 +271,9 @@ function HeaderBlock({ title, company, period }: CommonHeaderProps) { {periodLabel && ( Period: {periodLabel} )} + {filterNote && ( + {filterNote} + )} {company.company_name && ( @@ -307,15 +312,17 @@ interface ResultatrapportPDFProps { report: ResultatrapportReport company: CompanySettings generatedAt: string + /** Partial-view disclosure line (dimension-filtered exports). */ + filterNote?: string } -export function ResultatrapportPDF({ report, company, generatedAt }: ResultatrapportPDFProps) { +export function ResultatrapportPDF({ report, company, generatedAt, filterNote }: ResultatrapportPDFProps) { const hasPrior = report.prior_period !== null return ( - + Konto diff --git a/lib/reports/resultatrapport.ts b/lib/reports/resultatrapport.ts index 80ff557a..e9c133a0 100644 --- a/lib/reports/resultatrapport.ts +++ b/lib/reports/resultatrapport.ts @@ -32,7 +32,12 @@ export async function generateResultatrapport( supabase: SupabaseClient, companyId: string, fiscalPeriodId: string, - options?: { fromDate?: string; toDate?: string } + options?: { + fromDate?: string + toDate?: string + /** SIE dim → code filter ({"6":"P001"}). P&L-safe: see trial-balance.ts. */ + dimensions?: Record + } ): Promise { const { data: period } = await supabase .from('fiscal_periods') @@ -51,6 +56,7 @@ export async function generateResultatrapport( const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { fromDate: options?.fromDate, toDate: options?.toDate, + dimensions: options?.dimensions, }) const currentRows = filterPnl(currentTb.rows) @@ -58,9 +64,13 @@ export async function generateResultatrapport( // compared against a full prior year would be misleading; until we ship a // proper "same window, prior year" comparison the cleanest move is to // drop the prior column entirely when the user narrows the range. + // Same rule for a dimension filter: project codes are time-limited under + // K2/K3 (registry start/end dates), so "this code last year" may be a + // different project entirely — drop the column rather than compare + // unrelated activity (#862 review). let priorRows: TrialBalanceRow[] = [] let priorPeriodInfo: { start: string; end: string } | null = null - const isFullPeriod = !options?.fromDate && !options?.toDate + const isFullPeriod = !options?.fromDate && !options?.toDate && !options?.dimensions if (isFullPeriod) { // Prefer the explicit continuity chain; fall back to the period that ends // immediately before this one. The fallback keeps the comparison working diff --git a/lib/reports/source-lines.ts b/lib/reports/source-lines.ts index 3dcc30cc..19d7967b 100644 --- a/lib/reports/source-lines.ts +++ b/lib/reports/source-lines.ts @@ -20,6 +20,8 @@ export type ReportSourceLine = { description: string debit: number credit: number + /** SIE dim → code tags on the line; omitted when untagged. */ + dimensions?: Record } /** diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index f4e45a35..262882bf 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -17,6 +17,14 @@ import type { TrialBalanceRow } from '@/types' * period activity to `[fromDate, toDate]`. Defaults equal `period_start` and * `period_end` — identical to the no-options behaviour. * + * When `dimensions` is passed (map of SIE dim number → object code, e.g. + * `{"6":"P001"}`, AND across keys), both line queries filter with jsonb + * containment (`dimensions @> …`, served by idx_jel_dimensions_gin). The + * result is then a PARTIAL view: opening balances from year-end closing are + * company-wide, so callers must only use the filter for P&L-style reports + * (classes 3–8) where IB is immaterial — never for balance/statutory reports. + * The catalog whitelist + statutory-guard test pin this. + * * Uses joined queries with pagination to handle any number of entries. * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. */ @@ -28,6 +36,7 @@ export async function generateTrialBalance( excludeYearEndClosing?: boolean fromDate?: string toDate?: string + dimensions?: Record } ): Promise<{ rows: TrialBalanceRow[] @@ -44,10 +53,23 @@ export async function generateTrialBalance( .eq('company_id', companyId) .single() + const dimensionFilter = + options?.dimensions && Object.keys(options.dimensions).length > 0 + ? options.dimensions + : undefined + // ── Opening balances (IB) at period_start ────────────────────── - const { balances: openingBalances, obEntryId } = await getOpeningBalances( + const { balances: obBalances, obEntryId } = await getOpeningBalances( supabase, companyId, period ) + // A dimension-filtered view cannot use company-wide opening balances (the + // OB entry and the prior-period RPC are not dimension-aware). Drop them so + // every reported amount is dimension-scoped activity — correct for the P&L + // reports the filter is whitelisted for, and never fabricates balances if + // misapplied. obEntryId is still needed to exclude the OB entry from lines. + const openingBalances = dimensionFilter + ? new Map() + : obBalances // ── Roll IB forward from period_start up to fromDate ─────────── // When the caller requests a sub-range starting after period_start, the @@ -74,6 +96,11 @@ export async function generateTrialBalance( .gte('journal_entries.entry_date', period.period_start) .lt('journal_entries.entry_date', options.fromDate) + if (dimensionFilter) { + // jsonb containment (@>) — served by idx_jel_dimensions_gin. + query = query.contains('dimensions', dimensionFilter) + } + if (obEntryId) { query = query.neq('journal_entry_id', obEntryId) } @@ -127,6 +154,11 @@ export async function generateTrialBalance( query = query.lte('journal_entries.entry_date', options.toDate) } + if (dimensionFilter) { + // jsonb containment (@>) — served by idx_jel_dimensions_gin. + query = query.contains('dimensions', dimensionFilter) + } + if (obEntryId) { query = query.neq('journal_entry_id', obEntryId) } diff --git a/messages/en.json b/messages/en.json index 53040369..5c2b7a8e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4211,6 +4211,7 @@ "name_kpi": "Key figures", "name_sie_export": "SIE export", "desc_resultatrapport": "Revenue less costs for the period", + "desc_dimension_pnl": "Income statement per dimension — one column per project or cost centre", "desc_balansrapport": "Assets, liabilities and equity by account", "desc_trial_balance": "All accounts with opening and closing balances", "desc_kpi": "Margin, liquidity and other key figures", @@ -4236,6 +4237,7 @@ "group_ledgers": "Ledgers", "group_reconciliation": "Reconciliation", "name_resultatrapport": "Income statement (interim)", + "name_dimension_pnl": "Profit & loss by project/cost centre", "name_balansrapport": "Balance sheet (interim)", "name_trial_balance": "Trial balance", "name_income_statement": "Income statement", diff --git a/messages/sv.json b/messages/sv.json index 68ba16d7..5d5df36d 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4211,6 +4211,7 @@ "name_kpi": "Nyckeltal", "name_sie_export": "SIE-export", "desc_resultatrapport": "Intäkter minus kostnader för perioden", + "desc_dimension_pnl": "Resultatrapport per dimension — en kolumn per projekt eller kostnadsställe", "desc_balansrapport": "Tillgångar, skulder och eget kapital per konto", "desc_trial_balance": "Alla konton med ingående och utgående saldo", "desc_kpi": "Marginal, likviditet och andra nyckeltal", @@ -4236,6 +4237,7 @@ "group_ledgers": "Huvudböcker", "group_reconciliation": "Avstämning", "name_resultatrapport": "Resultatrapport", + "name_dimension_pnl": "Resultat per projekt/kostnadsställe", "name_balansrapport": "Balansrapport", "name_trial_balance": "Saldobalans", "name_income_statement": "Resultaträkning", diff --git a/types/index.ts b/types/index.ts index 9728cdf1..09287264 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1651,6 +1651,39 @@ export interface ResultatrapportReport { prior_period: { start: string; end: string } | null } +// Resultat per projekt/kostnadsställe — value-as-column P&L matrix over one +// SIE dimension. `code: null` marks the "(Utan dimension)" residual bucket, +// which is computed as Totalt − tagged columns so every row sums exactly to +// its resultatrapport counterpart. +export interface DimensionPnlColumn { + code: string | null + name: string | null +} + +export interface DimensionPnlRow { + account_number: string + account_name: string + values: number[] + total: number +} + +export interface DimensionPnlGroup { + class: number + class_label: string + rows: DimensionPnlRow[] + subtotals: number[] + subtotal_total: number +} + +export interface DimensionPnlReport { + dimension: { sie_dim_no: string; name: string } + columns: DimensionPnlColumn[] + groups: DimensionPnlGroup[] + net_per_column: number[] + net_total: number + period: { start: string; end: string } +} + export interface BalansrapportRow { account_number: string account_name: string