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>
This commit is contained in:
Jakob Wennberg
2026-07-10 11:03:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent da859d7236
commit a8801430f4
34 changed files with 1456 additions and 591 deletions
+24 -1
View File
@@ -342,11 +342,13 @@ describe('updateAsset: acquisition-basis correction guard', () => {
/**
* Minimal Supabase mock that captures the final UPDATE payload. updateAsset's
* correction guard touches three tables:
* correction guard touches four tables:
* - 'assets' → getAsset (.maybeSingle) and the update (.single)
* - 'depreciation_schedules' → hasPostedDepreciation (1st call, head {count})
* then hasManualDepreciationPosted (2nd call,
* .select('journal_entry_id') → {data})
* - 'journal_entries' → hasManualDepreciationPosted entries step of the
* two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts)
* - 'journal_entry_lines' → hasManualDepreciationPosted ledger scan → {data}
*/
function mockForUpdate(
@@ -382,11 +384,32 @@ describe('updateAsset: acquisition-basis correction guard', () => {
)
return chain
}
if (table === 'journal_entries') {
// Entries step of the two-step fetch: derive the entry ids from the
// line fixtures so the chunked line query has ids to ask for.
const entryIds = [
...new Set((opts.accumulatedCredits ?? []).map((l) => l.journal_entry_id)),
]
const chain: Record<string, unknown> = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
chain.range = vi.fn(() => chain)
chain.then = (resolve: (v: unknown) => void) =>
resolve({
data: entryIds.length > 0 ? entryIds.map((id) => ({ id })) : [{ id: 'entry-none' }],
error: null,
})
return chain
}
if (table === 'journal_entry_lines') {
const chain: Record<string, unknown> = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.gt = vi.fn(() => chain)
chain.in = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
chain.range = vi.fn(() => chain)
chain.then = (resolve: (v: unknown) => void) =>
resolve({ data: opts.accumulatedCredits ?? [], error: null })
return chain
@@ -5,19 +5,24 @@ import {
} from '../tax-provision/sarskild-loneskatt-calculator'
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
const builder = {
select: vi.fn(),
eq: vi.fn(),
gte: vi.fn(),
lte: vi.fn(),
then: undefined as unknown as (resolve: (v: { data: unknown; error: unknown }) => void) => void,
// The calculator uses the two-step entry-lines fetch
// (lib/bookkeeping/entry-lines.ts): call 1 reads journal_entries, call 2
// reads journal_entry_lines for those entry ids.
const responses: Array<{ data: unknown; error: unknown }> = [
{ data: [{ id: 'entry-1' }], error: null },
{ data: rows, error: null },
]
let call = 0
const makeBuilder = () => {
const result = responses[call++] ?? { data: null, error: null }
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) => resolve(result)
return b
}
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
builder.gte.mockReturnValue(builder)
builder.lte.mockReturnValue(builder)
builder.then = (resolve) => resolve({ data: rows, error: null })
return { from: vi.fn().mockReturnValue(builder) } as unknown as Parameters<
return { from: vi.fn().mockImplementation(() => makeBuilder()) } as unknown as Parameters<
typeof calculateSarskildLoneskatt
>[0]
}
+36 -25
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import type {
Asset,
AssetCategory,
@@ -269,21 +270,26 @@ async function hasManualDepreciationPosted(
.filter((id): id is string => id !== null),
)
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, journal_entries!inner(company_id, status)')
.eq('account_number', asset.bas_accumulated_account)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.gt('credit_amount', 0)
if (error) {
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts): posted
// entries for the company first, then their lines on the accumulated
// account with a credit.
let lines: { journal_entry_id: string }[]
try {
lines = await fetchEntryLines<{ journal_entry_id: string }>({
supabase,
lineColumns: 'journal_entry_id',
filterEntries: (q: EntryLinesQuery) =>
q.eq('company_id', companyId).eq('status', 'posted'),
filterLines: (q: EntryLinesQuery) =>
q.eq('account_number', asset.bas_accumulated_account).gt('credit_amount', 0),
attachEntriesAs: null,
})
} catch (err) {
throw new Error(
`Failed to scan ledger depreciation for asset ${asset.id}: ${error.message}`,
`Failed to scan ledger depreciation for asset ${asset.id}: ${err instanceof Error ? err.message : String(err)}`,
)
}
return ((lines ?? []) as { journal_entry_id: string }[]).some(
(line) => !siblingEngineEntries.has(line.journal_entry_id),
)
return lines.some((line) => !siblingEngineEntries.has(line.journal_entry_id))
}
export async function updateAsset(
@@ -861,24 +867,29 @@ export async function getAccumulatedDepreciationAsOf(
}
if (!asset) return 0
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)',
)
.eq('account_number', asset.bas_expense_account)
.eq('journal_entries.company_id', asset.company_id)
.eq('journal_entries.status', 'posted')
.lte('journal_entries.entry_date', asOfDate)
if (error) {
let data: Row[]
try {
data = await fetchEntryLines<Row>({
supabase,
lineColumns: 'debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', asset.company_id)
.eq('status', 'posted')
.lte('entry_date', asOfDate),
filterLines: (q: EntryLinesQuery) =>
q.eq('account_number', asset.bas_expense_account),
attachEntriesAs: null,
})
} catch (err) {
throw new Error(
`Failed to sum accumulated depreciation for asset ${assetId}: ${error.message}`,
`Failed to sum accumulated depreciation for asset ${assetId}: ${err instanceof Error ? err.message : String(err)}`,
)
}
return ((data ?? []) as Row[]).reduce((sum, row) => {
return data.reduce((sum, row) => {
// Expense account: normal balance is debit. Net = debit − credit so
// any storno (reversal) is netted out.
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import type { ProposedDisposition } from '../types'
@@ -67,25 +68,32 @@ export async function sumPostedYearEndDispositions(
companyId: string,
fiscalPeriodId: string,
): Promise<number> {
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type)',
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.eq('journal_entries.status', 'posted')
.eq('journal_entries.source_type', 'year_end')
if (error) {
throw new Error(`Failed to read posted dispositions: ${error.message}`)
}
type Row = {
account_number: string
debit_amount: number | string | null
credit_amount: number | string | null
}
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
let data: Row[]
try {
data = await fetchEntryLines<Row>({
supabase,
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
.eq('source_type', 'year_end'),
attachEntriesAs: null,
})
} catch (err) {
throw new Error(
`Failed to read posted dispositions: ${err instanceof Error ? err.message : String(err)}`,
)
}
let effect = 0
for (const row of (data ?? []) as Row[]) {
for (const row of data) {
const acc = row.account_number
if (!(acc.startsWith('88') || acc === '7533')) continue
effect += (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import type { ProposedDisposition } from '../types'
/** Särskild löneskatt på pensionskostnader (SLP). 24.26 % per SLF 1991:687. */
@@ -35,23 +36,29 @@ export async function calculateSarskildLoneskatt(
fiscalPeriodId: string,
options: { manualAdjustment?: number } = {},
): Promise<ProposedDisposition | null> {
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status)',
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
let data: Row[]
try {
data = await fetchEntryLines<Row>({
supabase,
lineColumns: 'account_number, debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) =>
q
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted'),
filterLines: (q: EntryLinesQuery) =>
q.gte('account_number', '7410').lte('account_number', '7419'),
attachEntriesAs: null,
})
} catch (err) {
throw new Error(
`Failed to fetch pension costs: ${err instanceof Error ? err.message : String(err)}`,
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.eq('journal_entries.status', 'posted')
.gte('account_number', '7410')
.lte('account_number', '7419')
if (error) {
throw new Error(`Failed to fetch pension costs: ${error.message}`)
}
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
const pensionCostsBooked = ((data ?? []) as Row[]).reduce((sum, row) => {
const pensionCostsBooked = data.reduce((sum, row) => {
// Cost account: normal balance is debit, so net = debit − credit.
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
}, 0)