feat(dimensions): PR4 reports — dimension-filtered P&L + Resultat per projekt/kostnadsställe (#862)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 15:20:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 86071334cb
commit 01dbef4015
41 changed files with 2694 additions and 129 deletions
+1
View File
@@ -209,6 +209,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
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',
+251
View File
@@ -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<string, MockResult[]>
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
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>): 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<typeof makeClient>
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',
)
})
})
@@ -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)
}
}
})
})
@@ -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<string, MockResult[]>
let containsCalls: { table: string; column: string; value: unknown }[]
function makeBuilder(tableName: string) {
const b: Record<string, unknown> = {}
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<typeof makeClient>
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)
})
})
+42 -3
View File
@@ -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<string> = 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<string> = 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)
}
+69
View File
@@ -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<string, string> }
| { 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, string>): 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, string>): 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`
}
+260
View File
@@ -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<number, string> = {
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<DimensionPnlReport> {
// 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<string, TrialBalanceRow>()
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<string, string>()
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<string, string>
}>(({ 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<string, Map<string, { debit: number; credit: number }>>()
const codesSeen = new Set<string>()
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)
}
+26 -4
View File
@@ -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<string, string>
}
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<string, string>
}
): Promise<GeneralLedgerReport> {
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<string, number>()
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<string, string> | 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<string, string> } : {}),
})
}
+7 -1
View File
@@ -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<string, string>
}
): Promise<IncomeStatementReport> {
// 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)
+17 -7
View File
@@ -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<string, string>
}
): Promise<MonthlyBreakdown> {
// 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: [] }
}
@@ -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 && (
<Text style={styles.period}>Period: {periodLabel}</Text>
)}
{filterNote && (
<Text style={styles.period}>{filterNote}</Text>
)}
</View>
<View style={styles.companyInfo}>
{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 (
<Document>
<Page size="A4" style={styles.page}>
<HeaderBlock title="Resultatrapport" company={company} period={report.period} />
<HeaderBlock title="Resultatrapport" company={company} period={report.period} filterNote={filterNote} />
<View style={styles.tableHeader}>
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
+12 -2
View File
@@ -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<string, string>
}
): Promise<ResultatrapportReport> {
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
+2
View File
@@ -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<string, string>
}
/**
+33 -1
View File
@@ -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<string, string>
}
): 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<string, { debit: number; credit: number }>()
: 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)
}