fix(reports): stop driving report queries from the unfiltered journal_entry_lines side (#971)

* fix(reports): drive report line queries from journal_entries, not the unfiltered lines side

Every report generator fetched journal_entry_lines with a
journal_entries!inner(...) embed and put the tenant filter on the
embedded side (.eq('journal_entries.company_id', ...)). PostgREST
compiles that to a correlated INNER JOIN LATERAL with a parameterized
LIMIT inside, which blocks join reordering: Postgres walked the ENTIRE
journal_entry_lines table (603k rows, all tenants) per report query.
Measured in production: 13.6 s vs 2.7 ms for the equivalent plain join,
against Supabase's 8 s statement_timeout; nightly cloud backups failed
for 5 of 11 companies on 2026-07-09 and a GL report 500'd.

Introduce lib/bookkeeping/entry-lines.ts with a shared two-step fetch:

1. fetch matching journal_entries (id + caller-selected columns)
   filtered by company_id / fiscal_period_id / status / entry_date /
   source_type, paginated via fetchAllRows;
2. fetch journal_entry_lines with .in('journal_entry_id', chunk) in
   chunks of 100 ids (URL-length safety), paginated per chunk;
3. reattach the parent entry to each line under the embed's key shape
   (line.journal_entries = {...}, aliasable) and sort lines by id
   ascending to preserve the old .order('id') semantics.

Converted call sites (selected columns and filters preserved):
trial-balance (x2), general-ledger, journal-register, sie-export
(reuses its existing entry list via fetchLinesByEntryIds),
vat-declaration, dimension-pnl, opening-balances, monthly-breakdown,
periodisk-sammanstallning, rc-basis-gaps (sibling-line fetch now also
chunked), ar-reconciliation, supplier-reconciliation,
bank-reconciliation, asset-service (x2), bolagsskatt-calculator,
sarskild-loneskatt-calculator.

Tests: unit tests for the helper (chunk size, reattachment shape,
forced id/journal_entry_id columns, empty result, cross-chunk sort,
error propagation); existing report/reconciliation/bokslut test mocks
updated to the two-step query shape, preserving every assertion about
report output.

From the 2026-07-09 production log triage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reports): stop echoing raw error messages from the general-ledger route

The catch handler returned err.message to the client in
details.reason; internal error strings (SQL fragments, table names,
timeout messages) must not reach the browser. The error is already
logged server-side with the request id, so the client envelope keeps
only the REPORT_GENERATION_FAILED code.

From the 2026-07-09 production log triage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-10 11:03:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent da859d7236
commit a8801430f4
34 changed files with 1456 additions and 591 deletions
@@ -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())
+9 -1
View File
@@ -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())
+84 -28
View File
@@ -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,
},
+64 -18
View File
@@ -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,
},
+50 -62
View File
@@ -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'),
+27 -32
View File
@@ -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())
+10 -1
View File
@@ -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())
+84 -5
View File
@@ -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 },