Files
accounted/lib/reports/dimension-pnl.ts
T
Jakob Wennberg a8801430f4 fix(reports): stop driving report queries from the unfiltered journal_entry_lines side (#971)
* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side

Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.

Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:

1. fetch matching journal_entries (id + caller-selected columns)
   filtered by company_id / fiscal_period_id / status / entry_date /
   source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
   chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
   (line.journal_entries = {...}, aliasable) and sort lines by id
   ascending to preserve the old .order('id') semantics.

Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.

Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.

From the 2026-07-09 production log triage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reports): stop echoing raw error messages from the general-ledger route

The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.

From the 2026-07-09 production log triage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:03:28 +02:00

261 lines
9.7 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 type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
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 fetchEntryLines<{
id: string
account_number: string
debit_amount: number
credit_amount: number
dimensions: Record<string, string>
}>({
supabase,
lineColumns: 'id, account_number, debit_amount, credit_amount, dimensions',
filterEntries: (q: EntryLinesQuery) => {
let query = q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
if (options?.toDate) {
query = query.lte('entry_date', options.toDate)
}
return query
},
// Key-existence via the extracted text field: dims 1/6 ride the partial
// expression indexes (idx_jel_dimensions_dim1/dim6).
filterLines: (q: EntryLinesQuery) => q.not(`dimensions->>${sieDimNo}`, 'is', null),
})
// 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)
}