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:
co-authored by
Claude Fable 5
parent
da859d7236
commit
a8801430f4
@@ -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: [] })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user