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
@@ -1000,10 +1000,18 @@ describe('getReconciliationStatus', () => {
data: [{ amount: 1000, journal_entry_id: 'je-tx', reconciliation_method: 'auto_exact' }],
})
// 2) journal_entry_lines: 50,000 IB debit + 1000 matched debit on 1930
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
{ debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
{ id: 'je-gen1', status: 'posted', source_type: 'opening_balance' },
{ id: 'je-gen2', status: 'posted', source_type: 'bank_import' },
],
})
enqueue({
data: [
{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-gen1' },
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-gen2' },
],
})
// 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration)
@@ -1031,10 +1039,18 @@ describe('getReconciliationStatus', () => {
],
})
// 2) GL lines: 50,000 IB + 1000 booked
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
{ debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
{ id: 'je-gen3', status: 'posted', source_type: 'opening_balance' },
{ id: 'je-gen4', status: 'posted', source_type: 'bank_import' },
],
})
enqueue({
data: [
{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-gen3' },
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-gen4' },
],
})
// 3) RPC: empty
@@ -1054,8 +1070,10 @@ describe('getReconciliationStatus', () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [{ amount: 100, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact' }] })
// Two-step entry-lines fetch: entries page first, then lines by entry id.
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
enqueue({
data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } }],
data: [{ debit_amount: 100, credit_amount: 0, journal_entry_id: 'je-1' }],
})
enqueue({ data: [] })
@@ -1068,16 +1086,24 @@ describe('getReconciliationStatus', () => {
expect(status.is_reconciled).toBe(true)
})
it('handles array-shaped journal_entries embed (Supabase wide typing)', async () => {
// Supabase typings sometimes widen embedded relations to arrays. The
// implementation handles both shapes: verify here.
it('splits IB from period movement via the reattached parent entry', async () => {
// The two-step entry-lines fetch reattaches the parent entry object on
// each line under `journal_entries`; entryOf() reads source_type from it
// to split the IB summary out of the period movement.
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
// Two-step entry-lines fetch: entries page first, then lines by entry id.
enqueue({
data: [
{ debit_amount: 1000, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'opening_balance' }] },
{ debit_amount: 200, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'bank_import' }] },
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance' },
{ id: 'je-mv', status: 'posted', source_type: 'bank_import' },
],
})
enqueue({
data: [
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-ib' },
{ debit_amount: 200, credit_amount: 0, journal_entry_id: 'je-mv' },
],
})
enqueue({ data: [] })
@@ -1105,11 +1131,19 @@ describe('getReconciliationStatus', () => {
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
})
// 2) GL lines on 1930: reversed original (debit 25000), storno (credit
// 25000), correction (debit 25000). All three are summed.
// 25000), correction (debit 25000). All three are summed. Served via
// the two-step entry-lines fetch: entries page, then lines by entry id.
enqueue({
data: [
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
],
})
const lines = [
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-orig' },
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-storno' },
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
]
enqueue({ data: lines })
// 3) RPC: empty
@@ -1143,11 +1177,20 @@ describe('getReconciliationStatus', () => {
enqueue({
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
})
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 24000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
{ debit_amount: 0, credit_amount: 24000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
],
})
enqueue({
data: [
{ debit_amount: 24000, credit_amount: 0, journal_entry_id: 'je-orig' },
{ debit_amount: 0, credit_amount: 24000, journal_entry_id: 'je-storno' },
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
],
})
enqueue({ data: [] })
@@ -1173,11 +1216,20 @@ describe('getReconciliationStatus', () => {
enqueue({
data: [{ amount: 25000, journal_entry_id: 'je-orig', reconciliation_method: 'manual' }],
})
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
],
})
enqueue({
data: [
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-orig' },
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-storno' },
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
],
})
enqueue({ data: [] })
@@ -1199,9 +1251,16 @@ describe('getReconciliationStatus', () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] }) // no bank-feed transactions
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 500, credit_amount: 0, journal_entries: { id: 'je-manual', status: 'posted', source_type: 'manual' } },
{ id: 'je-manual', status: 'posted', source_type: 'manual' },
],
})
enqueue({
data: [
{ debit_amount: 500, credit_amount: 0, journal_entry_id: 'je-manual' },
],
})
enqueue({ data: [] })
@@ -1236,12 +1295,22 @@ describe('getReconciliationStatus', () => {
// 2) GL lines on the account: prior-period movements (6000 + 4000 = 10000,
// dated 2025) that net to the IB, the IB itself (10000, dated 2026-01-01),
// and the current period's movement (5000, dated 2026).
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 6000, credit_amount: 0, journal_entries: { id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-03-31' } },
{ debit_amount: 4000, credit_amount: 0, journal_entries: { id: 'je-p2', status: 'posted', source_type: 'import', entry_date: '2025-09-30' } },
{ debit_amount: 10000, credit_amount: 0, journal_entries: { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' } },
{ debit_amount: 5000, credit_amount: 0, journal_entries: { id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-15' } },
{ id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-03-31' },
{ id: 'je-p2', status: 'posted', source_type: 'import', entry_date: '2025-09-30' },
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
{ id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-15' },
],
})
enqueue({
data: [
{ debit_amount: 6000, credit_amount: 0, journal_entry_id: 'je-p1' },
{ debit_amount: 4000, credit_amount: 0, journal_entry_id: 'je-p2' },
{ debit_amount: 10000, credit_amount: 0, journal_entry_id: 'je-ib' },
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'je-c1' },
],
})
// 3) RPC: empty
@@ -1273,11 +1342,20 @@ describe('getReconciliationStatus', () => {
{ date: '2026-04-10', amount: 3000, journal_entry_id: 'je-c', reconciliation_method: 'manual' },
],
})
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 8000, credit_amount: 0, journal_entries: { id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-05-01' } },
{ debit_amount: 8000, credit_amount: 0, journal_entries: { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' } },
{ debit_amount: 3000, credit_amount: 0, journal_entries: { id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-04-10' } },
{ id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-05-01' },
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
{ id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-04-10' },
],
})
enqueue({
data: [
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'je-p1' },
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'je-ib' },
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'je-c1' },
],
})
enqueue({ data: [] })
@@ -1308,11 +1386,20 @@ describe('getReconciliationStatus', () => {
{ date: '2026-03-15', amount: 3000, journal_entry_id: 'je-mar', reconciliation_method: 'manual' },
],
})
// Two-step entry-lines fetch: entries page first, then lines by entry id
// (parents reattached under journal_entries by the helper).
enqueue({
data: [
{ debit_amount: 9000, credit_amount: 0, journal_entries: { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' } },
{ debit_amount: 2000, credit_amount: 0, journal_entries: { id: 'je-feb1', status: 'posted', source_type: 'import', entry_date: '2026-02-10' } },
{ debit_amount: 3000, credit_amount: 0, journal_entries: { id: 'je-mar1', status: 'posted', source_type: 'import', entry_date: '2026-03-15' } },
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
{ id: 'je-feb1', status: 'posted', source_type: 'import', entry_date: '2026-02-10' },
{ id: 'je-mar1', status: 'posted', source_type: 'import', entry_date: '2026-03-15' },
],
})
enqueue({
data: [
{ debit_amount: 9000, credit_amount: 0, journal_entry_id: 'je-ib' },
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'je-feb1' },
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'je-mar1' },
],
})
enqueue({ data: [] })
+15 -12
View File
@@ -3,6 +3,7 @@ import type { Transaction, ReconciliationMethod } from '@/types'
import { eventBus } from '@/lib/events/bus'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
// ============================================================
// Types
@@ -481,18 +482,20 @@ export async function getReconciliationStatus(
// posted + reversed = the ledger balance, exactly as the trial balance counts
// it. The .in() filter on the query already excludes draft/cancelled.
// Paginated for the same 1000-row-cap reason as the transactions above: a
// silently truncated GL side would corrupt gl_1930_balance and the difference.
const fetchedLines = await fetchAllRows<GlLineRow>(({ from, to }) => {
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
.eq('account_number', bankAccount)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
return glQuery.order('id').range(from, to)
// Fetched via the two-step entry-lines helper (entries first, then lines
// chunked by entry id, both paginated): a silently truncated GL side would
// corrupt gl_1930_balance and the difference. See lib/bookkeeping/entry-lines.ts.
const fetchedLines = await fetchEntryLines<GlLineRow>({
supabase,
entryColumns: 'id, company_id, entry_date, status, source_type',
lineColumns: 'debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) => {
let glQuery = q.eq('company_id', companyId).in('status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('entry_date', dateTo)
return glQuery
},
filterLines: (q: EntryLinesQuery) => q.eq('account_number', bankAccount),
})
// Floor the window at the most recent opening-balance date on this account