From a8801430f4fca4d8ed8a4eedf65e3185067f8873 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:03:28 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- app/api/reports/general-ledger/route.ts | 7 +- lib/bokslut/__tests__/asset-service.test.ts | 25 +- .../sarskild-loneskatt-calculator.test.ts | 29 +- lib/bokslut/assets/asset-service.ts | 61 ++-- .../tax-provision/bolagsskatt-calculator.ts | 34 +- .../sarskild-loneskatt-calculator.ts | 35 ++- lib/bookkeeping/__tests__/entry-lines.test.ts | 294 ++++++++++++++++++ lib/bookkeeping/entry-lines.ts | 196 ++++++++++++ .../__tests__/bank-reconciliation.test.ts | 149 +++++++-- lib/reconciliation/bank-reconciliation.ts | 27 +- .../__tests__/ar-reconciliation.test.ts | 28 +- .../__tests__/continuity-check.test.ts | 10 +- lib/reports/__tests__/dimension-pnl.test.ts | 10 +- lib/reports/__tests__/general-ledger.test.ts | 112 +++++-- .../__tests__/journal-register.test.ts | 82 +++-- .../__tests__/monthly-breakdown.test.ts | 112 +++---- .../periodisk-sammanstallning.test.ts | 71 ++++- lib/reports/__tests__/sie-export.test.ts | 59 ++-- .../__tests__/supplier-reconciliation.test.ts | 32 +- .../trial-balance-dimensions.test.ts | 10 +- lib/reports/__tests__/trial-balance.test.ts | 11 +- lib/reports/__tests__/vat-declaration.test.ts | 89 +++++- lib/reports/ar-reconciliation.ts | 38 +-- lib/reports/dimension-pnl.ts | 38 +-- lib/reports/general-ledger.ts | 56 ++-- lib/reports/journal-register.ts | 38 +-- lib/reports/monthly-breakdown.ts | 46 ++- lib/reports/opening-balances.ts | 29 +- lib/reports/periodisk-sammanstallning.ts | 40 +-- lib/reports/rc-basis-gaps.ts | 55 ++-- lib/reports/sie-export.ts | 31 +- lib/reports/supplier-reconciliation.ts | 38 +-- lib/reports/trial-balance.ts | 123 ++++---- lib/reports/vat-declaration.ts | 32 +- 34 files changed, 1456 insertions(+), 591 deletions(-) create mode 100644 lib/bookkeeping/__tests__/entry-lines.test.ts create mode 100644 lib/bookkeeping/entry-lines.ts diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts index 233e8cb3..5ee3b918 100644 --- a/app/api/reports/general-ledger/route.ts +++ b/app/api/reports/general-ledger/route.ts @@ -29,11 +29,10 @@ export const GET = withRouteContext( }) return NextResponse.json({ data }) } catch (err) { + // The raw error message is logged server-side only: it can carry + // internal details (SQL, table names) that must not reach the client. log.error('general ledger generation failed', err as Error, { periodId }) - return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { - requestId, - details: { reason: err instanceof Error ? err.message : 'unknown' }, - }) + return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { requestId }) } }, ) diff --git a/lib/bokslut/__tests__/asset-service.test.ts b/lib/bokslut/__tests__/asset-service.test.ts index 83936641..39c50b38 100644 --- a/lib/bokslut/__tests__/asset-service.test.ts +++ b/lib/bokslut/__tests__/asset-service.test.ts @@ -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 = {} + 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 = {} 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 diff --git a/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts b/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts index f643496f..b6fbe43a 100644 --- a/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts +++ b/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts @@ -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 = {} + 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] } diff --git a/lib/bokslut/assets/asset-service.ts b/lib/bokslut/assets/asset-service.ts index d42b41a8..0f5aa053 100644 --- a/lib/bokslut/assets/asset-service.ts +++ b/lib/bokslut/assets/asset-service.ts @@ -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({ + 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)) diff --git a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts index b8c949df..55a5ff9b 100644 --- a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts +++ b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts @@ -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 { - 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({ + 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) diff --git a/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts b/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts index 9703802c..89a3e2c4 100644 --- a/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts +++ b/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts @@ -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 { - 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({ + 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) diff --git a/lib/bookkeeping/__tests__/entry-lines.test.ts b/lib/bookkeeping/__tests__/entry-lines.test.ts new file mode 100644 index 00000000..17349b74 --- /dev/null +++ b/lib/bookkeeping/__tests__/entry-lines.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { + fetchEntryLines, + fetchLinesByEntryIds, + type EntryLinesQuery, +} from '../entry-lines' + +/** + * Recording variant of the queued Supabase mock: every `.from()` starts a + * chain that consumes the next queued result AND records each chained method + * call (name + args), so tests can assert the query shape the helper builds + * (chunk sizes for `.in()`, forced columns in `.select()`, paging order). + */ +function createRecordingSupabase() { + const queue: { data: unknown; error: unknown }[] = [] + const chains: { method: string; args: unknown[] }[][] = [] + + const enqueue = (result: { data?: unknown; error?: unknown }) => { + queue.push({ data: result.data ?? null, error: result.error ?? null }) + } + + const buildChain = ( + result: { data: unknown; error: unknown }, + chainCalls: { method: string; args: unknown[] }[], + ): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return (...args: unknown[]) => { + chainCalls.push({ method: String(prop), args }) + return buildChain(result, chainCalls) + } + }, + } + return new Proxy({}, handler) + } + + const supabase = { + from: vi.fn().mockImplementation((table: string) => { + const chainCalls: { method: string; args: unknown[] }[] = [ + { method: 'from', args: [table] }, + ] + chains.push(chainCalls) + const result = queue.shift() || { data: null, error: null } + return buildChain(result, chainCalls) + }), + } + + const callOf = (chain: { method: string; args: unknown[] }[], method: string) => + chain.find((c) => c.method === method) + + return { supabase, enqueue, chains, callOf } +} + +function makeEntries(count: number): { id: string; entry_date: string }[] { + // Zero-padded ids keep lexicographic order == numeric order. + return Array.from({ length: count }, (_, i) => ({ + id: `entry-${String(i).padStart(4, '0')}`, + entry_date: '2024-06-15', + })) +} + +describe('fetchEntryLines', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns [] without querying lines when no entries match', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [] }) // journal_entries page + + const lines = await fetchEntryLines({ + supabase: supabase as unknown as SupabaseClient, + lineColumns: 'id, account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => q.eq('company_id', 'company-1'), + }) + + expect(lines).toEqual([]) + // Only the entries query ran: no journal_entry_lines round-trip. + expect(supabase.from).toHaveBeenCalledTimes(1) + expect(supabase.from).toHaveBeenCalledWith('journal_entries') + }) + + it('chunks line fetches at 100 entry ids per .in() query', async () => { + const { supabase, enqueue, chains, callOf } = createRecordingSupabase() + const entries = makeEntries(250) + enqueue({ data: entries }) // journal_entries page + enqueue({ data: [{ id: 'line-1', journal_entry_id: entries[0].id }] }) // chunk 1 + enqueue({ data: [] }) // chunk 2 + enqueue({ data: [] }) // chunk 3 + + const lines = await fetchEntryLines<{ id: string; journal_entry_id: string }>({ + supabase: supabase as unknown as SupabaseClient, + lineColumns: 'id, account_number', + filterEntries: (q: EntryLinesQuery) => q.eq('company_id', 'company-1'), + }) + + expect(lines).toHaveLength(1) + // 1 entries query + 3 line chunks (100 + 100 + 50). + expect(chains).toHaveLength(4) + expect(chains[0][0]).toEqual({ method: 'from', args: ['journal_entries'] }) + const chunkSizes = chains.slice(1).map((chain) => { + expect(chain[0]).toEqual({ method: 'from', args: ['journal_entry_lines'] }) + const inCall = callOf(chain, 'in') + expect(inCall?.args[0]).toBe('journal_entry_id') + return (inCall?.args[1] as string[]).length + }) + expect(chunkSizes).toEqual([100, 100, 50]) + }) + + it('reattaches the parent entry under journal_entries by default', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: [ + { id: 'entry-1', entry_date: '2024-01-15', voucher_number: 1 }, + { id: 'entry-2', entry_date: '2024-02-15', voucher_number: 2 }, + ], + }) + enqueue({ + data: [ + { id: 'line-1', journal_entry_id: 'entry-1', account_number: '1930' }, + { id: 'line-2', journal_entry_id: 'entry-2', account_number: '3001' }, + ], + }) + + const lines = await fetchEntryLines<{ + id: string + journal_entry_id: string + account_number: string + journal_entries: { id: string; entry_date: string; voucher_number: number } + }>({ + supabase: supabase as unknown as SupabaseClient, + entryColumns: 'id, entry_date, voucher_number', + lineColumns: 'id, account_number', + filterEntries: (q: EntryLinesQuery) => q.eq('company_id', 'company-1'), + }) + + expect(lines).toHaveLength(2) + expect(lines[0].journal_entries).toEqual({ + id: 'entry-1', + entry_date: '2024-01-15', + voucher_number: 1, + }) + expect(lines[1].journal_entries.voucher_number).toBe(2) + }) + + it('reattaches under a custom key for aliased embeds', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'entry-1', entry_date: '2024-01-15' }] }) + enqueue({ data: [{ id: 'line-1', journal_entry_id: 'entry-1' }] }) + + const lines = await fetchEntryLines<{ + id: string + journal_entry: { id: string; entry_date: string } + }>({ + supabase: supabase as unknown as SupabaseClient, + entryColumns: 'id, entry_date', + lineColumns: 'id', + filterEntries: (q: EntryLinesQuery) => q, + attachEntriesAs: 'journal_entry', + }) + + expect(lines[0].journal_entry).toEqual({ id: 'entry-1', entry_date: '2024-01-15' }) + expect((lines[0] as Record).journal_entries).toBeUndefined() + }) + + it('skips reattachment when attachEntriesAs is null', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [{ id: 'entry-1' }] }) + enqueue({ data: [{ id: 'line-1', journal_entry_id: 'entry-1' }] }) + + const lines = await fetchEntryLines>({ + supabase: supabase as unknown as SupabaseClient, + lineColumns: 'id', + filterEntries: (q: EntryLinesQuery) => q, + attachEntriesAs: null, + }) + + expect(lines[0].journal_entries).toBeUndefined() + }) + + it('applies filterLines to every chunk query', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: makeEntries(150) }) + enqueue({ data: [] }) + enqueue({ data: [] }) + + const filterLines = vi.fn((q: EntryLinesQuery) => q) + await fetchEntryLines({ + supabase: supabase as unknown as SupabaseClient, + lineColumns: 'id', + filterEntries: (q: EntryLinesQuery) => q, + filterLines, + }) + + // 150 entries -> 2 chunks -> filterLines once per chunk. + expect(filterLines).toHaveBeenCalledTimes(2) + }) + + it('forces id and journal_entry_id into the selects', async () => { + const { supabase, enqueue, chains, callOf } = createRecordingSupabase() + enqueue({ data: [{ id: 'entry-1', entry_date: '2024-01-15' }] }) + enqueue({ data: [] }) + + await fetchEntryLines({ + supabase: supabase as unknown as SupabaseClient, + // Neither list names the forced columns. + entryColumns: 'entry_date', + lineColumns: 'account_number', + filterEntries: (q: EntryLinesQuery) => q, + }) + + const entrySelect = callOf(chains[0], 'select')?.args[0] as string + expect(entrySelect.split(',').map((s) => s.trim())).toContain('id') + const lineSelect = callOf(chains[1], 'select')?.args[0] as string + const lineCols = lineSelect.split(',').map((s) => s.trim()) + expect(lineCols).toContain('id') + expect(lineCols).toContain('journal_entry_id') + // Both queries page on a stable unique order (fetch-all.ts invariant). + expect(callOf(chains[0], 'order')?.args[0]).toBe('id') + expect(callOf(chains[1], 'order')?.args[0]).toBe('id') + }) + + it('propagates entry query errors', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'boom' } }) + + await expect( + fetchEntryLines({ + supabase: supabase as unknown as SupabaseClient, + lineColumns: 'id', + filterEntries: (q: EntryLinesQuery) => q, + }), + ).rejects.toThrow('boom') + }) +}) + +describe('fetchLinesByEntryIds', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns [] for an empty id list without querying', async () => { + const { supabase } = createQueuedMockSupabase() + + const lines = await fetchLinesByEntryIds( + supabase as unknown as SupabaseClient, + [], + 'id, account_number', + ) + + expect(lines).toEqual([]) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('sorts lines by id ascending across chunks', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const entryIds = makeEntries(150).map((e) => e.id) + // Chunk results arrive unsorted relative to each other; ids are + // zero-padded so lexicographic order is deterministic. + enqueue({ + data: [ + { id: 'line-0300', journal_entry_id: entryIds[0] }, + { id: 'line-0100', journal_entry_id: entryIds[1] }, + ], + }) + enqueue({ + data: [ + { id: 'line-0200', journal_entry_id: entryIds[100] }, + ], + }) + + const lines = await fetchLinesByEntryIds<{ id: string }>( + supabase as unknown as SupabaseClient, + entryIds, + 'id', + ) + + expect(lines.map((l) => l.id)).toEqual(['line-0100', 'line-0200', 'line-0300']) + }) + + it('propagates line query errors', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'lines boom' } }) + + await expect( + fetchLinesByEntryIds(supabase as unknown as SupabaseClient, ['entry-1'], 'id'), + ).rejects.toThrow('lines boom') + }) +}) diff --git a/lib/bookkeeping/entry-lines.ts b/lib/bookkeeping/entry-lines.ts new file mode 100644 index 00000000..2fd4d07a --- /dev/null +++ b/lib/bookkeeping/entry-lines.ts @@ -0,0 +1,196 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' + +/** + * Two-step fetch for journal_entry_lines scoped by journal_entries filters. + * + * WHY THIS EXISTS: the previous pattern selected from journal_entry_lines + * with a `journal_entries!inner(...)` embed and put every scope filter on + * the embedded side (`.eq('journal_entries.company_id', ...)`). PostgREST + * compiles that embed to a correlated INNER JOIN LATERAL with a + * parameterized LIMIT inside, which blocks Postgres from reordering the + * join: every report query walked the ENTIRE journal_entry_lines table + * (all tenants) instead of starting from the handful of matching entries. + * Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join, + * against Supabase's 8 s statement_timeout, which 500'd reports and made + * nightly backups fail. + * + * The fix drives the query from the journal_entries side instead: + * 1. fetch the matching journal_entries (id + whatever entry columns the + * caller needs), paginated via fetchAllRows; + * 2. fetch journal_entry_lines with `.in('journal_entry_id', chunk)` in + * chunks of {@link ENTRY_ID_CHUNK_SIZE} ids (URL-length safety), + * paginated per chunk; + * 3. reattach the parent entry object to each line under the same key the + * embed produced (`line.journal_entries = {...}` by default), so call + * sites keep their downstream code unchanged, and sort all lines by id + * ascending to preserve the old `.order('id')` semantics. + */ + +/** Max journal_entry ids per `.in()` filter: keeps the request URL short. */ +const ENTRY_ID_CHUNK_SIZE = 100 + +/** How many line chunks are fetched in parallel. */ +const CHUNK_BATCH_SIZE = 5 + +/** + * PostgREST query builders carry deep generic types that do not survive + * being passed through a callback; the helper only needs "builder in, + * builder out", so the filter callbacks are typed structurally. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EntryLinesQuery = any + +export interface FetchEntryLinesOptions { + supabase: SupabaseClient + /** + * Columns to select from journal_entries. `id` is always included (it is + * needed for chunking, paging order, and reattachment). Defaults to just + * `id` for callers that only use the entry side as a filter. + */ + entryColumns?: string + /** + * Columns to select from journal_entry_lines. `id` and `journal_entry_id` + * are always included (stable paging order + parent reattachment). + */ + lineColumns: string + /** + * Applies the entry-level filters (company_id, fiscal_period_id, status, + * entry_date range, source_type, ...). MUST filter by company_id: this is + * the tenant scope. Filters that used to target the embed + * (`.eq('journal_entries.company_id', x)`) become plain column filters + * here (`.eq('company_id', x)`). + */ + filterEntries: (query: EntryLinesQuery) => EntryLinesQuery + /** + * Optional line-level filters (account_number ranges, jsonb dimension + * containment, ...), applied to every chunk query. + */ + filterLines?: (query: EntryLinesQuery) => EntryLinesQuery + /** + * Key the parent entry object is attached under on each returned line. + * Defaults to 'journal_entries' (the un-aliased PostgREST embed key). + * Pass e.g. 'journal_entry' for call sites that aliased the embed, or + * null to skip reattachment entirely. + */ + attachEntriesAs?: string | null +} + +/** Ensure `required` columns are present in a comma-separated select list. */ +function ensureColumns(select: string, required: string[]): string { + const parts = select + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + if (parts.includes('*')) return parts.join(', ') + for (const col of required) { + if (!parts.includes(col)) parts.push(col) + } + return parts.join(', ') +} + +/** Sort by id ascending; rows without an id keep their relative order. */ +function compareById(a: { id?: unknown }, b: { id?: unknown }): number { + const aId = a.id + const bId = b.id + if (typeof aId !== 'string' || typeof bId !== 'string') return 0 + return aId < bId ? -1 : aId > bId ? 1 : 0 +} + +/** + * Fetch journal_entry_lines for an explicit list of journal_entry ids, in + * chunks of {@link ENTRY_ID_CHUNK_SIZE}, each chunk paginated via + * fetchAllRows. Lines are returned sorted by id ascending. Used directly by + * callers that already hold the parent entries (e.g. SIE export); most call + * sites want {@link fetchEntryLines} instead. + */ +export async function fetchLinesByEntryIds( + supabase: SupabaseClient, + entryIds: string[], + lineColumns: string, + filterLines?: (query: EntryLinesQuery) => EntryLinesQuery +): Promise { + if (entryIds.length === 0) return [] + + const select = ensureColumns(lineColumns, ['id', 'journal_entry_id']) + + const chunks: string[][] = [] + for (let i = 0; i < entryIds.length; i += ENTRY_ID_CHUNK_SIZE) { + chunks.push(entryIds.slice(i, i + ENTRY_ID_CHUNK_SIZE)) + } + + const allLines: TLine[] = [] + for (let i = 0; i < chunks.length; i += CHUNK_BATCH_SIZE) { + const batch = chunks.slice(i, i + CHUNK_BATCH_SIZE) + const results = await Promise.all( + batch.map((chunk) => + fetchAllRows( + ({ from, to }) => { + let query: EntryLinesQuery = supabase + .from('journal_entry_lines') + .select(select) + .in('journal_entry_id', chunk) + if (filterLines) query = filterLines(query) + // Stable total order on the line PK for correct paging within the + // chunk (see fetch-all.ts ordering invariant). + return query.order('id', { ascending: true }).range(from, to) + }, + { dedupeBy: (r) => String((r as { id?: unknown }).id) } + ) + ) + ) + for (const rows of results) allLines.push(...rows) + } + + // Chunk concatenation is not globally ordered; re-sort so callers see the + // same id-ascending order the old single embed query produced. + allLines.sort(compareById) + return allLines +} + +/** + * Fetch journal_entry_lines whose parent journal_entries match + * `filterEntries`, with the parent entry reattached to each line. See the + * module docstring for why this replaces the `journal_entries!inner` embed. + * + * The generic `TLine` is the caller-declared shape of a returned line, + * INCLUDING the attached entry key (e.g. `journal_entries: {...}`). + */ +export async function fetchEntryLines( + options: FetchEntryLinesOptions +): Promise { + const { supabase, lineColumns, filterEntries, filterLines } = options + const entryColumns = ensureColumns(options.entryColumns ?? 'id', ['id']) + const attachKey = + options.attachEntriesAs === undefined + ? 'journal_entries' + : options.attachEntriesAs + + const entries = await fetchAllRows<{ id: string }>( + ({ from, to }) => + filterEntries(supabase.from('journal_entries').select(entryColumns)) + // Stable total order on the entry PK for correct paging. + .order('id', { ascending: true }) + .range(from, to), + { dedupeBy: (e) => e.id } + ) + + if (entries.length === 0) return [] + + const entryById = new Map(entries.map((e) => [e.id, e])) + + const lines = await fetchLinesByEntryIds>( + supabase, + entries.map((e) => e.id), + lineColumns, + filterLines + ) + + if (attachKey) { + for (const line of lines) { + line[attachKey] = entryById.get(line.journal_entry_id as string) + } + } + + return lines as TLine[] +} diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index b17e3709..b71d3ff9 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -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: [] }) diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index a899ab48..872092e5 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -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(({ 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({ + 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 diff --git a/lib/reports/__tests__/ar-reconciliation.test.ts b/lib/reports/__tests__/ar-reconciliation.test.ts index cccb7f34..2c11f36d 100644 --- a/lib/reports/__tests__/ar-reconciliation.test.ts +++ b/lib/reports/__tests__/ar-reconciliation.test.ts @@ -52,6 +52,8 @@ describe('generateARReconciliation', () => { error: null, }, // 1: journal_entry_lines for account 1510 + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 8000, credit_amount: 0, journal_entry_id: 'e1' }, @@ -81,6 +83,8 @@ describe('generateARReconciliation', () => { error: null, }, // 1: journal_entry_lines: manual debit on 1510 creates mismatch + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 5000, credit_amount: 0, journal_entry_id: 'e1' }, @@ -115,6 +119,8 @@ describe('generateARReconciliation', () => { it('handles null invoice data gracefully', async () => { results = [ { data: null, error: null }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e1' }, @@ -134,6 +140,8 @@ describe('generateARReconciliation', () => { it('uses correct debit-normal balance for account 1510 (asset)', async () => { results = [ { data: [], error: null }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 10000, credit_amount: 0, journal_entry_id: 'e1' }, @@ -162,6 +170,8 @@ describe('generateARReconciliation', () => { error: null, }, // 1: 1510 balance = 3 200 SEK + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 3200, credit_amount: 0, journal_entry_id: 'e1' }, @@ -190,6 +200,8 @@ describe('generateARReconciliation', () => { error: null, }, // 1: 1510 balance reflects only the SEK invoice + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' }, @@ -221,6 +233,8 @@ describe('generateARReconciliation', () => { error: null, }, // 1: GL: 1 200 on 1510, 300 on 1513 → combined 1 500 + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 1200, credit_amount: 0, journal_entry_id: 'e1' }, @@ -250,6 +264,8 @@ describe('generateARReconciliation', () => { }, // 1: 1510 lines as returned by posted+reversed: original (reversed debit // 5000), storno (credit 5000), correction (debit 5000). Net = 5000. + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 5000, credit_amount: 0, journal_entry_id: 'reg-reversed' }, @@ -268,10 +284,14 @@ describe('generateARReconciliation', () => { expect(result.is_reconciled).toBe(true) // Guard the actual fix: the 1510/1513 query must include reversed entries. - const statusFilter = calls.find( - (c) => c.method === 'in' && c.args[0] === 'journal_entries.status', + // The status filter now lives on the journal_entries query itself (the + // two-step entry-lines fetch), not on an embedded-side column. The open + // invoices query also filters .in('status', ...), so assert that ONE of + // the status filters is the posted+reversed ledger inclusion rule. + const statusFilters = calls.filter( + (c) => c.method === 'in' && c.args[0] === 'status', ) - expect(statusFilter?.args[1]).toEqual(['posted', 'reversed']) + expect(statusFilters.map((c) => c.args[1])).toContainEqual(['posted', 'reversed']) }) it('uses Math.round for monetary precision', async () => { @@ -282,6 +302,8 @@ describe('generateARReconciliation', () => { ], error: null, }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 66.77, credit_amount: 0, journal_entry_id: 'e1' }, diff --git a/lib/reports/__tests__/continuity-check.test.ts b/lib/reports/__tests__/continuity-check.test.ts index 439481f8..7061e51e 100644 --- a/lib/reports/__tests__/continuity-check.test.ts +++ b/lib/reports/__tests__/continuity-check.test.ts @@ -14,7 +14,15 @@ function makeBuilder(tableName: string) { } const consume = (): MockResult => { const queue = mockResults[tableName] - if (!queue || queue.length === 0) return { data: null, error: null } + if (!queue || queue.length === 0) { + // The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads + // journal_entries before journal_entry_lines. Tests queue line rows + // directly, so default the entries step to one generic entry. + if (tableName === 'journal_entries') { + return { data: [{ id: 'entry-1' }], error: null } + } + return { data: null, error: null } + } return queue.shift()! } b.single = vi.fn().mockImplementation(async () => consume()) diff --git a/lib/reports/__tests__/dimension-pnl.test.ts b/lib/reports/__tests__/dimension-pnl.test.ts index 1fe0c867..99c68fff 100644 --- a/lib/reports/__tests__/dimension-pnl.test.ts +++ b/lib/reports/__tests__/dimension-pnl.test.ts @@ -23,7 +23,15 @@ function makeBuilder(tableName: string) { } const consume = (): MockResult => { const queue = mockResults[tableName] - if (!queue || queue.length === 0) return { data: null, error: null } + if (!queue || queue.length === 0) { + // The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads + // journal_entries before journal_entry_lines. Tests queue line rows + // directly, so default the entries step to one generic entry. + if (tableName === 'journal_entries') { + return { data: [{ id: 'entry-1' }], error: null } + } + return { data: null, error: null } + } return queue.shift()! } b.single = vi.fn().mockImplementation(async () => consume()) diff --git a/lib/reports/__tests__/general-ledger.test.ts b/lib/reports/__tests__/general-ledger.test.ts index 163f12a8..521fd31c 100644 --- a/lib/reports/__tests__/general-ledger.test.ts +++ b/lib/reports/__tests__/general-ledger.test.ts @@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // ============================================================ // Mock: table-keyed result queues +// +// The report fetches lines via the two-step entry-lines helper +// (lib/bookkeeping/entry-lines.ts): journal_entries first, then +// journal_entry_lines by entry id, reattaching the parent entry on each line +// under `journal_entries`. Tests queue entry rows (with the entry fields the +// report reads) and line rows that reference them via journal_entry_id. // ============================================================ type MockResult = { data?: unknown; error?: unknown } @@ -61,10 +67,8 @@ describe('generateGeneralLedger', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, ], - journal_entry_lines: [ - // period lines: empty (prior lines come from RPC, defaults to empty) - { data: [], error: null }, - ], + // No matching entries → the line query is skipped entirely. + journal_entries: [{ data: [], error: null }], } const report = await generateGeneralLedger(supabase, 'company-1', 'period-1') @@ -77,15 +81,24 @@ describe('generateGeneralLedger', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, ], - journal_entry_lines: [ - // period lines (joined with entry data) + journal_entries: [ { data: [ - { account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } }, - { account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } }, - { account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' } }, - { account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entries: { entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' } }, - { account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entries: { entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' } }, + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' }, + { id: 'e2', entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' }, + ], + error: null, + }, + ], + journal_entry_lines: [ + // period lines (parent entry reattached from the entries fetch) + { + data: [ + { account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' }, + { account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1' }, + { account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2' }, + { account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2' }, ], error: null, }, @@ -125,31 +138,40 @@ describe('generateGeneralLedger', () => { }) it('does not double a balance when an unstable page boundary re-serves a line (#790/#791)', async () => { - // Reproduces the doubling bug's mechanism: a paginated query whose order - // was not stable can return the same journal_entry_line on two pages. - // Page 1 must be a FULL page (PAGE_SIZE rows) so fetchAllRows fetches a - // second page; page 2 re-serves the 5010 line. dedupeBy(line id) must - // collapse it so the single 4000 posting totals 4000, not 8000. + // Reproduces the doubling bug's mechanism: a paginated LINE query whose + // order was not stable can return the same journal_entry_line on two + // pages. Page 1 must be a FULL page (PAGE_SIZE rows) so fetchAllRows + // fetches a second page; page 2 re-serves the 5010 line. dedupeBy(line id) + // must collapse it so the single 4000 posting totals 4000, not 8000. const PAGE_SIZE = 1000 const filler = Array.from({ length: PAGE_SIZE - 1 }, (_, i) => ({ id: `f${i}`, account_number: '1930', debit_amount: 0, credit_amount: 0, - journal_entries: { entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual' }, + journal_entry_id: 'e1', })) const rentLine = { id: 'rent-line-1', account_number: '5010', debit_amount: 4000, credit_amount: 0, - journal_entries: { entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual' }, + journal_entry_id: 'e2', } mockResults = { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual' }, + { id: 'e2', entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [...filler, rentLine], error: null }, // page 1: full → triggers page 2 { data: [rentLine], error: null }, // page 2: duplicate of the 5010 line @@ -183,12 +205,20 @@ describe('generateGeneralLedger', () => { error: null, }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' }, + ], + error: null, + }, + ], journal_entry_lines: [ // period lines { data: [ - { account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' } }, - { account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entries: { entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' } }, + { account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' }, + { account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' }, ], error: null, }, @@ -217,13 +247,21 @@ describe('generateGeneralLedger', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' }, + ], + error: null, + }, + ], journal_entry_lines: [ // period lines across multiple accounts { data: [ - { account_number: '1510', debit_amount: 1000, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } }, - { account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } }, - { account_number: '3001', debit_amount: 0, credit_amount: 500, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' } }, + { account_number: '1510', debit_amount: 1000, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' }, + { account_number: '3001', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' }, ], error: null, }, @@ -244,13 +282,23 @@ describe('generateGeneralLedger', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' }, + { id: 'e2', entry_date: '2024-01-10', voucher_number: 1, voucher_series: 'A', description: 'First', source_type: 'manual' }, + { id: 'e3', entry_date: '2024-01-05', voucher_number: 3, voucher_series: 'A', description: 'Earlier date', source_type: 'manual' }, + ], + error: null, + }, + ], journal_entry_lines: [ // period lines: out of order { data: [ - { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entries: { entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' } }, - { account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entries: { entry_date: '2024-01-10', voucher_number: 1, voucher_series: 'A', description: 'First', source_type: 'manual' } }, - { account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entries: { entry_date: '2024-01-05', voucher_number: 3, voucher_series: 'A', description: 'Earlier date', source_type: 'manual' } }, + { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entry_id: 'e2' }, + { account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entry_id: 'e3' }, ], error: null, }, @@ -263,7 +311,7 @@ describe('generateGeneralLedger', () => { const report = await generateGeneralLedger(supabase, 'company-1', 'period-1') const acc = report.accounts[0] - // e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2) + // e3 (Jan 5) first, then e2 (Jan 10, #1), then e1 (Jan 10, #2) expect(acc.lines[0].description).toBe('Earlier date') expect(acc.lines[1].description).toBe('First') expect(acc.lines[2].description).toBe('Second') @@ -274,10 +322,18 @@ describe('generateGeneralLedger', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [ - { account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' } }, + { account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entry_id: 'e1' }, ], error: null, }, diff --git a/lib/reports/__tests__/journal-register.test.ts b/lib/reports/__tests__/journal-register.test.ts index b0a67477..041bbebe 100644 --- a/lib/reports/__tests__/journal-register.test.ts +++ b/lib/reports/__tests__/journal-register.test.ts @@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // ============================================================ // Mock: table-keyed result queues +// +// The report fetches lines via the two-step entry-lines helper +// (lib/bookkeeping/entry-lines.ts): journal_entries first, then +// journal_entry_lines by entry id, reattaching the parent entry on each line +// under `journal_entries`. Tests queue entry rows and line rows that +// reference them via journal_entry_id. // ============================================================ type MockResult = { data?: unknown; error?: unknown } @@ -56,9 +62,8 @@ describe('generateJournalRegister', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, ], - journal_entry_lines: [ - { data: [], error: null }, - ], + // No matching entries → the line query is skipped entirely. + journal_entries: [{ data: [], error: null }], } const report = await generateJournalRegister(supabase, 'company-1', 'period-1') @@ -78,7 +83,6 @@ describe('generateJournalRegister', () => { debit_amount: 0, credit_amount: 0, journal_entry_id: 'e0', - journal_entries: { id: 'e0', entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual', status: 'posted' }, })) const rentLine = { id: 'rent-line-1', @@ -86,13 +90,21 @@ describe('generateJournalRegister', () => { debit_amount: 4000, credit_amount: 0, journal_entry_id: 'e1', - journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual', status: 'posted' }, } mockResults = { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e0', entry_date: '2024-01-02', voucher_number: 1, voucher_series: 'A', description: 'filler', source_type: 'manual', status: 'posted' }, + { id: 'e1', entry_date: '2024-01-15', voucher_number: 2, voucher_series: 'A', description: 'Lokalhyra', source_type: 'manual', status: 'posted' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [...filler, rentLine], error: null }, // page 1: full → triggers page 2 { data: [rentLine], error: null }, // page 2: duplicate of the 5010 line @@ -120,14 +132,23 @@ describe('generateJournalRegister', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', source_type: 'invoice', status: 'posted' }, + { id: 'e2', entry_date: '2024-02-01', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction', status: 'posted' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [ - { account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', source_type: 'invoice', status: 'posted' } }, - { account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', source_type: 'invoice', status: 'posted' } }, - { account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', source_type: 'invoice', status: 'posted' } }, - { account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2', journal_entries: { id: 'e2', entry_date: '2024-02-01', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction', status: 'posted' } }, - { account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2', journal_entries: { id: 'e2', entry_date: '2024-02-01', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction', status: 'posted' } }, + { account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' }, + { account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1' }, + { account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2' }, + { account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2' }, ], error: null, }, @@ -171,13 +192,22 @@ describe('generateJournalRegister', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' }, + { id: 'e2', entry_date: '2024-01-16', voucher_number: 2, voucher_series: 'A', description: 'Reversal', source_type: 'manual', status: 'posted' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [ - { account_number: '1930', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' } }, - { account_number: '5410', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' } }, - { account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2', journal_entries: { id: 'e2', entry_date: '2024-01-16', voucher_number: 2, voucher_series: 'A', description: 'Reversal', source_type: 'manual', status: 'posted' } }, - { account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e2', journal_entries: { id: 'e2', entry_date: '2024-01-16', voucher_number: 2, voucher_series: 'A', description: 'Reversal', source_type: 'manual', status: 'posted' } }, + { account_number: '1930', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '5410', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' }, + { account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2' }, + { account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e2' }, ], error: null, }, @@ -199,11 +229,19 @@ describe('generateJournalRegister', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [ - { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' } }, - { account_number: '9999', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' } }, + { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '9999', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1' }, ], error: null, }, @@ -233,11 +271,19 @@ describe('generateJournalRegister', () => { fiscal_periods: [ { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, ], + journal_entries: [ + { + data: [ + { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' }, + ], + error: null, + }, + ], journal_entry_lines: [ { data: [ - { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' } }, - { account_number: '3001', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1', journal_entries: { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' } }, + { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '3001', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1' }, ], error: null, }, diff --git a/lib/reports/__tests__/monthly-breakdown.test.ts b/lib/reports/__tests__/monthly-breakdown.test.ts index 3f25246b..95dc1caf 100644 --- a/lib/reports/__tests__/monthly-breakdown.test.ts +++ b/lib/reports/__tests__/monthly-breakdown.test.ts @@ -61,40 +61,35 @@ describe('generateMonthlyBreakdown', () => { }) it('correctly classifies revenue (class 3) and expense (class 4-7) accounts', async () => { + // Two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts): + // call 1 = fiscal period, call 2 = journal_entries, call 3 = lines by + // entry id (the parent entry is reattached under `journal_entry`). let callCount = 0 supabase.from.mockImplementation(() => { callCount++ - return callCount === 1 - ? chain({ data: { period_start: '2024-01-01', period_end: '2024-03-31' }, error: null }) - : chain({ - data: [ - { - account_number: '3001', - debit_amount: 0, - credit_amount: 10000, - journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '5010', - debit_amount: 3000, - credit_amount: 0, - journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '3001', - debit_amount: 0, - credit_amount: 5000, - journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '6200', - debit_amount: 1500, - credit_amount: 0, - journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - ], - error: null, - }) + if (callCount === 1) { + return chain({ data: { period_start: '2024-01-01', period_end: '2024-03-31' }, error: null }) + } + if (callCount === 2) { + return chain({ + data: [ + { id: 'e1', entry_date: '2024-01-15', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + { id: 'e2', entry_date: '2024-01-20', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + { id: 'e3', entry_date: '2024-02-10', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + { id: 'e4', entry_date: '2024-02-15', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + ], + error: null, + }) + } + return chain({ + data: [ + { account_number: '3001', debit_amount: 0, credit_amount: 10000, journal_entry_id: 'e1' }, + { account_number: '5010', debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e2' }, + { account_number: '3001', debit_amount: 0, credit_amount: 5000, journal_entry_id: 'e3' }, + { account_number: '6200', debit_amount: 1500, credit_amount: 0, journal_entry_id: 'e4' }, + ], + error: null, + }) }) const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1') @@ -118,40 +113,33 @@ describe('generateMonthlyBreakdown', () => { }) it('ignores balance sheet accounts (class 1, 2) but includes class 8 financial items', async () => { + // Two-step entry-lines fetch: call 1 = fiscal period, call 2 = + // journal_entries, call 3 = lines by entry id. let callCount = 0 supabase.from.mockImplementation(() => { callCount++ - return callCount === 1 - ? chain({ data: { period_start: '2024-01-01', period_end: '2024-01-31' }, error: null }) - : chain({ - data: [ - { - account_number: '1930', - debit_amount: 10000, - credit_amount: 0, - journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '2611', - debit_amount: 0, - credit_amount: 2500, - journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '8400', - debit_amount: 500, - credit_amount: 0, - journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - { - account_number: '8300', - debit_amount: 0, - credit_amount: 200, - journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, - }, - ], - error: null, - }) + if (callCount === 1) { + return chain({ data: { period_start: '2024-01-01', period_end: '2024-01-31' }, error: null }) + } + if (callCount === 2) { + return chain({ + data: [ + { id: 'e1', entry_date: '2024-01-15', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + { id: 'e2', entry_date: '2024-01-20', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + { id: 'e3', entry_date: '2024-01-25', status: 'posted', company_id: 'company-1', fiscal_period_id: 'period-1' }, + ], + error: null, + }) + } + return chain({ + data: [ + { account_number: '1930', debit_amount: 10000, credit_amount: 0, journal_entry_id: 'e1' }, + { account_number: '2611', debit_amount: 0, credit_amount: 2500, journal_entry_id: 'e1' }, + { account_number: '8400', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2' }, + { account_number: '8300', debit_amount: 0, credit_amount: 200, journal_entry_id: 'e3' }, + ], + error: null, + }) }) const result = await generateMonthlyBreakdown(supabase as never, 'company-1', 'period-1') diff --git a/lib/reports/__tests__/periodisk-sammanstallning.test.ts b/lib/reports/__tests__/periodisk-sammanstallning.test.ts index 831c4b03..20570136 100644 --- a/lib/reports/__tests__/periodisk-sammanstallning.test.ts +++ b/lib/reports/__tests__/periodisk-sammanstallning.test.ts @@ -102,18 +102,42 @@ interface InvoiceFx { // Recent validation so VIES_UNVALIDATED warnings don't fire by default. const RECENT = new Date().toISOString() +// The generator fetches lines via the two-step entry-lines helper +// (lib/bookkeeping/entry-lines.ts): journal_entries first, then +// journal_entry_lines by entry id with the parent reattached under +// `journal_entries`. Each fixture invoice gets one entry (je-). +function je(sourceId: string) { + return `je-${sourceId}` +} + +function entryEU(sourceId: string) { + return { + id: je(sourceId), + company_id: 'c1', + entry_date: '2025-05-15', + status: 'posted', + source_type: 'invoice_created', + source_id: sourceId, + } +} + +function entryCredit(sourceId: string) { + return { + id: je(sourceId), + company_id: 'c1', + entry_date: '2025-05-20', + status: 'posted', + source_type: 'credit_note', + source_id: sourceId, + } +} + function lineEU(account: string, credit: number, sourceId: string) { return { account_number: account, debit_amount: 0, credit_amount: credit, - journal_entries: { - company_id: 'c1', - entry_date: '2025-05-15', - status: 'posted', - source_type: 'invoice_created', - source_id: sourceId, - }, + journal_entry_id: je(sourceId), } } @@ -122,13 +146,7 @@ function lineCredit(account: string, debit: number, sourceId: string) { account_number: account, debit_amount: debit, credit_amount: 0, - journal_entries: { - company_id: 'c1', - entry_date: '2025-05-20', - status: 'posted', - source_type: 'credit_note', - source_id: sourceId, - }, + journal_entry_id: je(sourceId), } } @@ -148,6 +166,7 @@ function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = describe('generatePeriodiskSammanstallning', () => { it('empty period returns zero rows and zero warnings', async () => { + // journal_entries: none match → the line fetch is skipped entirely. results = [{ data: [], error: null }] const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5) @@ -161,6 +180,8 @@ describe('generatePeriodiskSammanstallning', () => { it('single EU service sale → 1 row, type 3 only', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv-de')], error: null }, { data: [lineEU('3308', 10000, 'inv-de')], error: null }, { data: [invDE()], error: null }, ] @@ -181,6 +202,8 @@ describe('generatePeriodiskSammanstallning', () => { it('aggregates multiple invoices to same customer', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1'), entryEU('inv2'), entryEU('inv3')], error: null }, { data: [ lineEU('3308', 4000, 'inv1'), @@ -207,6 +230,8 @@ describe('generatePeriodiskSammanstallning', () => { it('one customer with both services and goods → 1 row with both filled', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1'), entryEU('inv2')], error: null }, { data: [ lineEU('3308', 7000, 'inv1'), @@ -228,6 +253,8 @@ describe('generatePeriodiskSammanstallning', () => { it('credit invoice nets against original in same period', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1'), entryCredit('cn1')], error: null }, { data: [ lineEU('3308', 10000, 'inv1'), @@ -249,6 +276,8 @@ describe('generatePeriodiskSammanstallning', () => { it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1'), entryCredit('cn1')], error: null }, { data: [ lineEU('3308', 10000, 'inv1'), @@ -267,6 +296,8 @@ describe('generatePeriodiskSammanstallning', () => { it('customer missing country → MISSING_COUNTRY error and row blocked', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1')], error: null }, { data: [lineEU('3308', 5000, 'inv1')], error: null }, { data: [{ @@ -285,6 +316,8 @@ describe('generatePeriodiskSammanstallning', () => { it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1')], error: null }, { data: [lineEU('3308', 5000, 'inv1')], error: null }, { data: [{ @@ -303,6 +336,8 @@ describe('generatePeriodiskSammanstallning', () => { it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1')], error: null }, { data: [lineEU('3308', 5000, 'inv1')], error: null }, { data: [{ @@ -321,6 +356,8 @@ describe('generatePeriodiskSammanstallning', () => { it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1')], error: null }, { data: [lineEU('3308', 5000, 'inv1')], error: null }, { data: [{ @@ -339,6 +376,8 @@ describe('generatePeriodiskSammanstallning', () => { it('Greek customer → country code emitted as EL', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1')], error: null }, { data: [lineEU('3308', 4200, 'inv1')], error: null }, { data: [{ @@ -356,6 +395,8 @@ describe('generatePeriodiskSammanstallning', () => { it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv1')], error: null }, { data: [lineEU('3108', 9000, 'inv1')], error: null }, { data: [{ ...invDE('inv1') }], error: null }, ] @@ -367,6 +408,8 @@ describe('generatePeriodiskSammanstallning', () => { it('sorts rows by country then vat_number', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [entryEU('inv-fr'), entryEU('inv-de'), entryEU('inv-at')], error: null }, { data: [ lineEU('3308', 1000, 'inv-fr'), diff --git a/lib/reports/__tests__/sie-export.test.ts b/lib/reports/__tests__/sie-export.test.ts index cdea4b0c..49a40c0c 100644 --- a/lib/reports/__tests__/sie-export.test.ts +++ b/lib/reports/__tests__/sie-export.test.ts @@ -60,10 +60,12 @@ const baseOptions = { // 1: previous fiscal period .single() (#RAR -1) // 2: chart_of_accounts (fetchAllRows) // 3: journal_entries (fetchAllRows) -// 4: journal_entry_lines (fetchAllRows) ← split out from the entries query +// 4: journal_entry_lines (fetchLinesByEntryIds; SKIPPED when slot 3 +// returned no entries, so empty-period tests queue nothing here) // 5: dimensions (registry #DIM/#UNDERDIM rows) // 6: dimension_values (registry #OBJEKT rows) -// 7: opening balances (RPC fallback or journal_entry_lines page) +// 7: opening balances (RPC fallback, or the OB entry ownership check + +// its lines when opening_balance_entry_id is set: two slots) // Registry fixtures for the system dims (seeded by ensure_company_dimensions). const dimKostnadsstalle = { id: 'dim-1', sie_dim_no: 1, parent_sie_dim_no: null, name: 'Kostnadsställe' } @@ -85,8 +87,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values { data: [], error: null }, // opening balances RPC @@ -110,8 +111,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback @@ -136,8 +136,7 @@ describe('generateSIEExport', () => { ], error: null, }, - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback @@ -193,8 +192,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions { data: [ @@ -370,8 +368,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { // Kostnadsbärare (2) is a sub-dimension of Kostnadsställe (1); the // parent has NO values of its own: it must still be declared because @@ -513,8 +510,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback @@ -538,8 +534,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback @@ -556,8 +551,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback @@ -577,8 +571,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions: seeded, valueless { data: [], error: null }, // dimension_values { data: [], error: null }, // RPC fallback @@ -664,8 +657,7 @@ describe('generateSIEExport', () => { { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries (no movements this period) - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no movements -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values // RPC fallback returns prior IBs derived from historical journal lines @@ -689,17 +681,18 @@ describe('generateSIEExport', () => { it('reads #IB from explicit opening_balance_entry_id when set', async () => { // When opening_balance_entry_id is set, getOpeningBalances uses the - // journal_entry_lines path (fetchAllRows) instead of the RPC, so the - // queue here serves the line rows rather than RPC rows. + // two-step entry-lines path (entry ownership check, then its lines) + // instead of the RPC, so the queue serves those rows rather than RPC rows. results = [ { data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: 'ob-entry-1' }, error: null }, { data: null, error: null }, // prevPeriod { data: [], error: null }, // accounts - { data: [], error: null }, // journal_entries - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries (no entries -> line fetch skipped) { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values - // fetchAllRows page 1: explicit OB entry lines + // getOpeningBalances step 1: the OB entry itself (ownership check) + { data: [{ id: 'ob-entry-1' }], error: null }, + // getOpeningBalances step 2: the explicit OB entry lines { data: [ { account_number: '1930', debit_amount: 12000, credit_amount: 0 }, @@ -753,10 +746,10 @@ describe('generateSIEExport', () => { ], error: null, }, - // journal_entry_lines (allLines): #824 moved per-entry lines into a single - // paged join query; lines map back to entries by journal_entry_id (the inline - // entry.lines above are overwritten). The OB entry's lines (excluded from - // movement via obEntryId) and the real transfer's lines both flow through here. + // journal_entry_lines (allLines): fetched by entry id via + // fetchLinesByEntryIds; lines map back to entries by journal_entry_id. + // The OB entry's lines (excluded from movement via obEntryId) and the + // real transfer's lines both flow through here. { data: [ { id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', dimensions: {} }, @@ -768,7 +761,9 @@ describe('generateSIEExport', () => { }, { data: [], error: null }, // dimensions { data: [], error: null }, // dimension_values - // fetchAllRows for OB entry lines (opening_balance_entry_id is set) + // getOpeningBalances step 1: the OB entry itself (ownership check) + { data: [{ id: 'ob-entry-1' }], error: null }, + // getOpeningBalances step 2: the explicit OB entry lines { data: [ { account_number: '1933', debit_amount: 96466.59, credit_amount: 0 }, diff --git a/lib/reports/__tests__/supplier-reconciliation.test.ts b/lib/reports/__tests__/supplier-reconciliation.test.ts index 0701df3d..dae3aae8 100644 --- a/lib/reports/__tests__/supplier-reconciliation.test.ts +++ b/lib/reports/__tests__/supplier-reconciliation.test.ts @@ -52,6 +52,8 @@ describe('generateReconciliation', () => { error: null, }, // 1: journal_entry_lines for account 2440 + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 10000, journal_entry_id: 'e1' }, @@ -80,8 +82,10 @@ describe('generateReconciliation', () => { const page2 = Array.from({ length: 500 }, (_, i) => ({ id: `p2-${i}`, debit_amount: 0, credit_amount: 10 })) results = [ { data: [], error: null }, // 0: supplier_invoices, none open - { data: page1, error: null }, // 1: 2440 lines page 1 (full → triggers next page) - { data: page2, error: null }, // 2: 2440 lines page 2 (partial → stop) + // 1: journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, + { data: page1, error: null }, // 2: 2440 lines page 1 (full → triggers next page) + { data: page2, error: null }, // 3: 2440 lines page 2 (partial → stop) ] const result = await generateReconciliation(supabase, 'company-1', 'period-1') @@ -100,6 +104,8 @@ describe('generateReconciliation', () => { error: null, }, // 1: journal_entry_lines, balance 7000 + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 7000, journal_entry_id: 'e1' }, @@ -133,6 +139,8 @@ describe('generateReconciliation', () => { it('handles null invoice data gracefully', async () => { results = [ { data: null, error: null }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 3000, journal_entry_id: 'e1' }, @@ -152,6 +160,8 @@ describe('generateReconciliation', () => { it('computes credit-normal balance for account 2440 (liability)', async () => { results = [ { data: [], error: null }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 15000, journal_entry_id: 'e1' }, @@ -181,6 +191,8 @@ describe('generateReconciliation', () => { error: null, }, // 1: 2440 balance = 3 475 SEK (matches converted ledger total) + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 3475, journal_entry_id: 'e1' }, @@ -212,6 +224,8 @@ describe('generateReconciliation', () => { error: null, }, // 1: 2440 balance reflects only the SEK invoice + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' }, @@ -246,6 +260,8 @@ describe('generateReconciliation', () => { // 1: 2440 lines as returned by the posted+reversed query for one corrected, // paid invoice of 11 231,25: registration (reversed credit), storno // (debit), correction (credit), payment (debit). Net credit−debit = 0. + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 11231.25, journal_entry_id: 'reg-reversed' }, @@ -266,10 +282,14 @@ describe('generateReconciliation', () => { // Guard the actual fix: the 2440 query must include reversed entries, not // filter to posted-only (which excluded the reversed registration leg). - const statusFilter = calls.find( - (c) => c.method === 'in' && c.args[0] === 'journal_entries.status', + // The status filter now lives on the journal_entries query itself (the + // two-step entry-lines fetch), not on an embedded-side column. The open + // invoices query also filters .in('status', ...), so assert that ONE of + // the status filters is the posted+reversed ledger inclusion rule. + const statusFilters = calls.filter( + (c) => c.method === 'in' && c.args[0] === 'status', ) - expect(statusFilter?.args[1]).toEqual(['posted', 'reversed']) + expect(statusFilters.map((c) => c.args[1])).toContainEqual(['posted', 'reversed']) }) it('uses Math.round for monetary precision', async () => { @@ -281,6 +301,8 @@ describe('generateReconciliation', () => { ], error: null, }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { debit_amount: 0, credit_amount: 66.67, journal_entry_id: 'e1' }, diff --git a/lib/reports/__tests__/trial-balance-dimensions.test.ts b/lib/reports/__tests__/trial-balance-dimensions.test.ts index 0d2af1a7..82a47a0a 100644 --- a/lib/reports/__tests__/trial-balance-dimensions.test.ts +++ b/lib/reports/__tests__/trial-balance-dimensions.test.ts @@ -23,7 +23,15 @@ function makeBuilder(tableName: string) { }) const consume = (): MockResult => { const queue = mockResults[tableName] - if (!queue || queue.length === 0) return { data: null, error: null } + if (!queue || queue.length === 0) { + // The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads + // journal_entries before journal_entry_lines. Tests queue line rows + // directly, so default the entries step to one generic entry. + if (tableName === 'journal_entries') { + return { data: [{ id: 'entry-1' }], error: null } + } + return { data: null, error: null } + } return queue.shift()! } b.single = vi.fn().mockImplementation(async () => consume()) diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index a27018fd..1e13e99b 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -16,7 +16,16 @@ function makeBuilder(tableName: string) { } const consume = (): MockResult => { const queue = mockResults[tableName] - if (!queue || queue.length === 0) return { data: null, error: null } + if (!queue || queue.length === 0) { + // The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads + // journal_entries before journal_entry_lines. Tests queue line rows + // directly, so default the entries step to one generic entry: the mock + // ignores filters and the reports under test only consume line rows. + if (tableName === 'journal_entries') { + return { data: [{ id: 'entry-1' }], error: null } + } + return { data: null, error: null } + } return queue.shift()! } b.single = vi.fn().mockImplementation(async () => consume()) diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index 31beb595..23b960e6 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -183,16 +183,19 @@ describe('getVatDeclarationSummary', () => { // ============================================================ // Ledger-based VAT declaration tests // -// After Phase 1b refactor, the calculator does TWO queries per call: -// [0] fetchAllRows: journal_entry_lines on every account in ACCOUNT_RUTA -// (26xx VAT, 3xxx revenue, 4xxx reverse-charge cost accounts) -// [1] journal_entries source_type counts (used for invoice/transaction metadata) +// The calculator queries per call (two-step entry-lines fetch, see +// lib/bookkeeping/entry-lines.ts): +// [0] journal_entries matching the period (id page) +// [1] journal_entry_lines by entry id, filtered to ACCOUNT_RUTA accounts +// (26xx VAT, 3xxx revenue, 4xxx reverse-charge cost accounts); +// skipped entirely when [0] is empty +// [2] journal_entries source_type counts (invoice/transaction metadata) // ============================================================ describe('calculateVatDeclaration', () => { it('returns all zeros when no ledger lines exist', async () => { results = [ - { data: [], error: null }, // journal_entry_lines + { data: [], error: null }, // journal_entries: none → line fetch skipped { data: [], error: null }, // entry counts ] @@ -213,6 +216,8 @@ describe('calculateVatDeclaration', () => { it('sums output VAT to ruta10/11/12 and revenue to ruta05', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, @@ -241,6 +246,8 @@ describe('calculateVatDeclaration', () => { it('sums input VAT from 2641 debit balance', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2641', debit_amount: 250, credit_amount: 0 }, @@ -259,6 +266,8 @@ describe('calculateVatDeclaration', () => { it('includes calculated input VAT (2645) from EU reverse charge in ruta48', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2645', debit_amount: 500, credit_amount: 0 }, @@ -276,6 +285,8 @@ describe('calculateVatDeclaration', () => { it('maps EU/export revenue to ruta39/ruta40', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3308', debit_amount: 0, credit_amount: 8000 }, @@ -294,6 +305,8 @@ describe('calculateVatDeclaration', () => { it('handles credit notes as net reduction on revenue/VAT accounts', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ // Invoice: C2611 2500, C3001 10000 @@ -317,6 +330,8 @@ describe('calculateVatDeclaration', () => { it('calculates ruta49 as output minus input VAT', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, @@ -338,6 +353,8 @@ describe('calculateVatDeclaration', () => { it('detects refund when input VAT exceeds output VAT', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 500 }, @@ -365,6 +382,8 @@ describe('calculateVatDeclaration', () => { it('handles all three VAT rates in a single period', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3001', debit_amount: 0, credit_amount: 10000 }, @@ -398,6 +417,8 @@ describe('calculateVatDeclaration', () => { describe('calculateVatDeclaration: reverse charge', () => { it('maps 2614/2624/2634 credit balances to ruta30/31/32', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2614', debit_amount: 0, credit_amount: 1250 }, @@ -422,6 +443,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('includes ruta30-32 in ruta49 formula', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, @@ -446,6 +469,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('populates ruta20 from EU goods cost accounts (4515/4516/4517)', async () => { // EU goods purchase: D 4515 25000, D 2645 6250, C 2614 6250, C 2440 25000 results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4515', debit_amount: 25000, credit_amount: 0 }, @@ -469,6 +494,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('populates ruta21 from EU services cost accounts (4535/4536/4537)', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4535', debit_amount: 5000, credit_amount: 0 }, @@ -493,6 +520,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('populates ruta22 from non-EU services cost accounts (4531/4532/4533)', async () => { // Anthropic-style: D 4531 3000, D 2645 750, C 2614 750, C 2440 3000 results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4531', debit_amount: 3000, credit_amount: 0 }, @@ -515,6 +544,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('populates ruta23 from domestic goods reverse-charge cost accounts (4415/4416/4417)', async () => { // Domestic mobile reverse charge: D 4415 100000, D 2647 25000, C 2614 25000, C 2440 100000 results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4415', debit_amount: 100000, credit_amount: 0 }, @@ -537,6 +568,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('populates ruta24 from domestic services reverse-charge cost accounts (4425/4426/4427)', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4425', debit_amount: 8000, credit_amount: 0 }, @@ -557,6 +590,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('returns zero ruta20-24 when no reverse-charge cost-account activity', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, @@ -579,6 +614,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('reverse-charge credit notes net out the cost-account debit balance', async () => { // Original purchase: D 4535 5000; reversal (credit note): C 4535 1000 results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4535', debit_amount: 5000, credit_amount: 0 }, @@ -602,6 +639,8 @@ describe('calculateVatDeclaration: reverse charge', () => { it('maps domestic reverse-charge input VAT (2647) to ruta48', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2647', debit_amount: 500, credit_amount: 0 }, @@ -627,6 +666,8 @@ describe('calculateVatDeclaration: reverse charge', () => { describe('calculateVatDeclaration: import, uttag, exempt', () => { it('maps import VAT accounts (2615/2625/2635) to ruta60/61/62', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2615', debit_amount: 0, credit_amount: 2500 }, @@ -650,6 +691,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('populates ruta50 (import beskattningsunderlag) from 4545-4547', async () => { // Full import flow: D 4545 10000, C 2615 2500, D 2641 2500 results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4545', debit_amount: 10000, credit_amount: 0 }, @@ -673,6 +716,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('populates ruta06 from uttag accounts (3401/3402/3403)', async () => { // Uttag: D 2010 (private withdrawal); C 3401 1000 + C 2612 250 (25% rate uttag) results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3401', debit_amount: 0, credit_amount: 1000 }, @@ -691,6 +736,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('expanded ruta42 covers 3004, 3100, 3404, 3994, 3980', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3004', debit_amount: 0, credit_amount: 1000 }, @@ -711,6 +758,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('maps EU/export revenue variants (3108/3105) to ruta35/36', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3108', debit_amount: 0, credit_amount: 15000 }, @@ -729,6 +778,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('maps output VAT variant accounts (2612/2623/2636) to correct rutor', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2612', debit_amount: 0, credit_amount: 1000 }, // egna uttag 25% @@ -749,6 +800,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('handles zero output VAT on some rates but non-zero on others', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2621', debit_amount: 0, credit_amount: 600 }, @@ -771,6 +824,8 @@ describe('calculateVatDeclaration: import, uttag, exempt', () => { it('rounds sub-öre amounts via Math.round * 100 / 100', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 0.001 }, @@ -801,6 +856,8 @@ describe('SKV §4.1.1.4 cross-field contracts', () => { // SKV: if any of momspliktigForsaljning/momspliktigaUttag/vinstmarginal/hyresInkomst > 0, // at least one of momsForsaljningUtgaende{Hog,Medel,Lag} must be > 0. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3001', debit_amount: 0, credit_amount: 10000 }, @@ -826,6 +883,8 @@ describe('SKV §4.1.1.4 cross-field contracts', () => { // If any of inkopVarorEU/inkopTjansterEU/inkopTjansterUtanforEU/inkopVarorSE/inkopTjansterSE > 0, // at least one of momsInkopUtgaende{Hog,Medel,Lag} must be > 0. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4535', debit_amount: 5000, credit_amount: 0 }, @@ -848,6 +907,8 @@ describe('SKV §4.1.1.4 cross-field contracts', () => { it('ERROR: import base requires import output VAT (rule 5)', async () => { // If import (ruta50) > 0, at least one of momsImportUtgaende{Hog,Medel,Lag} must be > 0. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '4545', debit_amount: 10000, credit_amount: 0 }, @@ -870,6 +931,8 @@ describe('SKV §4.1.1.4 cross-field contracts', () => { // This is the BLOCKER scenario the Phase 1b refactor fixes: previously ruta50 was // never populated, so any import VAT booking would fail SKV's contract. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2615', debit_amount: 0, credit_amount: 2500 }, @@ -894,6 +957,8 @@ describe('SKV §4.1.1.4 cross-field contracts', () => { // holds by construction. This test is the canary that catches drift if anyone // ever adds an extra term or rate to the form. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2611', debit_amount: 0, credit_amount: 2500 }, @@ -932,6 +997,8 @@ describe('SKV §4.1.1.4 cross-field contracts', () => { describe('calculateVatDeclaration: parent/summary accounts', () => { it('maps 2610 (parent) to ruta10 when posted directly', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '1910', debit_amount: 12500, credit_amount: 0 }, @@ -952,6 +1019,8 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { it('maps 2620 (parent) to ruta11 and 2630 (parent) to ruta12', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2620', debit_amount: 0, credit_amount: 600 }, @@ -972,6 +1041,8 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { // Vilande accounts hold output VAT for invoices that have been sent but not // yet paid, used by cash-method bookkeepers per BFNAR 2006:1. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2618', debit_amount: 0, credit_amount: 500 }, @@ -995,6 +1066,8 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { // bookkeeping practice, SIE imports, etc.), the ruta reflects the literal // ledger total: accounting truth wins. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2610', debit_amount: 0, credit_amount: 1000 }, @@ -1012,6 +1085,8 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { it('maps 2640 (input VAT parent) to ruta48', async () => { results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '2640', debit_amount: 200, credit_amount: 0 }, @@ -1032,6 +1107,8 @@ describe('calculateVatDeclaration: parent/summary accounts', () => { // correct VAT amount on the parent account. Before the fix, ruta10 read 0 // and ruta49 incorrectly showed a refund. results = [ + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3001', debit_amount: 0, credit_amount: 21600 }, @@ -1060,6 +1137,8 @@ describe('calculateVatDeclaration: annual VAT spans the räkenskapsår', () => { // lookup, the second the journal lines, the third the entry counts. results = [ { data: { period_start: '2025-07-03', period_end: '2026-12-31' }, error: null }, + // journal_entries page for the two-step entry-lines fetch + { data: [{ id: 'entry-1' }], error: null }, { data: [ { account_number: '3001', debit_amount: 0, credit_amount: 21600 }, diff --git a/lib/reports/ar-reconciliation.ts b/lib/reports/ar-reconciliation.ts index 60675019..e7199b4e 100644 --- a/lib/reports/ar-reconciliation.ts +++ b/lib/reports/ar-reconciliation.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' export interface ARReconciliationResult { ar_ledger_total: number @@ -86,32 +87,23 @@ export async function generateARReconciliation( // trial balance / balance sheet use. A corrected invoice flips its original to // status='reversed'; that reversed leg is cancelled by the posted storno, so // both must be summed or a corrected invoice manufactures a phantom gap. - // Paginated with a stable id order (+ dedupe defense) so a period with >1000 - // ledger lines on 1510/1513 isn't silently truncated into a phantom gap. - const journalLines = await fetchAllRows<{ + // Fetched via the two-step entry-lines helper (entries first, then lines + // chunked by entry id, both paginated): see lib/bookkeeping/entry-lines.ts. + const journalLines = await fetchEntryLines<{ id: string debit_amount: number | null credit_amount: number | null - }>(({ from, to }) => - supabase - .from('journal_entry_lines') - .select(` - id, - debit_amount, - credit_amount, - journal_entry:journal_entries!inner( - status, - company_id, - fiscal_period_id - ) - `) - .in('account_number', ['1510', '1513']) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', periodId) - .in('journal_entries.status', ['posted', 'reversed']) - .order('id', { ascending: true }) - .range(from, to) - , { dedupeBy: (l) => l.id }) + }>({ + supabase, + lineColumns: 'id, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('fiscal_period_id', periodId) + .in('status', ['posted', 'reversed']), + filterLines: (q: EntryLinesQuery) => q.in('account_number', ['1510', '1513']), + attachEntriesAs: null, + }) // Both 1510 and 1513 are debit-normal assets: balance = debits - credits let account1510Balance = 0 diff --git a/lib/reports/dimension-pnl.ts b/lib/reports/dimension-pnl.ts index 8476a730..4d8b5ea8 100644 --- a/lib/reports/dimension-pnl.ts +++ b/lib/reports/dimension-pnl.ts @@ -1,6 +1,7 @@ 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, @@ -99,32 +100,31 @@ export async function generateDimensionPnl( // 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 fetchAllRows<{ + const taggedLines = await fetchEntryLines<{ id: string account_number: string debit_amount: number credit_amount: number dimensions: Record - }>(({ from, to }) => { - let query = supabase - .from('journal_entry_lines') - .select( - 'id, account_number, debit_amount, credit_amount, dimensions, journal_entries!inner(company_id, fiscal_period_id, status, entry_date)' - ) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', fiscalPeriodId) - .in('journal_entries.status', ['posted', 'reversed']) - // Key-existence via the extracted text field: dims 1/6 ride the partial - // expression indexes (idx_jel_dimensions_dim1/dim6). - .not(`dimensions->>${sieDimNo}`, 'is', null) + }>({ + 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('journal_entries.entry_date', options.toDate) - } + if (options?.toDate) { + query = query.lte('entry_date', options.toDate) + } - // Stable total order on the line PK for correct paging (see fetch-all.ts). - return query.order('id', { ascending: true }).range(from, to) - }, { dedupeBy: (r) => r.id }) + 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. diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts index 45466cab..41c8a4fa 100644 --- a/lib/reports/general-ledger.ts +++ b/lib/reports/general-ledger.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { getOpeningBalances } from './opening-balances' export interface GeneralLedgerLine { @@ -35,8 +36,10 @@ export interface GeneralLedgerReport { * Generate general ledger (huvudbok) for a fiscal period. * BFL 5 kap. 1 §: systematisk ordning: all transactions grouped by account. * - * Uses joined queries with pagination to handle any number of entries. - * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. + * Uses the shared two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts): + * entries first, then lines chunked by entry id, both paginated, so any + * number of entries is handled without the pathological journal_entries!inner + * embed plan. * * Opening balances use the opening_balance_entry set by year-end closing * when available; falls back to summing prior-period entries. @@ -88,14 +91,12 @@ export async function generateGeneralLedger( } } - // ── Period lines via joined query (excluding OB entry) ───────── + // ── Period lines via the two-step entry-lines fetch (excluding OB entry) ── // Race condition note: if year-end closing runs concurrently and creates // the OB entry between the period query and this query, the entry could // be missed. The window is sub-second and the consequence is a single // stale report: acceptable. - // Supabase types !inner joins as arrays; for many-to-one (line → entry) - // it returns a single object at runtime. Cast via `as any` on the query. - const rawLines = await fetchAllRows<{ + const rawLines = await fetchEntryLines<{ id: string account_number: string debit_amount: number @@ -109,30 +110,29 @@ export async function generateGeneralLedger( description: string source_type: string } - }>(({ from, to }) => { - let query = supabase - .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, journal_entry_id, dimensions, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', periodId) - .in('journal_entries.status', ['posted', 'reversed']) + }>({ + supabase, + entryColumns: + 'entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status', + lineColumns: + 'id, account_number, debit_amount, credit_amount, journal_entry_id, dimensions', + filterEntries: (q: EntryLinesQuery) => { + let query = q + .eq('company_id', companyId) + .eq('fiscal_period_id', periodId) + .in('status', ['posted', 'reversed']) - if (dimensionFilter) { - // jsonb containment (@>): served by idx_jel_dimensions_gin. - query = query.contains('dimensions', dimensionFilter) - } + if (obEntryId) { + query = query.neq('id', obEntryId) + } - if (obEntryId) { - query = query.neq('journal_entry_id', obEntryId) - } - - // Stable total order on the line PK: paging is only correct with a - // deterministic order, else rows duplicate/skip across pages and balances - // double or accounts vanish (see fetch-all.ts ordering invariant). The - // report re-sorts lines per account below, so this order is invisible. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return query.order('id', { ascending: true }).range(from, to) as any - }, { dedupeBy: (r) => r.id }) + return query + }, + filterLines: dimensionFilter + ? // jsonb containment (@>): served by idx_jel_dimensions_gin. + (q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter) + : undefined, + }) if (rawLines.length === 0 && openingBalances.size === 0) { return { accounts: [], period: { start: period.period_start, end: period.period_end } } diff --git a/lib/reports/journal-register.ts b/lib/reports/journal-register.ts index 16ef30cf..5548c444 100644 --- a/lib/reports/journal-register.ts +++ b/lib/reports/journal-register.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' export interface JournalRegisterLine { account_number: string @@ -32,8 +33,10 @@ export interface JournalRegisterReport { * Generate journal register (grundbok) for a fiscal period. * BFL 5 kap. 1 §: registreringsordning: all vouchers in chronological registration order. * - * Uses a joined query with pagination to handle any number of entries. - * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. + * Uses the shared two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts): + * entries first, then lines chunked by entry id, both paginated, so any + * number of entries is handled without the pathological journal_entries!inner + * embed plan. * * Unlike the general ledger and trial balance, the grundbok includes ALL * entries: the opening_balance_entry is NOT excluded, because it is a @@ -57,11 +60,9 @@ export async function generateJournalRegister( return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: '', end: '' } } } - // Fetch all lines with joined entry data: single paginated query, - // no entry ID array, no truncation at 1000 rows - // Supabase types !inner joins as arrays; for many-to-one (line → entry) - // it returns a single object at runtime. Cast via `as any` on the query. - const rawLines = await fetchAllRows<{ + // Fetch entries and their lines via the two-step entry-lines fetch: both + // sides paginated, lines chunked by entry id, no truncation at 1000 rows. + const rawLines = await fetchEntryLines<{ id: string account_number: string debit_amount: number @@ -76,18 +77,17 @@ export async function generateJournalRegister( source_type: string status: string } - }>(({ from, to }) => { - return supabase - .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(id, entry_date, voucher_number, voucher_series, description, source_type, status, company_id, fiscal_period_id)') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', periodId) - .in('journal_entries.status', ['posted', 'reversed']) - // Stable total order on the line PK: without it, rows duplicate/skip - // across pages and entries appear twice or go missing (see fetch-all.ts). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .order('id', { ascending: true }).range(from, to) as any - }, { dedupeBy: (r) => r.id }) + }>({ + supabase, + entryColumns: + 'id, entry_date, voucher_number, voucher_series, description, source_type, status, company_id, fiscal_period_id', + lineColumns: 'id, account_number, debit_amount, credit_amount, journal_entry_id', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('fiscal_period_id', periodId) + .in('status', ['posted', 'reversed']), + }) if (rawLines.length === 0) { return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } } diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts index 4fafd154..8a84559f 100644 --- a/lib/reports/monthly-breakdown.ts +++ b/lib/reports/monthly-breakdown.ts @@ -1,5 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' export interface MonthlyBreakdownMonth { label: string @@ -47,35 +47,27 @@ export async function generateMonthlyBreakdown( return { months: [] } } - // Get all posted journal entry lines for this period with their entry dates + // Get all posted journal entry lines for this period with their entry dates, + // via the two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). // eslint-disable-next-line @typescript-eslint/no-explicit-any let lines: any[] try { - lines = await fetchAllRows(({ from, to }) => { - let query = supabase - .from('journal_entry_lines') - .select(` - account_number, - debit_amount, - credit_amount, - journal_entry:journal_entries!inner( - entry_date, - status, - company_id, - fiscal_period_id - ) - `) - .eq('journal_entries.fiscal_period_id', fiscalPeriodId) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.status', 'posted') - - if (options?.dimensions && Object.keys(options.dimensions).length > 0) { - // jsonb containment (@>): served by idx_jel_dimensions_gin. - query = query.contains('dimensions', options.dimensions) - } - - // Stable total order for correct paging (see fetch-all.ts). - return query.order('id', { ascending: true }).range(from, to) + lines = await fetchEntryLines({ + supabase, + entryColumns: 'entry_date, status, company_id, fiscal_period_id', + lineColumns: 'account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('fiscal_period_id', fiscalPeriodId) + .eq('company_id', companyId) + .eq('status', 'posted'), + filterLines: + options?.dimensions && Object.keys(options.dimensions).length > 0 + ? // jsonb containment (@>): served by idx_jel_dimensions_gin. + (q: EntryLinesQuery) => q.contains('dimensions', options.dimensions) + : undefined, + // The old embed was aliased: journal_entry:journal_entries!inner(...). + attachEntriesAs: 'journal_entry', }) } catch { return { months: [] } diff --git a/lib/reports/opening-balances.ts b/lib/reports/opening-balances.ts index 6c7e0d8f..8b29af76 100644 --- a/lib/reports/opening-balances.ts +++ b/lib/reports/opening-balances.ts @@ -1,5 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' /** * Get opening balances (ingående balans) for a fiscal period. @@ -37,25 +37,22 @@ export async function getOpeningBalances( if (obEntryId) { // Use the explicit opening balance entry (set by year-end closing). - // Typically ~50 rows: one per balance sheet account. Uses fetchAllRows - // for consistency (avoids silent truncation) and joins journal_entries - // to enforce company_id ownership (defense in depth alongside RLS). - const obLines = await fetchAllRows<{ + // Typically ~50 rows: one per balance sheet account. The two-step + // entry-lines fetch verifies company_id ownership on the entry side + // (defense in depth alongside RLS) and paginates (avoids silent + // truncation). See lib/bookkeeping/entry-lines.ts. + const obLines = await fetchEntryLines<{ id: string account_number: string debit_amount: number credit_amount: number - }>(({ from, to }) => - supabase - .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, journal_entries!inner(company_id)') - .eq('journal_entry_id', obEntryId) - .eq('journal_entries.company_id', companyId) - // Stable total order for correct paging (see fetch-all.ts). - .order('id', { ascending: true }) - .range(from, to), - { dedupeBy: (r) => r.id } - ) + }>({ + supabase, + lineColumns: 'id, account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q.eq('id', obEntryId).eq('company_id', companyId), + attachEntriesAs: null, + }) for (const line of obLines) { const existing = balances.get(line.account_number) || { debit: 0, credit: 0 } diff --git a/lib/reports/periodisk-sammanstallning.ts b/lib/reports/periodisk-sammanstallning.ts index 3efa5ce8..984217ab 100644 --- a/lib/reports/periodisk-sammanstallning.ts +++ b/lib/reports/periodisk-sammanstallning.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { calculatePeriodDates, formatPeriodLabel } from './period-dates' import { calculateVatDeclaration } from './vat-declaration' @@ -181,29 +182,22 @@ export async function generatePeriodiskSammanstallning( const { start, end } = calculatePeriodDates(periodType, year, period) - const lines = await fetchAllRows(({ from, to }) => - supabase - .from('journal_entry_lines') - .select(` - account_number, - debit_amount, - credit_amount, - journal_entries!inner ( - company_id, entry_date, status, source_type, source_id - ) - `) - .in('account_number', PS_ACCOUNTS) - .eq('journal_entries.company_id', companyId) - .in('journal_entries.status', ['posted', 'reversed']) - // Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales - // always go through AR); excluded to avoid phantom rows. - .in('journal_entries.source_type', ['invoice_created', 'credit_note']) - .gte('journal_entries.entry_date', start) - .lte('journal_entries.entry_date', end) - // Stable total order for correct paging (see fetch-all.ts). - .order('id', { ascending: true }) - .range(from, to) as unknown as PromiseLike<{ data: RawLine[] | null; error: { message: string } | null }>, - ) + // Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). + const lines = await fetchEntryLines({ + supabase, + entryColumns: 'company_id, entry_date, status, source_type, source_id', + lineColumns: 'account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .in('status', ['posted', 'reversed']) + // Cash sales on 3308/3108 are not a real flow (EU reverse-charge sales + // always go through AR); excluded to avoid phantom rows. + .in('source_type', ['invoice_created', 'credit_note']) + .gte('entry_date', start) + .lte('entry_date', end), + filterLines: (q: EntryLinesQuery) => q.in('account_number', PS_ACCOUNTS), + }) const invoiceIds = Array.from( new Set( diff --git a/lib/reports/rc-basis-gaps.ts b/lib/reports/rc-basis-gaps.ts index 2ec2c183..6b099126 100644 --- a/lib/reports/rc-basis-gaps.ts +++ b/lib/reports/rc-basis-gaps.ts @@ -1,5 +1,9 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + fetchEntryLines, + fetchLinesByEntryIds, + type EntryLinesQuery, +} from '@/lib/bookkeeping/entry-lines' import { calculatePeriodDates } from './vat-declaration' import type { VatPeriodType } from '@/types' @@ -106,41 +110,30 @@ export async function findRcBasisGaps( ): Promise { const { start, end } = calculatePeriodDates(periodType, year, period) - const rcLines = (await fetchAllRows(({ from, to }) => - supabase - .from('journal_entry_lines') - .select(` - journal_entry_id, - account_number, - debit_amount, - credit_amount, - journal_entries!inner ( - id, voucher_number, voucher_series, entry_date, description, status, company_id - ) - `) - .in('account_number', RC_OUTPUT_ACCOUNTS as unknown as string[]) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.status', 'posted') - .gte('journal_entries.entry_date', start) - .lte('journal_entries.entry_date', end) - // Stable total order for correct paging (see fetch-all.ts). - .order('id', { ascending: true }) - .range(from, to), - )) as RcLineRow[] + // Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). + const rcLines = (await fetchEntryLines({ + supabase, + entryColumns: + 'id, voucher_number, voucher_series, entry_date, description, status, company_id', + lineColumns: 'journal_entry_id, account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('status', 'posted') + .gte('entry_date', start) + .lte('entry_date', end), + filterLines: (q: EntryLinesQuery) => + q.in('account_number', RC_OUTPUT_ACCOUNTS as unknown as string[]), + })) as RcLineRow[] if (rcLines.length === 0) return [] const entryIds = [...new Set(rcLines.map((l) => l.journal_entry_id))] - const siblingLines = await fetchAllRows(({ from, to }) => - supabase - .from('journal_entry_lines') - .select('id, journal_entry_id, account_number, debit_amount, credit_amount') - .in('journal_entry_id', entryIds) - // Stable total order for correct paging (see fetch-all.ts). - .order('id', { ascending: true }) - .range(from, to), - { dedupeBy: (r) => r.id }, + const siblingLines = await fetchLinesByEntryIds( + supabase, + entryIds, + 'id, journal_entry_id, account_number, debit_amount, credit_amount', ) const basisByEntry = new Map() diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index e05685a5..cd53124b 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchLinesByEntryIds } from '@/lib/bookkeeping/entry-lines' import { getBranding } from '@/lib/branding/service' import { createLogger } from '@/lib/logger' import { getOpeningBalances } from './opening-balances' @@ -110,26 +111,16 @@ export async function generateSIEExport( .range(from, to) }, { dedupeBy: (r) => r.id }) - // Fetch all lines for those entries, filtered server-side via an inner join - // so the same company/period/status (and year-end exclusion) constraints - // apply, then group by journal_entry_id. - const allLines = await fetchAllRows(({ from, to }) => { - let q = supabase - .from('journal_entry_lines') - .select('*, journal_entries!inner(company_id, fiscal_period_id, status, source_type)') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', options.fiscal_period_id) - .in('journal_entries.status', ['posted', 'reversed']) - - if (options.exclude_year_end_closing) { - q = q.neq('journal_entries.source_type', 'year_end') - } - - // Stable total order on the line PK so paging can't duplicate/skip a line - // across the 1000-row boundary; dedupeBy is the defense-in-depth net. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return q.order('id', { ascending: true }).range(from, to) as any - }, { dedupeBy: (r) => r.id }) + // Fetch all lines for those entries by entry id, in chunks (see + // lib/bookkeeping/entry-lines.ts for why the old journal_entries!inner + // embed filter had to go). Reusing the entry list already fetched above + // means the company/period/status (and year-end exclusion) constraints + // carry over exactly; then group by journal_entry_id. + const allLines = await fetchLinesByEntryIds( + supabase, + entries.map((e) => e.id), + '*' + ) const linesByEntryId = new Map() for (const line of allLines) { diff --git a/lib/reports/supplier-reconciliation.ts b/lib/reports/supplier-reconciliation.ts index fa38df8e..a76a8402 100644 --- a/lib/reports/supplier-reconciliation.ts +++ b/lib/reports/supplier-reconciliation.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' export interface ReconciliationResult { supplier_ledger_total: number @@ -80,32 +81,23 @@ export async function generateReconciliation( // debit balance. (This is exactly the false −41 121,25 kr "Ej avstämd" gap a // fully-paid, fully-corrected company hit: posted-only = −41 121,25, but // posted+reversed = 0, matching the leverantörsreskontra.) - // Paginated with a stable id order (+ dedupe defense) so a period with >1000 - // ledger lines on 2440 isn't silently truncated into a phantom gap. - const journalLines = await fetchAllRows<{ + // Fetched via the two-step entry-lines helper (entries first, then lines + // chunked by entry id, both paginated): see lib/bookkeeping/entry-lines.ts. + const journalLines = await fetchEntryLines<{ id: string debit_amount: number | null credit_amount: number | null - }>(({ from, to }) => - supabase - .from('journal_entry_lines') - .select(` - id, - debit_amount, - credit_amount, - journal_entry:journal_entries!inner( - status, - company_id, - fiscal_period_id - ) - `) - .eq('account_number', '2440') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', periodId) - .in('journal_entries.status', ['posted', 'reversed']) - .order('id', { ascending: true }) - .range(from, to) - , { dedupeBy: (l) => l.id }) + }>({ + supabase, + lineColumns: 'id, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('fiscal_period_id', periodId) + .in('status', ['posted', 'reversed']), + filterLines: (q: EntryLinesQuery) => q.eq('account_number', '2440'), + attachEntriesAs: null, + }) // Account 2440 is a liability: credit normal balance // Balance = credits - debits diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index 9a8cba96..7a732257 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { getOpeningBalances } from './opening-balances' import type { TrialBalanceRow } from '@/types' @@ -25,8 +26,10 @@ import type { TrialBalanceRow } from '@/types' * (classes 3-8) where IB is immaterial: never for balance/statutory reports. * The catalog whitelist + statutory-guard test pin this. * - * Uses joined queries with pagination to handle any number of entries. - * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. + * Uses the shared two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts): + * entries first, then lines chunked by entry id, both paginated, so any + * number of entries is handled without the pathological journal_entries!inner + * embed plan (see entry-lines.ts for the full story). */ export async function generateTrialBalance( supabase: SupabaseClient, @@ -81,37 +84,37 @@ export async function generateTrialBalance( period?.period_start && options.fromDate > period.period_start ) { - const priorLines = await fetchAllRows<{ + const priorLines = await fetchEntryLines<{ id: string account_number: string debit_amount: number credit_amount: number - }>(({ from, to }) => { - let query = supabase - .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', fiscalPeriodId) - .in('journal_entries.status', ['posted', 'reversed']) - .gte('journal_entries.entry_date', period.period_start) - .lt('journal_entries.entry_date', options.fromDate) + }>({ + supabase, + lineColumns: 'id, account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => { + let query = q + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .in('status', ['posted', 'reversed']) + .gte('entry_date', period.period_start) + .lt('entry_date', options.fromDate) - if (dimensionFilter) { - // jsonb containment (@>): served by idx_jel_dimensions_gin. - query = query.contains('dimensions', dimensionFilter) - } + if (obEntryId) { + query = query.neq('id', obEntryId) + } - if (obEntryId) { - query = query.neq('journal_entry_id', obEntryId) - } + if (options?.excludeYearEndClosing) { + query = query.neq('source_type', 'year_end') + } - if (options?.excludeYearEndClosing) { - query = query.neq('journal_entries.source_type', 'year_end') - } - - // Stable total order on the line PK for correct paging (see fetch-all.ts). - return query.order('id', { ascending: true }).range(from, to) - }, { dedupeBy: (r) => r.id }) + return query + }, + filterLines: dimensionFilter + ? // jsonb containment (@>): served by idx_jel_dimensions_gin. + (q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter) + : undefined, + }) for (const line of priorLines) { const existing = openingBalances.get(line.account_number) || { debit: 0, credit: 0 } @@ -128,48 +131,48 @@ export async function generateTrialBalance( // obEntryId between the period query and this query, the OB entry could // be missed from both IB and period. The window is sub-second and the // consequence is a single stale report: acceptable. - const lines = await fetchAllRows<{ + const lines = await fetchEntryLines<{ id: string account_number: string debit_amount: number credit_amount: number - }>(({ from, to }) => { - let query = supabase - .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type, entry_date)') - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', fiscalPeriodId) - .in('journal_entries.status', ['posted', 'reversed']) + }>({ + supabase, + lineColumns: 'id, account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => { + let query = q + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .in('status', ['posted', 'reversed']) - // Date filters are only applied when the caller explicitly asks. The - // period itself is already enforced via the fiscal_period_id join, so - // adding redundant entry_date bounds for the default case would just - // increase query complexity (and break older mocks that don't stub gte - // /lte). The fiscal_period_id constraint plus a CHECK on entry_date in - // the engine keep activity inside the period. - if (options?.fromDate) { - query = query.gte('journal_entries.entry_date', options.fromDate) - } - if (options?.toDate) { - query = query.lte('journal_entries.entry_date', options.toDate) - } + // Date filters are only applied when the caller explicitly asks. The + // period itself is already enforced via fiscal_period_id, so adding + // redundant entry_date bounds for the default case would just + // increase query complexity (and break older mocks that don't stub gte + // /lte). The fiscal_period_id constraint plus a CHECK on entry_date in + // the engine keep activity inside the period. + if (options?.fromDate) { + query = query.gte('entry_date', options.fromDate) + } + if (options?.toDate) { + query = query.lte('entry_date', options.toDate) + } - if (dimensionFilter) { - // jsonb containment (@>): served by idx_jel_dimensions_gin. - query = query.contains('dimensions', dimensionFilter) - } + if (obEntryId) { + query = query.neq('id', obEntryId) + } - if (obEntryId) { - query = query.neq('journal_entry_id', obEntryId) - } + if (options?.excludeYearEndClosing) { + query = query.neq('source_type', 'year_end') + } - if (options?.excludeYearEndClosing) { - query = query.neq('journal_entries.source_type', 'year_end') - } - - // Stable total order on the line PK for correct paging (see fetch-all.ts). - return query.order('id', { ascending: true }).range(from, to) - }, { dedupeBy: (r) => r.id }) + return query + }, + filterLines: dimensionFilter + ? // jsonb containment (@>): served by idx_jel_dimensions_gin. + (q: EntryLinesQuery) => q.contains('dimensions', dimensionFilter) + : undefined, + }) if (lines.length === 0 && openingBalances.size === 0) { return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true } diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts index f355164f..513f2be4 100644 --- a/lib/reports/vat-declaration.ts +++ b/lib/reports/vat-declaration.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import type { VatDeclaration, VatDeclarationRutor, @@ -265,28 +266,21 @@ export async function calculateVatDeclaration( ) // Fetch all posted journal entry lines on VAT-relevant accounts for the period - const lines = await fetchAllRows<{ + const lines = await fetchEntryLines<{ account_number: string debit_amount: number credit_amount: number - }>(({ from, to }) => - supabase - .from('journal_entry_lines') - .select(` - account_number, - debit_amount, - credit_amount, - journal_entries!inner (company_id, entry_date, status) - `) - .in('account_number', VAT_ACCOUNTS) - .eq('journal_entries.company_id', companyId) - .in('journal_entries.status', ['posted', 'reversed']) - .gte('journal_entries.entry_date', start) - .lte('journal_entries.entry_date', end) - // Stable total order for correct paging (see fetch-all.ts). - .order('id', { ascending: true }) - .range(from, to) - ) + }>({ + supabase, + lineColumns: 'account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .in('status', ['posted', 'reversed']) + .gte('entry_date', start) + .lte('entry_date', end), + filterLines: (q: EntryLinesQuery) => q.in('account_number', VAT_ACCOUNTS), + }) // Aggregate debit/credit totals per account const totals = new Map()