Files
accounted/lib/reports/__tests__/supplier-reconciliation.test.ts
T
Jakob WennbergandClaude Fable 5 a8801430f4 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>
2026-07-10 11:03:28 +02:00

322 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock: sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
let calls: Array<{ method: string; args: unknown[] }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
calls.push({ method: m, args })
return b
})
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import { generateReconciliation } from '../supplier-reconciliation'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
calls = []
supabase = makeClient()
})
describe('generateReconciliation', () => {
it('returns reconciled when supplier total matches account 2440 balance', async () => {
results = [
// 0: supplier_invoices
{
data: [
{ remaining_amount: 5000 },
{ remaining_amount: 3000 },
],
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' },
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'e2' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
// Supplier total: 5000 + 3000 = 8000
expect(result.supplier_ledger_total).toBe(8000)
// Account 2440 (credit-normal): credits - debits = 10000 - 2000 = 8000
expect(result.account_2440_balance).toBe(8000)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('paginates the 2440 ledger query: sums >1000 lines instead of truncating at 1000', async () => {
// Regression guard for the silent PostgREST 1000-row cap: fetchAllRows must
// page through ALL ledger lines. A full first page (length === PAGE_SIZE)
// forces a second fetch.
// Unique ids so the dedupe-by-id safety net doesn't collapse rows.
const page1 = Array.from({ length: 1000 }, (_, i) => ({ id: `p1-${i}`, debit_amount: 0, credit_amount: 10 }))
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
// 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')
// 1500 lines × 10 = 15 000. A 1000-row truncation would wrongly yield 10 000.
expect(result.account_2440_balance).toBe(15000)
})
it('detects mismatch when difference != 0', async () => {
results = [
// 0: supplier_invoices, total 5000
{
data: [
{ remaining_amount: 5000 },
],
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' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(5000)
expect(result.account_2440_balance).toBe(7000)
expect(result.difference).toBe(-2000)
expect(result.is_reconciled).toBe(false)
})
it('returns reconciled when both are zero/empty', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null },
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(0)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
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' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(3000)
expect(result.difference).toBe(-3000)
expect(result.is_reconciled).toBe(false)
})
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' },
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'e2' },
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e3' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
// Balance = credits - debits = 15000 - 5000 - 3000 = 7000
expect(result.account_2440_balance).toBe(7000)
})
it('converts foreign-currency remaining_amount to SEK before reconciliation', async () => {
// Reproduces the production bug: 225 EUR + 1 000 SEK was reported as 1 225
// against a 2440 balance of 3 475, flagging a false discrepancy.
results = [
// 0: supplier_invoices, 225 EUR at 11, plus 1 000 SEK
{
data: [
{ remaining_amount: 225, currency: 'EUR', exchange_rate: 11 },
{ remaining_amount: 1000, currency: 'SEK', exchange_rate: null },
],
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' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(3475)
expect(result.account_2440_balance).toBe(3475)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
expect(result.unconverted_fx_count).toBe(0)
})
it('excludes FX invoices without exchange_rate from the SEK total and counts them', async () => {
// An FX invoice without an exchange rate cannot be converted to SEK; the
// sum must not silently add raw foreign currency. The row is excluded and
// counted, so the UI can warn that the reconciliation may be unreliable.
results = [
// 0: supplier_invoices, 100 EUR with no rate (excluded), 1 000 SEK control
{
data: [
{ remaining_amount: 100, currency: 'EUR', exchange_rate: null },
{ remaining_amount: 1000, currency: 'SEK', exchange_rate: null },
],
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' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.unconverted_fx_count).toBe(1)
// EUR row excluded → ledger total is just the SEK 1 000
expect(result.supplier_ledger_total).toBe(1000)
expect(result.account_2440_balance).toBe(1000)
// Numbers match, but the calculation is incomplete (a row was excluded);
// BFL 5 kap requires the period not be stamped Avstämd until the missing
// exchange rate is filled in.
expect(result.is_reconciled).toBe(false)
})
it('counts posted AND reversed 2440 lines (corrected invoice nets correctly)', async () => {
// Regression for the Arcim Technology AB false "Ej avstämd" gap: two supplier
// invoices were registered, corrected via the storno flow, and fully paid.
// The corrected registrations flip to status='reversed'. The leverantörs-
// reskontra shows 0 outstanding, and over posted+reversed the 2440 balance is
// 0 too, but a posted-only query saw only the storno + correction + payment
// legs and reported a phantom −41 121,25 kr debit. The query must include the
// reversed registration leg so both reconcile.
results = [
// 0: supplier_invoices, both paid, nothing outstanding
{ data: [], error: null },
// 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' },
{ debit_amount: 11231.25, credit_amount: 0, journal_entry_id: 'storno' },
{ debit_amount: 0, credit_amount: 11231.25, journal_entry_id: 'correction' },
{ debit_amount: 11231.25, credit_amount: 0, journal_entry_id: 'payment' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(0)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
// Guard the actual fix: the 2440 query must include reversed entries, not
// filter to posted-only (which excluded the reversed registration leg).
// 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(statusFilters.map((c) => c.args[1])).toContainEqual(['posted', 'reversed'])
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{ remaining_amount: 33.33 },
{ remaining_amount: 33.34 },
],
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' },
],
error: null,
},
]
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
expect(result.supplier_ledger_total).toBe(66.67)
expect(result.account_2440_balance).toBe(66.67)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
})