fix(reports): stop driving report queries from the unfiltered journal_entry_lines side (#971)
* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side
Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.
Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:
1. fetch matching journal_entries (id + caller-selected columns)
filtered by company_id / fiscal_period_id / status / entry_date /
source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
(line.journal_entries = {...}, aliasable) and sort lines by id
ascending to preserve the old .order('id') semantics.
Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.
Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): stop echoing raw error messages from the general-ledger route
The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.
From the 2026-07-09 production log triage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -342,11 +342,13 @@ describe('updateAsset: acquisition-basis correction guard', () => {
|
||||
|
||||
/**
|
||||
* Minimal Supabase mock that captures the final UPDATE payload. updateAsset's
|
||||
* correction guard touches three tables:
|
||||
* correction guard touches four tables:
|
||||
* - 'assets' → getAsset (.maybeSingle) and the update (.single)
|
||||
* - 'depreciation_schedules' → hasPostedDepreciation (1st call, head {count})
|
||||
* then hasManualDepreciationPosted (2nd call,
|
||||
* .select('journal_entry_id') → {data})
|
||||
* - 'journal_entries' → hasManualDepreciationPosted entries step of the
|
||||
* two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts)
|
||||
* - 'journal_entry_lines' → hasManualDepreciationPosted ledger scan → {data}
|
||||
*/
|
||||
function mockForUpdate(
|
||||
@@ -382,11 +384,32 @@ describe('updateAsset: acquisition-basis correction guard', () => {
|
||||
)
|
||||
return chain
|
||||
}
|
||||
if (table === 'journal_entries') {
|
||||
// Entries step of the two-step fetch: derive the entry ids from the
|
||||
// line fixtures so the chunked line query has ids to ask for.
|
||||
const entryIds = [
|
||||
...new Set((opts.accumulatedCredits ?? []).map((l) => l.journal_entry_id)),
|
||||
]
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
chain.order = vi.fn(() => chain)
|
||||
chain.range = vi.fn(() => chain)
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: entryIds.length > 0 ? entryIds.map((id) => ({ id })) : [{ id: 'entry-none' }],
|
||||
error: null,
|
||||
})
|
||||
return chain
|
||||
}
|
||||
if (table === 'journal_entry_lines') {
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
chain.gt = vi.fn(() => chain)
|
||||
chain.in = vi.fn(() => chain)
|
||||
chain.order = vi.fn(() => chain)
|
||||
chain.range = vi.fn(() => chain)
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: opts.accumulatedCredits ?? [], error: null })
|
||||
return chain
|
||||
|
||||
@@ -5,19 +5,24 @@ import {
|
||||
} from '../tax-provision/sarskild-loneskatt-calculator'
|
||||
|
||||
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
|
||||
const builder = {
|
||||
select: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
gte: vi.fn(),
|
||||
lte: vi.fn(),
|
||||
then: undefined as unknown as (resolve: (v: { data: unknown; error: unknown }) => void) => void,
|
||||
// The calculator uses the two-step entry-lines fetch
|
||||
// (lib/bookkeeping/entry-lines.ts): call 1 reads journal_entries, call 2
|
||||
// reads journal_entry_lines for those entry ids.
|
||||
const responses: Array<{ data: unknown; error: unknown }> = [
|
||||
{ data: [{ id: 'entry-1' }], error: null },
|
||||
{ data: rows, error: null },
|
||||
]
|
||||
let call = 0
|
||||
const makeBuilder = () => {
|
||||
const result = responses[call++] ?? { data: null, error: null }
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) => resolve(result)
|
||||
return b
|
||||
}
|
||||
builder.select.mockReturnValue(builder)
|
||||
builder.eq.mockReturnValue(builder)
|
||||
builder.gte.mockReturnValue(builder)
|
||||
builder.lte.mockReturnValue(builder)
|
||||
builder.then = (resolve) => resolve({ data: rows, error: null })
|
||||
return { from: vi.fn().mockReturnValue(builder) } as unknown as Parameters<
|
||||
return { from: vi.fn().mockImplementation(() => makeBuilder()) } as unknown as Parameters<
|
||||
typeof calculateSarskildLoneskatt
|
||||
>[0]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import type {
|
||||
Asset,
|
||||
AssetCategory,
|
||||
@@ -269,21 +270,26 @@ async function hasManualDepreciationPosted(
|
||||
.filter((id): id is string => id !== null),
|
||||
)
|
||||
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, journal_entries!inner(company_id, status)')
|
||||
.eq('account_number', asset.bas_accumulated_account)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gt('credit_amount', 0)
|
||||
if (error) {
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts): posted
|
||||
// entries for the company first, then their lines on the accumulated
|
||||
// account with a credit.
|
||||
let lines: { journal_entry_id: string }[]
|
||||
try {
|
||||
lines = await fetchEntryLines<{ journal_entry_id: string }>({
|
||||
supabase,
|
||||
lineColumns: 'journal_entry_id',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q.eq('company_id', companyId).eq('status', 'posted'),
|
||||
filterLines: (q: EntryLinesQuery) =>
|
||||
q.eq('account_number', asset.bas_accumulated_account).gt('credit_amount', 0),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to scan ledger depreciation for asset ${asset.id}: ${error.message}`,
|
||||
`Failed to scan ledger depreciation for asset ${asset.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
return ((lines ?? []) as { journal_entry_id: string }[]).some(
|
||||
(line) => !siblingEngineEntries.has(line.journal_entry_id),
|
||||
)
|
||||
return lines.some((line) => !siblingEngineEntries.has(line.journal_entry_id))
|
||||
}
|
||||
|
||||
export async function updateAsset(
|
||||
@@ -861,24 +867,29 @@ export async function getAccumulatedDepreciationAsOf(
|
||||
}
|
||||
if (!asset) return 0
|
||||
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
'debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)',
|
||||
)
|
||||
.eq('account_number', asset.bas_expense_account)
|
||||
.eq('journal_entries.company_id', asset.company_id)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.lte('journal_entries.entry_date', asOfDate)
|
||||
|
||||
if (error) {
|
||||
let data: Row[]
|
||||
try {
|
||||
data = await fetchEntryLines<Row>({
|
||||
supabase,
|
||||
lineColumns: 'debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', asset.company_id)
|
||||
.eq('status', 'posted')
|
||||
.lte('entry_date', asOfDate),
|
||||
filterLines: (q: EntryLinesQuery) =>
|
||||
q.eq('account_number', asset.bas_expense_account),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to sum accumulated depreciation for asset ${assetId}: ${error.message}`,
|
||||
`Failed to sum accumulated depreciation for asset ${assetId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return ((data ?? []) as Row[]).reduce((sum, row) => {
|
||||
return data.reduce((sum, row) => {
|
||||
// Expense account: normal balance is debit. Net = debit − credit so
|
||||
// any storno (reversal) is netted out.
|
||||
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import type { ProposedDisposition } from '../types'
|
||||
|
||||
@@ -67,25 +68,32 @@ export async function sumPostedYearEndDispositions(
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
): Promise<number> {
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status, source_type)',
|
||||
)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.eq('journal_entries.source_type', 'year_end')
|
||||
if (error) {
|
||||
throw new Error(`Failed to read posted dispositions: ${error.message}`)
|
||||
}
|
||||
type Row = {
|
||||
account_number: string
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
}
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
let data: Row[]
|
||||
try {
|
||||
data = await fetchEntryLines<Row>({
|
||||
supabase,
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'posted')
|
||||
.eq('source_type', 'year_end'),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read posted dispositions: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
let effect = 0
|
||||
for (const row of (data ?? []) as Row[]) {
|
||||
for (const row of data) {
|
||||
const acc = row.account_number
|
||||
if (!(acc.startsWith('88') || acc === '7533')) continue
|
||||
effect += (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import type { ProposedDisposition } from '../types'
|
||||
|
||||
/** Särskild löneskatt på pensionskostnader (SLP). 24.26 % per SLF 1991:687. */
|
||||
@@ -35,23 +36,29 @@ export async function calculateSarskildLoneskatt(
|
||||
fiscalPeriodId: string,
|
||||
options: { manualAdjustment?: number } = {},
|
||||
): Promise<ProposedDisposition | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(
|
||||
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status)',
|
||||
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
let data: Row[]
|
||||
try {
|
||||
data = await fetchEntryLines<Row>({
|
||||
supabase,
|
||||
lineColumns: 'account_number, debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) =>
|
||||
q
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'posted'),
|
||||
filterLines: (q: EntryLinesQuery) =>
|
||||
q.gte('account_number', '7410').lte('account_number', '7419'),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to fetch pension costs: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('account_number', '7410')
|
||||
.lte('account_number', '7419')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch pension costs: ${error.message}`)
|
||||
}
|
||||
|
||||
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
|
||||
const pensionCostsBooked = ((data ?? []) as Row[]).reduce((sum, row) => {
|
||||
const pensionCostsBooked = data.reduce((sum, row) => {
|
||||
// Cost account: normal balance is debit, so net = debit − credit.
|
||||
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
|
||||
}, 0)
|
||||
|
||||
@@ -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<object> = {
|
||||
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<string, unknown>).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<Record<string, unknown>>({
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<TLine extends { id?: unknown }>(
|
||||
supabase: SupabaseClient,
|
||||
entryIds: string[],
|
||||
lineColumns: string,
|
||||
filterLines?: (query: EntryLinesQuery) => EntryLinesQuery
|
||||
): Promise<TLine[]> {
|
||||
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<TLine>(
|
||||
({ 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<TLine>(
|
||||
options: FetchEntryLinesOptions
|
||||
): Promise<TLine[]> {
|
||||
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<Record<string, unknown>>(
|
||||
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[]
|
||||
}
|
||||
@@ -1000,10 +1000,18 @@ describe('getReconciliationStatus', () => {
|
||||
data: [{ amount: 1000, journal_entry_id: 'je-tx', reconciliation_method: 'auto_exact' }],
|
||||
})
|
||||
// 2) journal_entry_lines: 50,000 IB debit + 1000 matched debit on 1930
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
|
||||
{ debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
|
||||
{ id: 'je-gen1', status: 'posted', source_type: 'opening_balance' },
|
||||
{ id: 'je-gen2', status: 'posted', source_type: 'bank_import' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-gen1' },
|
||||
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-gen2' },
|
||||
],
|
||||
})
|
||||
// 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration)
|
||||
@@ -1031,10 +1039,18 @@ describe('getReconciliationStatus', () => {
|
||||
],
|
||||
})
|
||||
// 2) GL lines: 50,000 IB + 1000 booked
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 50000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'opening_balance' } },
|
||||
{ debit_amount: 1000, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } },
|
||||
{ id: 'je-gen3', status: 'posted', source_type: 'opening_balance' },
|
||||
{ id: 'je-gen4', status: 'posted', source_type: 'bank_import' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-gen3' },
|
||||
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-gen4' },
|
||||
],
|
||||
})
|
||||
// 3) RPC: empty
|
||||
@@ -1054,8 +1070,10 @@ describe('getReconciliationStatus', () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
enqueue({ data: [{ amount: 100, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact' }] })
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id.
|
||||
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
|
||||
enqueue({
|
||||
data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { status: 'posted', source_type: 'bank_import' } }],
|
||||
data: [{ debit_amount: 100, credit_amount: 0, journal_entry_id: 'je-1' }],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
|
||||
@@ -1068,16 +1086,24 @@ describe('getReconciliationStatus', () => {
|
||||
expect(status.is_reconciled).toBe(true)
|
||||
})
|
||||
|
||||
it('handles array-shaped journal_entries embed (Supabase wide typing)', async () => {
|
||||
// Supabase typings sometimes widen embedded relations to arrays. The
|
||||
// implementation handles both shapes: verify here.
|
||||
it('splits IB from period movement via the reattached parent entry', async () => {
|
||||
// The two-step entry-lines fetch reattaches the parent entry object on
|
||||
// each line under `journal_entries`; entryOf() reads source_type from it
|
||||
// to split the IB summary out of the period movement.
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
enqueue({ data: [] })
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id.
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 1000, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'opening_balance' }] },
|
||||
{ debit_amount: 200, credit_amount: 0, journal_entries: [{ status: 'posted', source_type: 'bank_import' }] },
|
||||
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance' },
|
||||
{ id: 'je-mv', status: 'posted', source_type: 'bank_import' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
||||
{ debit_amount: 200, credit_amount: 0, journal_entry_id: 'je-mv' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
@@ -1105,11 +1131,19 @@ describe('getReconciliationStatus', () => {
|
||||
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
|
||||
})
|
||||
// 2) GL lines on 1930: reversed original (debit 25000), storno (credit
|
||||
// 25000), correction (debit 25000). All three are summed.
|
||||
// 25000), correction (debit 25000). All three are summed. Served via
|
||||
// the two-step entry-lines fetch: entries page, then lines by entry id.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
|
||||
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
|
||||
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
|
||||
],
|
||||
})
|
||||
const lines = [
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
|
||||
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-orig' },
|
||||
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-storno' },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
|
||||
]
|
||||
enqueue({ data: lines })
|
||||
// 3) RPC: empty
|
||||
@@ -1143,11 +1177,20 @@ describe('getReconciliationStatus', () => {
|
||||
enqueue({
|
||||
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
|
||||
})
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 24000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
|
||||
{ debit_amount: 0, credit_amount: 24000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
|
||||
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
|
||||
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
|
||||
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 24000, credit_amount: 0, journal_entry_id: 'je-orig' },
|
||||
{ debit_amount: 0, credit_amount: 24000, journal_entry_id: 'je-storno' },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
@@ -1173,11 +1216,20 @@ describe('getReconciliationStatus', () => {
|
||||
enqueue({
|
||||
data: [{ amount: 25000, journal_entry_id: 'je-orig', reconciliation_method: 'manual' }],
|
||||
})
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
|
||||
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
|
||||
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
|
||||
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
|
||||
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-orig' },
|
||||
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-storno' },
|
||||
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
@@ -1199,9 +1251,16 @@ describe('getReconciliationStatus', () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
enqueue({ data: [] }) // no bank-feed transactions
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 500, credit_amount: 0, journal_entries: { id: 'je-manual', status: 'posted', source_type: 'manual' } },
|
||||
{ id: 'je-manual', status: 'posted', source_type: 'manual' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 500, credit_amount: 0, journal_entry_id: 'je-manual' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
@@ -1236,12 +1295,22 @@ describe('getReconciliationStatus', () => {
|
||||
// 2) GL lines on the account: prior-period movements (6000 + 4000 = 10000,
|
||||
// dated 2025) that net to the IB, the IB itself (10000, dated 2026-01-01),
|
||||
// and the current period's movement (5000, dated 2026).
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 6000, credit_amount: 0, journal_entries: { id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-03-31' } },
|
||||
{ debit_amount: 4000, credit_amount: 0, journal_entries: { id: 'je-p2', status: 'posted', source_type: 'import', entry_date: '2025-09-30' } },
|
||||
{ debit_amount: 10000, credit_amount: 0, journal_entries: { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' } },
|
||||
{ debit_amount: 5000, credit_amount: 0, journal_entries: { id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-15' } },
|
||||
{ id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-03-31' },
|
||||
{ id: 'je-p2', status: 'posted', source_type: 'import', entry_date: '2025-09-30' },
|
||||
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
||||
{ id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-15' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 6000, credit_amount: 0, journal_entry_id: 'je-p1' },
|
||||
{ debit_amount: 4000, credit_amount: 0, journal_entry_id: 'je-p2' },
|
||||
{ debit_amount: 10000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
||||
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'je-c1' },
|
||||
],
|
||||
})
|
||||
// 3) RPC: empty
|
||||
@@ -1273,11 +1342,20 @@ describe('getReconciliationStatus', () => {
|
||||
{ date: '2026-04-10', amount: 3000, journal_entry_id: 'je-c', reconciliation_method: 'manual' },
|
||||
],
|
||||
})
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 8000, credit_amount: 0, journal_entries: { id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-05-01' } },
|
||||
{ debit_amount: 8000, credit_amount: 0, journal_entries: { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' } },
|
||||
{ debit_amount: 3000, credit_amount: 0, journal_entries: { id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-04-10' } },
|
||||
{ id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-05-01' },
|
||||
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
||||
{ id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-04-10' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'je-p1' },
|
||||
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
||||
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'je-c1' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
@@ -1308,11 +1386,20 @@ describe('getReconciliationStatus', () => {
|
||||
{ date: '2026-03-15', amount: 3000, journal_entry_id: 'je-mar', reconciliation_method: 'manual' },
|
||||
],
|
||||
})
|
||||
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
||||
// (parents reattached under journal_entries by the helper).
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 9000, credit_amount: 0, journal_entries: { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' } },
|
||||
{ debit_amount: 2000, credit_amount: 0, journal_entries: { id: 'je-feb1', status: 'posted', source_type: 'import', entry_date: '2026-02-10' } },
|
||||
{ debit_amount: 3000, credit_amount: 0, journal_entries: { id: 'je-mar1', status: 'posted', source_type: 'import', entry_date: '2026-03-15' } },
|
||||
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
||||
{ id: 'je-feb1', status: 'posted', source_type: 'import', entry_date: '2026-02-10' },
|
||||
{ id: 'je-mar1', status: 'posted', source_type: 'import', entry_date: '2026-03-15' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 9000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
||||
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'je-feb1' },
|
||||
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'je-mar1' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Transaction, ReconciliationMethod } from '@/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -481,18 +482,20 @@ export async function getReconciliationStatus(
|
||||
|
||||
// posted + reversed = the ledger balance, exactly as the trial balance counts
|
||||
// it. The .in() filter on the query already excludes draft/cancelled.
|
||||
// Paginated for the same 1000-row-cap reason as the transactions above: a
|
||||
// silently truncated GL side would corrupt gl_1930_balance and the difference.
|
||||
const fetchedLines = await fetchAllRows<GlLineRow>(({ from, to }) => {
|
||||
let glQuery = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
|
||||
.eq('account_number', bankAccount)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
|
||||
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
|
||||
return glQuery.order('id').range(from, to)
|
||||
// Fetched via the two-step entry-lines helper (entries first, then lines
|
||||
// chunked by entry id, both paginated): a silently truncated GL side would
|
||||
// corrupt gl_1930_balance and the difference. See lib/bookkeeping/entry-lines.ts.
|
||||
const fetchedLines = await fetchEntryLines<GlLineRow>({
|
||||
supabase,
|
||||
entryColumns: 'id, company_id, entry_date, status, source_type',
|
||||
lineColumns: 'debit_amount, credit_amount',
|
||||
filterEntries: (q: EntryLinesQuery) => {
|
||||
let glQuery = q.eq('company_id', companyId).in('status', ['posted', 'reversed'])
|
||||
if (dateFrom) glQuery = glQuery.gte('entry_date', dateFrom)
|
||||
if (dateTo) glQuery = glQuery.lte('entry_date', dateTo)
|
||||
return glQuery
|
||||
},
|
||||
filterLines: (q: EntryLinesQuery) => q.eq('account_number', bankAccount),
|
||||
})
|
||||
|
||||
// Floor the window at the most recent opening-balance date on this account
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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-<sourceId>).
|
||||
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'),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>
|
||||
}>(({ 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.
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<RawLine>(({ 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<RawLine>({
|
||||
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(
|
||||
|
||||
@@ -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<RcBasisGap[]> {
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
const rcLines = (await fetchAllRows<unknown>(({ 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<unknown>({
|
||||
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<SiblingLineRow>(({ 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<SiblingLineRow>(
|
||||
supabase,
|
||||
entryIds,
|
||||
'id, journal_entry_id, account_number, debit_amount, credit_amount',
|
||||
)
|
||||
|
||||
const basisByEntry = new Map<string, number>()
|
||||
|
||||
+11
-20
@@ -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<JournalEntryLine & { journal_entry_id: string }>(({ 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<JournalEntryLine & { journal_entry_id: string }>(
|
||||
supabase,
|
||||
entries.map((e) => e.id),
|
||||
'*'
|
||||
)
|
||||
|
||||
const linesByEntryId = new Map<string, JournalEntryLine[]>()
|
||||
for (const line of allLines) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<string, { debit: number; credit: number }>()
|
||||
|
||||
Reference in New Issue
Block a user