Files
accounted/lib/reports/__tests__/dimension-pnl.test.ts
T
Jakob WennbergandClaude Fable 5 01dbef4015 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>
2026-07-02 15:20:47 +02:00

252 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
)
})
})