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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user