diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 379017cb..2efd2c27 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -27,7 +27,13 @@ const METHOD_LABELS: Record = { interface ReconciliationStatus { bank_transaction_total: number + /** + * @deprecated Kept on the server response for back-compat. The UI no longer + * reads it — `gl_1930_period_movement` is required. + */ gl_1930_balance: number + gl_1930_period_movement: number + gl_1930_opening_balance: number difference: number is_reconciled: boolean matched_count: number @@ -295,12 +301,14 @@ export function BankReconciliationView() {
- Banktransaktioner (summa) + Banktransaktioner i perioden {formatCurrency(status.bank_transaction_total)}
- saldo (huvudbok) - {formatCurrency(status.gl_1930_balance)} + Bokfört på i perioden + + {formatCurrency(status.gl_1930_period_movement)} +
Differens @@ -308,6 +316,13 @@ export function BankReconciliationView() { {formatCurrency(status.difference)}
+ {status.gl_1930_opening_balance !== 0 && ( +

+ Ingående balans (IB) på :{' '} + {formatCurrency(status.gl_1930_opening_balance)} + {' '}— räknas inte i avstämningen. +

+ )}
Matchade: {status.matched_count} Omatchade transaktioner: {status.unmatched_transaction_count} diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index d458cca7..0a9dfe7d 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -10,6 +10,7 @@ import { runReconciliation, manualLink, unlinkReconciliation, + getReconciliationStatus, } from '../bank-reconciliation' import type { UnlinkedGLLine } from '../bank-reconciliation' import { makeTransaction } from '@/tests/helpers' @@ -554,3 +555,134 @@ describe('unlinkReconciliation', () => { expect(result.success).toBe(true) }) }) + +// ============================================================ +// getReconciliationStatus — IB exclusion (PR 3 of #443) +// ============================================================ + +describe('getReconciliationStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + function createQueueMockSupabase() { + const resultQueue: { data: unknown; error: unknown }[] = [] + const enqueue = (...results: { data?: unknown; error?: unknown }[]) => { + for (const r of results) resultQueue.push({ data: r.data ?? null, error: r.error ?? null }) + } + const buildChain = (): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + const next = resultQueue.shift() ?? { data: null, error: null } + return (resolve: (v: unknown) => void) => resolve(next) + } + return (..._args: unknown[]) => buildChain() + }, + } + return new Proxy({}, handler) + } + const supabase = { + from: vi.fn().mockImplementation(() => buildChain()), + rpc: vi.fn().mockImplementation(() => buildChain()), + } + return { supabase, enqueue } + } + + it('reports is_reconciled=true when only the IB voucher is unmatched on 1930', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + // 1) transactions: 1000 SEK matched (journal_entry_id set) + enqueue({ + 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 + enqueue({ + data: [ + { debit_amount: 50000, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } }, + { debit_amount: 1000, credit_amount: 0, journal_entries: { source_type: 'bank_import' } }, + ], + }) + // 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration) + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.gl_1930_balance).toBe(51000) // includes IB + expect(status.gl_1930_period_movement).toBe(1000) // excludes IB + expect(status.gl_1930_opening_balance).toBe(50000) + expect(status.bank_transaction_total).toBe(1000) + expect(status.difference).toBe(0) + expect(status.is_reconciled).toBe(true) + expect(status.unmatched_gl_line_count).toBe(0) + }) + + it('reports is_reconciled=false and a non-zero difference when a real bank tx is unmatched', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + // 1) transactions: 1500 total, only 1000 matched + enqueue({ + data: [ + { amount: 1000, journal_entry_id: 'je-1', reconciliation_method: null }, + { amount: 500, journal_entry_id: null, reconciliation_method: null }, + ], + }) + // 2) GL lines: 50,000 IB + 1000 booked + enqueue({ + data: [ + { debit_amount: 50000, credit_amount: 0, journal_entries: { source_type: 'opening_balance' } }, + { debit_amount: 1000, credit_amount: 0, journal_entries: { source_type: 'bank_import' } }, + ], + }) + // 3) RPC: empty + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.bank_transaction_total).toBe(1500) + expect(status.gl_1930_period_movement).toBe(1000) + expect(status.gl_1930_opening_balance).toBe(50000) + expect(status.difference).toBe(500) // bank > GL period movement + expect(status.is_reconciled).toBe(false) + expect(status.unmatched_transaction_count).toBe(1) + }) + + it('handles companies with no IB on 1930 (period_movement === gl_balance)', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + enqueue({ data: [{ amount: 100, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact' }] }) + enqueue({ + data: [{ debit_amount: 100, credit_amount: 0, journal_entries: { source_type: 'bank_import' } }], + }) + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.gl_1930_opening_balance).toBe(0) + expect(status.gl_1930_period_movement).toBe(100) + expect(status.gl_1930_balance).toBe(100) + expect(status.difference).toBe(0) + 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. + const { supabase, enqueue } = createQueueMockSupabase() + + enqueue({ data: [] }) + enqueue({ + data: [ + { debit_amount: 1000, credit_amount: 0, journal_entries: [{ source_type: 'opening_balance' }] }, + { debit_amount: 200, credit_amount: 0, journal_entries: [{ source_type: 'bank_import' }] }, + ], + }) + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.gl_1930_opening_balance).toBe(1000) + expect(status.gl_1930_period_movement).toBe(200) + }) +}) diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 673ddf1e..dc8e7d13 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -36,7 +36,21 @@ export interface ReconciliationRunResult { export interface ReconciliationStatus { bank_transaction_total: number + /** + * @deprecated Use `gl_1930_period_movement` for the reconciliation diff. This + * field is preserved for back-compat with persisted status snapshots produced + * before the IB-exclusion change; new consumers reading this to compute the + * "real" difference will be off by the IB amount whenever a SIE-imported + * opening balance exists on 1930. The `difference` field on this interface + * is computed against `gl_1930_period_movement`, not this. + */ gl_1930_balance: number + /** Ledger movement on 1930 excluding source_type='opening_balance' lines. */ + gl_1930_period_movement: number + /** IB on 1930 within the date range — surfaced separately so reconciliation + * doesn't treat it as an unmatched bank transaction. */ + gl_1930_opening_balance: number + /** bankTotal − gl_1930_period_movement. Zero when every period transaction is matched. */ difference: number is_reconciled: boolean matched_count: number @@ -232,10 +246,13 @@ export async function getReconciliationStatus( const { data: transactions } = await txQuery - // Get GL bank account lines (all, not just unlinked) + // Get GL bank account lines (all, not just unlinked). Pull source_type + // from the join so we can split IB out of the period-movement comparison — + // an opening_balance line on 1930 is the prior year's closing balance, not + // a bank transaction we should expect to match. let glQuery = supabase .from('journal_entry_lines') - .select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)') + .select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status, source_type)') .eq('account_number', bankAccount) .eq('journal_entries.company_id', companyId) .eq('journal_entries.status', 'posted') @@ -245,16 +262,38 @@ export async function getReconciliationStatus( const { data: glLines } = await glQuery + type GlLineRow = { + debit_amount: number | string | null + credit_amount: number | string | null + journal_entries: { source_type?: string | null } | { source_type?: string | null }[] | null + } + function isOpeningBalance(line: GlLineRow): boolean { + const je = line.journal_entries + if (!je) return false + // Supabase typings sometimes widen embedded relations to arrays even when + // the join is one-to-one. Handle both shapes defensively. + const sourceType = Array.isArray(je) ? je[0]?.source_type : je.source_type + return sourceType === 'opening_balance' + } + // Calculate totals const bankTotal = (transactions || []).reduce( (sum, tx) => sum + (Number(tx.amount) || 0), 0 ) - const glBalance = (glLines || []).reduce( + const allLines = (glLines || []) as GlLineRow[] + const glBalance = allLines.reduce( (sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0), 0 ) + const glOpeningBalance = allLines + .filter(isOpeningBalance) + .reduce( + (sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0), + 0 + ) + const glPeriodMovement = glBalance - glOpeningBalance const matchedCount = (transactions || []).filter( (tx) => tx.journal_entry_id !== null @@ -264,14 +303,17 @@ export async function getReconciliationStatus( (tx) => tx.journal_entry_id === null ).length - // Unlinked GL lines count + // Unlinked GL lines count (RPC excludes source_type='opening_balance' since + // 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql) const unlinkedLines = await fetchUnlinkedGLLines(supabase, companyId, dateFrom, dateTo) - const difference = Math.round((bankTotal - glBalance) * 100) / 100 + const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100 return { bank_transaction_total: Math.round(bankTotal * 100) / 100, gl_1930_balance: Math.round(glBalance * 100) / 100, + gl_1930_period_movement: Math.round(glPeriodMovement * 100) / 100, + gl_1930_opening_balance: Math.round(glOpeningBalance * 100) / 100, difference, is_reconciled: Math.abs(difference) < 0.01, matched_count: matchedCount, diff --git a/supabase/migrations/20260514132534_unlinked_1930_lines_exclude_opening_balance.sql b/supabase/migrations/20260514132534_unlinked_1930_lines_exclude_opening_balance.sql new file mode 100644 index 00000000..a3d7fa0b --- /dev/null +++ b/supabase/migrations/20260514132534_unlinked_1930_lines_exclude_opening_balance.sql @@ -0,0 +1,78 @@ +-- Exclude opening_balance vouchers from the unmatched-1930 set. +-- +-- IB lines (source_type = 'opening_balance', typically created by SIE import +-- or year-end carry-over) post to 1930 on period_start. They have no +-- counterpart in the bank feed by definition — the bank statement starts at +-- IB and accumulates from there. Counting them as "unmatched" produces a +-- phantom voucher in the reconciliation UI and a difference equal to the IB +-- amount, even when every real bank transaction is matched. +-- +-- Supersedes the prior definitions in: +-- - supabase/migrations/20240101000030_bank_reconciliation.sql +-- - supabase/migrations/20260401100000_fix_unlinked_1930_lines_company_id.sql +-- - supabase/migrations/20260415000000_schema_sync.sql + +DROP FUNCTION IF EXISTS public.get_unlinked_1930_lines(uuid, date, date); + +CREATE FUNCTION public.get_unlinked_1930_lines( + p_company_id UUID, + p_date_from DATE DEFAULT NULL, + p_date_to DATE DEFAULT NULL +) +RETURNS TABLE ( + line_id UUID, + journal_entry_id UUID, + debit_amount NUMERIC, + credit_amount NUMERIC, + line_description TEXT, + entry_date DATE, + voucher_number INT, + voucher_series TEXT, + entry_description TEXT, + source_type TEXT +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + jel.id AS line_id, + je.id AS journal_entry_id, + jel.debit_amount, + jel.credit_amount, + jel.line_description, + je.entry_date, + je.voucher_number, + je.voucher_series, + je.description AS entry_description, + je.source_type + FROM public.journal_entry_lines jel + JOIN public.journal_entries je ON je.id = jel.journal_entry_id + WHERE jel.account_number = '1930' + AND je.company_id = p_company_id + AND je.status = 'posted' + -- Unconditional exclusion. By gnubok's data model 'opening_balance' is + -- reserved for the fiscal-year IB voucher (always posts on period_start); + -- mid-year corrective entries use 'correction' or 'manual'. So this filter + -- can't accidentally hide a legitimate mid-period unmatched entry — there + -- is no such thing as a mid-period opening_balance. + -- + -- IS DISTINCT FROM is NULL-safe. Today journal_entries.source_type is + -- NOT NULL, so the only behavioural difference vs `<>` is defensive: if the + -- NOT NULL constraint is ever relaxed, `<>` would silently drop NULL rows + -- (NULL <> 'x' evaluates to NULL, not TRUE), making them invisible to + -- reconciliation. IS DISTINCT FROM treats NULL as a distinct value. + AND je.source_type IS DISTINCT FROM 'opening_balance' + AND (p_date_from IS NULL OR je.entry_date >= p_date_from) + AND (p_date_to IS NULL OR je.entry_date <= p_date_to) + AND NOT EXISTS ( + SELECT 1 + FROM public.transactions t + WHERE t.journal_entry_id = je.id + AND t.company_id = p_company_id + ) + ORDER BY je.entry_date, je.voucher_number; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/get_unlinked_1930_lines.pg.test.ts b/tests/pg/get_unlinked_1930_lines.pg.test.ts new file mode 100644 index 00000000..3f781a09 --- /dev/null +++ b/tests/pg/get_unlinked_1930_lines.pg.test.ts @@ -0,0 +1,168 @@ +/** + * pg-real test for get_unlinked_1930_lines (PR 3 of erp-mafia/gnubok#443). + * + * Verifies the RPC excludes opening_balance vouchers from the unmatched-1930 + * set, while preserving the existing behavior for posted bank-import vouchers, + * date-range filtering, and company scoping. + * + * Migration: 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql + */ +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool } from './setup' +import { insertAuthUser, insertCompany, insertFiscalPeriod } from './fixtures' + +async function insertPostedJournalEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + entryDate: string + sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import' + voucherNumber: number + amount?: number +}): Promise { + const id = randomUUID() + const amount = params.amount ?? 1000 + // Insert as posted directly. This bypasses commit_journal_entry's voucher + // sequencing; that's fine for testing the read-side RPC, which only cares + // about (account_number, status, source_type, date_range, link presence). + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, $5, 'A', $6, $7, $8, 'posted')`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.entryDate, + `Test ${params.sourceType}`, + params.sourceType, + ], + ) + // Balanced pair on 1930 + 2091 (balanserad vinst/förlust — the realistic + // carried-forward counterpart for an IB on a bank account; harmless for the + // other source_types where the test only cares about the 1930 side). + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, '1930', $2, 0), + ($1, '2091', 0, $2)`, + [id, amount], + ) + return id +} + +describe('get_unlinked_1930_lines RPC — opening_balance exclusion', () => { + it('excludes opening_balance vouchers from the unmatched-1930 set', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2026-01-01', + periodEnd: '2026-12-31', + }) + + // Three vouchers on 1930, all posted, none linked to a transaction: + // IB voucher (source_type='opening_balance') — should be EXCLUDED + // Bank import voucher — should be RETURNED + // Manual voucher — should be RETURNED + await insertPostedJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-01-01', + sourceType: 'opening_balance', + voucherNumber: 1, + amount: 50000, + }) + const bankEntryId = await insertPostedJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-03-15', + sourceType: 'bank_transaction', + voucherNumber: 2, + amount: 1500, + }) + const manualEntryId = await insertPostedJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-04-20', + sourceType: 'manual', + voucherNumber: 3, + amount: 200, + }) + + const { rows } = await getPool().query( + `SELECT journal_entry_id, source_type FROM public.get_unlinked_1930_lines($1)`, + [companyId], + ) + + const returnedIds = new Set(rows.map((r) => r.journal_entry_id)) + expect(returnedIds.has(bankEntryId)).toBe(true) + expect(returnedIds.has(manualEntryId)).toBe(true) + // IB voucher should NOT be returned regardless of company/date scope. + expect(rows.find((r) => r.source_type === 'opening_balance')).toBeUndefined() + }) + + it('still applies date_from / date_to filtering', async () => { + const userId = await insertAuthUser() + const companyId = await insertCompany({ createdBy: userId }) + const fiscalPeriodId = await insertFiscalPeriod({ + userId, + companyId, + periodStart: '2026-01-01', + periodEnd: '2026-12-31', + }) + + await insertPostedJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-02-01', + sourceType: 'bank_transaction', + voucherNumber: 10, + }) + await insertPostedJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-08-01', + sourceType: 'bank_transaction', + voucherNumber: 11, + }) + + // Window covers only the second voucher. + const { rows } = await getPool().query( + `SELECT entry_date FROM public.get_unlinked_1930_lines($1, $2, $3) ORDER BY entry_date`, + [companyId, '2026-07-01', '2026-12-31'], + ) + + expect(rows).toHaveLength(1) + expect(rows[0].entry_date.toISOString().slice(0, 10)).toBe('2026-08-01') + }) + + it('scopes to the requested company only', async () => { + const userA = await insertAuthUser() + const userB = await insertAuthUser() + const companyA = await insertCompany({ createdBy: userA, name: 'A' }) + const companyB = await insertCompany({ createdBy: userB, name: 'B' }) + const fpA = await insertFiscalPeriod({ userId: userA, companyId: companyA }) + const fpB = await insertFiscalPeriod({ userId: userB, companyId: companyB }) + + await insertPostedJournalEntry({ + userId: userA, companyId: companyA, fiscalPeriodId: fpA, + entryDate: '2026-03-01', sourceType: 'bank_transaction', voucherNumber: 1, + }) + await insertPostedJournalEntry({ + userId: userB, companyId: companyB, fiscalPeriodId: fpB, + entryDate: '2026-03-01', sourceType: 'bank_transaction', voucherNumber: 1, + }) + + const { rows: rowsA } = await getPool().query( + `SELECT 1 FROM public.get_unlinked_1930_lines($1)`, + [companyA], + ) + const { rows: rowsB } = await getPool().query( + `SELECT 1 FROM public.get_unlinked_1930_lines($1)`, + [companyB], + ) + expect(rowsA).toHaveLength(1) + expect(rowsB).toHaveLength(1) + }) +})