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:
co-authored by
Claude Fable 5
parent
86071334cb
commit
01dbef4015
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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> } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user