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
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
// Resultat per projekt/kostnadsställe — value-as-column P&L matrix over one
// SIE dimension. ?dim_no picks the dimension (default 6, projekt).
export const GET = withRouteContext(
'report.dimension_pnl',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const dimNo = searchParams.get('dim_no') ?? '6'
if (!periodId) {
return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId })
}
if (!/^[1-9]\d{0,3}$/.test(dimNo)) {
return NextResponse.json({ error: 'dim_no must be an SIE dimension number' }, { status: 400 })
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
try {
// Only toDate — the matrix is cumulative from period_start by design
// (closing-balance semantics; see lib/reports/dimension-pnl.ts).
const data = await generateDimensionPnl(supabase, companyId!, periodId, dimNo, {
toDate: parsed.range.toDate,
})
return NextResponse.json({ data })
} catch (err) {
log.error('dimension pnl generation failed', err as Error, { periodId, dimNo })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
+132
View File
@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { generateDimensionPnl } from '@/lib/reports/dimension-pnl'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
import {
reportToWorkbook,
textColumn,
currencyColumn,
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import type { DimensionPnlReport } from '@/types'
// One row per account; the dimension values are dynamic columns, exactly as
// the on-screen matrix renders. Column labels stay Swedish (report surface).
type FlatRow = {
group: string
account_number: string
account_name: string
values: number[]
total: number
}
export const GET = withRouteContext(
'report.dimension_pnl_xlsx',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const dimNo = searchParams.get('dim_no') ?? '6'
if (!periodId) {
return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId })
}
if (!/^[1-9]\d{0,3}$/.test(dimNo)) {
return NextResponse.json({ error: 'dim_no must be an SIE dimension number' }, { status: 400 })
}
const [{ data: companyRow }, { data: period }] = await Promise.all([
supabase
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.single(),
supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single(),
])
if (!period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const parsed = parseReportDateRange(searchParams, period)
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 })
}
try {
// Only toDate — the matrix is cumulative from period_start by design.
const report: DimensionPnlReport = await generateDimensionPnl(
supabase,
companyId!,
periodId,
dimNo,
{ toDate: parsed.range.toDate },
)
const valueHeaders = report.columns.map((c) =>
c.code === null ? '(Utan dimension)' : c.name ? `${c.code} ${c.name}` : c.code,
)
const rows: FlatRow[] = []
for (const g of report.groups) {
for (const r of g.rows) {
rows.push({
group: g.class_label,
account_number: r.account_number,
account_name: r.account_name,
values: r.values,
total: r.total,
})
}
}
rows.push({
group: 'Resultat',
account_number: '',
account_name: 'Beräknat resultat',
values: report.net_per_column,
total: report.net_total,
})
const workbook = reportToWorkbook<FlatRow>([
{
name: `Resultat per ${report.dimension.name}`.slice(0, 31),
columns: [
textColumn('Grupp'),
textColumn('Konto'),
textColumn('Benämning'),
...valueHeaders.map((h) => currencyColumn(h)),
currencyColumn('Totalt'),
],
rows,
mapRow: (r) => [r.group, r.account_number, r.account_name, ...r.values, r.total],
},
])
const filename = xlsxFilename(
'resultat-per-dimension',
companyRow?.company_name ?? '',
report.period.end,
)
return new NextResponse(new Uint8Array(workbook), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
} catch (err) {
log.error('dimension pnl xlsx failed', err as Error, { periodId, dimNo })
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
},
)
+9 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
export const GET = withRouteContext(
'report.general_ledger',
@@ -17,8 +18,15 @@ export const GET = withRouteContext(
return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId })
}
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo)
const data = await generateGeneralLedger(supabase, companyId!, periodId, accountFrom, accountTo, {
dimensions: dimFilter.dimensions,
})
return NextResponse.json({ data })
} catch (err) {
log.error('general ledger generation failed', err as Error, { periodId })
+28 -2
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { requireCompanyId } from '@/lib/company/context'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
import {
reportToWorkbook,
textColumn,
@@ -54,8 +55,15 @@ export async function GET(request: Request) {
.eq('company_id', companyId)
.single()
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo)
const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo, {
dimensions: dimFilter.dimensions,
})
// Flatten accounts + their lines into a single sheet. Each account contributes
// an opening-balance row, its lines (with running balance), and a closing
@@ -99,6 +107,24 @@ export async function GET(request: Request) {
})
}
// Partial-view disclosure: a filtered huvudbok starts balance accounts
// at zero IB (opening balances cannot be dimension-scoped) — the export
// must say so or a project-filtered ledger reads as a full one.
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
if (disclosure) {
rows.unshift({
account_number: disclosure,
account_name: '',
date: null as unknown as Date,
voucher: '',
description: 'Ingående balanser ingår inte i filtrerad vy',
source_type: '',
debit: null as unknown as number,
credit: null as unknown as number,
balance: null as unknown as number,
})
}
const buffer = reportToWorkbook<FlatRow>([
{
name: 'Huvudbok',
@@ -128,7 +154,7 @@ export async function GET(request: Request) {
},
])
const filename = xlsxFilename('huvudbok', companyRow?.company_name ?? '', report.period.end)
const filename = xlsxFilename(`huvudbok${dimensionFilterFileSuffix(dimFilter.dimensions)}`, companyRow?.company_name ?? '', report.period.end)
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+15 -3
View File
@@ -6,6 +6,7 @@ import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStat
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
// K2/K3 uppställningsform (ÅRL bilaga 2, kostnadsslagsindelad) splits class 8
// into three named blocks with subtotals:
@@ -80,8 +81,16 @@ export async function GET(request: Request) {
const effectiveStart = range.fromDate ?? period.period_start
const effectiveEnd = range.toDate ?? period.period_end
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const report = await generateIncomeStatement(supabase, companyId, periodId, range)
const report = await generateIncomeStatement(supabase, companyId, periodId, {
...range,
dimensions: dimFilter.dimensions,
})
report.period = { start: effectiveStart, end: effectiveEnd }
const operatingResult = Math.round((report.total_revenue - report.total_expenses) * 100) / 100
@@ -196,7 +205,10 @@ export async function GET(request: Request) {
const pdfBuffer = await renderToBuffer(
FinancialStatementPDF({
title: 'Resultaträkning',
// Partial-view disclosure in the document title (BFNAR 2013:2).
title: dimensionFilterDisclosure(dimFilter.dimensions)
? `Resultaträkning — ${dimensionFilterDisclosure(dimFilter.dimensions)}`
: 'Resultaträkning',
groups,
summary,
period: report.period,
@@ -207,7 +219,7 @@ export async function GET(request: Request) {
// "-utkast" suffix keeps the draft status visible even after the file
// leaves the browser — complements the in-document ÅRL 2:7 disclaimer.
const filename = `resultatrakning-${report.period.start}--${report.period.end}-utkast.pdf`
const filename = `resultatrakning${dimensionFilterFileSuffix(dimFilter.dimensions)}-${report.period.start}--${report.period.end}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
+10 -1
View File
@@ -3,6 +3,7 @@ import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
export const GET = withRouteContext(
'report.income_statement',
@@ -34,8 +35,16 @@ export const GET = withRouteContext(
range = parsed.range
}
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const result = await generateIncomeStatement(supabase, companyId!, periodId, range)
const result = await generateIncomeStatement(supabase, companyId!, periodId, {
...range,
dimensions: dimFilter.dimensions,
})
if (period) {
result.period = {
+26 -2
View File
@@ -10,6 +10,7 @@ import {
xlsxFilename,
} from '@/lib/reports/xlsx-export'
import type { IncomeStatementSection } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
interface FlatRow {
section: string
@@ -92,8 +93,16 @@ export async function GET(request: Request) {
const range = parsedRange.range
const effectiveEnd = range.toDate ?? period.period_end
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const report = await generateIncomeStatement(supabase, companyId, periodId, range)
const report = await generateIncomeStatement(supabase, companyId, periodId, {
...range,
dimensions: dimFilter.dimensions,
})
const revenueRows = flatten(
report.revenue_sections,
@@ -137,6 +146,21 @@ export async function GET(request: Request) {
]
const mapRow = (r: FlatRow) => [r.section, r.account_number, r.account_name, r.amount]
// Partial-view disclosure on every sheet — any tab opened alone must
// still identify the export as filtered (BFNAR 2013:2).
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
if (disclosure) {
const note: FlatRow = {
section: disclosure,
account_number: '',
account_name: '',
amount: null as unknown as number,
}
for (const sheetRows of [revenueRows, expenseRows, financialRows, summaryRows]) {
sheetRows.unshift(note)
}
}
const buffer = reportToWorkbook<FlatRow>([
{ name: 'Intäkter', columns, rows: revenueRows, mapRow },
{ name: 'Kostnader', columns, rows: expenseRows, mapRow },
@@ -145,7 +169,7 @@ export async function GET(request: Request) {
])
const filename = xlsxFilename(
'resultatrakning',
`resultatrakning${dimensionFilterFileSuffix(dimFilter.dimensions)}`,
companyRow?.company_name ?? '',
effectiveEnd,
)
+22 -3
View File
@@ -13,6 +13,7 @@ import {
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { requireCompanyId } from '@/lib/company/context'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
import type { KPIReport, KPIPreferences } from '@/types'
export async function GET(request: Request) {
@@ -39,6 +40,17 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
// Dimension filter applies to the P&L-side KPIs only (net result, revenue/
// expenses, months, expense composition). Balance-side KPIs (cash, VAT,
// receivables) and supplier/invoice aggregates stay company-wide — a
// dimension-scoped "cash position" would be silently wrong, not filtered.
// The KPI view hides those tiles when a filter is active.
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
const dimensions = dimFilter.dimensions
// Load user preferences for account overrides
const { data: prefsData } = await supabase
.from('extension_data')
@@ -59,11 +71,12 @@ export async function GET(request: Request) {
monthlyBreakdown,
paidInvoicesResult,
topSuppliersResult,
filteredTrialBalance,
] = await Promise.all([
generateIncomeStatement(supabase, companyId, periodId),
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId),
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
supabase
.from('invoices')
.select('invoice_date, paid_at')
@@ -77,6 +90,12 @@ export async function GET(request: Request) {
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited'),
// Second, dimension-scoped TB only when filtered — feeds the expense
// composition (classes 4–7, P&L) without touching the unfiltered TB the
// balance-side KPIs read.
dimensions
? generateTrialBalance(supabase, companyId, periodId, { dimensions })
: Promise.resolve(null),
])
// Cash position — use account overrides if set
@@ -109,7 +128,7 @@ export async function GET(request: Request) {
// normal balance, so amount = closing_debit - closing_credit. Negative
// values (rare reclassifications) are clamped to 0 so the donut renders
// sensibly.
const expenseComposition = trialBalanceResult.rows.reduce(
const expenseComposition = (filteredTrialBalance ?? trialBalanceResult).rows.reduce(
(acc, r) => {
if (r.account_class < 4 || r.account_class > 7) return acc
const amount = r.closing_debit - r.closing_credit
+9 -1
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { requireCompanyId } from '@/lib/company/context'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -20,8 +21,15 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const data = await generateMonthlyBreakdown(supabase, companyId, periodId)
const data = await generateMonthlyBreakdown(supabase, companyId, periodId, {
dimensions: dimFilter.dimensions,
})
return NextResponse.json({ data })
} catch {
return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 })
+13 -2
View File
@@ -6,6 +6,7 @@ import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-templat
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import type { CompanySettings } from '@/types'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -55,18 +56,28 @@ export async function GET(request: Request) {
return NextResponse.json({ error: parsedRange.error }, { status: 400 })
}
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const report = await generateResultatrapport(supabase, companyId, periodId, parsedRange.range)
const report = await generateResultatrapport(supabase, companyId, periodId, {
...parsedRange.range,
dimensions: dimFilter.dimensions,
})
const pdfBuffer = await renderToBuffer(
ResultatrapportPDF({
report,
company: companyRow as CompanySettings,
generatedAt: new Date().toISOString(),
// Partial-view disclosure in the document header (BFNAR 2013:2).
filterNote: dimensionFilterDisclosure(dimFilter.dimensions) ?? undefined,
})
)
const filename = `resultatrapport-${report.period.start}--${report.period.end}.pdf`
const filename = `resultatrapport${dimensionFilterFileSuffix(dimFilter.dimensions)}-${report.period.start}--${report.period.end}.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
+10 -1
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -37,8 +38,16 @@ export async function GET(request: Request) {
range = parsed.range
}
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const result = await generateResultatrapport(supabase, companyId, periodId, range)
const result = await generateResultatrapport(supabase, companyId, periodId, {
...range,
dimensions: dimFilter.dimensions,
})
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
+24 -2
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { generateResultatrapport } from '@/lib/reports/resultatrapport'
import { requireCompanyId } from '@/lib/company/context'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter'
import {
reportToWorkbook,
textColumn,
@@ -58,8 +59,16 @@ export async function GET(request: Request) {
range = parsed.range
}
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
try {
const report = await generateResultatrapport(supabase, companyId, periodId, range)
const report = await generateResultatrapport(supabase, companyId, periodId, {
...range,
dimensions: dimFilter.dimensions,
})
const rows: FlatRow[] = []
for (const g of report.groups) {
@@ -88,6 +97,19 @@ export async function GET(request: Request) {
prior_period: report.net_result_prior,
})
// Partial-view disclosure survives the file boundary: a filtered export
// must never be mistakable for the authoritative report (BFNAR 2013:2).
const disclosure = dimensionFilterDisclosure(dimFilter.dimensions)
if (disclosure) {
rows.unshift({
group: disclosure,
account_number: '',
account_name: '',
current_period: null as unknown as number,
prior_period: null as unknown as number,
})
}
const buffer = reportToWorkbook<FlatRow>([
{
name: 'Resultatrapport',
@@ -110,7 +132,7 @@ export async function GET(request: Request) {
])
const filename = xlsxFilename(
'resultatrapport',
`resultatrapport${dimensionFilterFileSuffix(dimFilter.dimensions)}`,
companyRow?.company_name ?? '',
report.period.end,
)
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
import type { ReportSourceLine } from '@/lib/reports/source-lines'
/**
@@ -41,6 +42,14 @@ export async function GET(
)
}
// Same filter as the parent report, so drill-down totals match the row the
// user expanded (a filtered resultatrapport row must not expand to
// unfiltered lines).
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
// Look up account name (and verify account belongs to the company)
const { data: account } = await supabase
.from('chart_of_accounts')
@@ -85,15 +94,17 @@ export async function GET(
id: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string> | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
journal_entries: any
}>(({ from, to }) =>
supabase
}>(({ from, to }) => {
let query = supabase
.from('journal_entry_lines')
.select(`
id,
debit_amount,
credit_amount,
dimensions,
journal_entry_id,
journal_entries!inner(
id,
@@ -110,8 +121,14 @@ export async function GET(
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.order('id', { ascending: true })
.range(from, to), { dedupeBy: (r) => r.id })
if (dimFilter.dimensions) {
// jsonb containment (@>) — served by idx_jel_dimensions_gin.
query = query.contains('dimensions', dimFilter.dimensions)
}
return query.order('id', { ascending: true }).range(from, to)
}, { dedupeBy: (r) => r.id })
// Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as
// a final deterministic tiebreak for lines sharing a date and voucher number
@@ -124,6 +141,9 @@ export async function GET(
description: row.journal_entries.description || '',
debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
...(row.dimensions && Object.keys(row.dimensions).length > 0
? { dimensions: row.dimensions }
: {}),
}))
allMapped.sort((a, b) => {
const dateComp = a.date.localeCompare(b.date)