diff --git a/lib/reports/__tests__/general-ledger.test.ts b/lib/reports/__tests__/general-ledger.test.ts index 628a1b2d..e12c30ef 100644 --- a/lib/reports/__tests__/general-ledger.test.ts +++ b/lib/reports/__tests__/general-ledger.test.ts @@ -1,26 +1,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // ============================================================ -// Mock — sequential result queue +// Mock — table-keyed result queues // ============================================================ -let resultIdx: number -let results: Array<{ data?: unknown; error?: unknown }> +type MockResult = { data?: unknown; error?: unknown } +let mockResults: Record -function makeBuilder() { +function makeBuilder(tableName: string) { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'lt', 'order', 'range']) { + for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(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 }) + const consume = (): MockResult => { + const queue = mockResults[tableName] + if (!queue || queue.length === 0) return { data: null, error: null } + return queue.shift()! + } + b.single = vi.fn().mockImplementation(async () => consume()) + b.then = (resolve: (v: unknown) => void) => resolve(consume()) return b } function makeClient() { return { - from: vi.fn().mockImplementation(() => makeBuilder()), - rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }), + from: vi.fn().mockImplementation((table: string) => makeBuilder(table)), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any } @@ -31,17 +35,15 @@ let supabase: ReturnType beforeEach(() => { vi.clearAllMocks() - resultIdx = 0 - results = [] + mockResults = {} supabase = makeClient() }) describe('generateGeneralLedger', () => { it('returns empty report when no fiscal period found', async () => { - results = [ - // 0: fiscal_periods.single() → null - { data: null, error: null }, - ] + mockResults = { + fiscal_periods: [{ data: null, error: null }], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-1') expect(report.accounts).toEqual([]) @@ -49,12 +51,17 @@ describe('generateGeneralLedger', () => { }) it('returns empty report when no entries in period', async () => { - results = [ - // 0: fiscal_periods.single() - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - // 1: journal_entries (empty) - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines (from getOpeningBalances fallback) — empty + { data: [], error: null }, + // period lines — empty + { data: [], error: null }, + ], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-1') expect(report.accounts).toEqual([]) @@ -62,41 +69,37 @@ describe('generateGeneralLedger', () => { }) it('groups lines by account with correct totals and running balance', async () => { - results = [ - // 0: fiscal_periods.single() - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - // 1: journal_entries for this period - { - data: [ - { 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, - }, - // 2: journal_entry_lines - { - 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, - }, - // 3: chart_of_accounts - { - data: [ - { account_number: '1510', account_name: 'Kundfordringar' }, - { account_number: '1930', account_name: 'Företagskonto' }, - { account_number: '2611', account_name: 'Utgående moms 25%' }, - { account_number: '3001', account_name: 'Försäljning 25%' }, - ], - error: null, - }, - // 4: prior entries (none) - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines — empty (first year) + { data: [], error: null }, + // period lines (joined with entry data) + { + 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1510', account_name: 'Kundfordringar' }, + { account_number: '1930', account_name: 'Företagskonto' }, + { account_number: '2611', account_name: 'Utgående moms 25%' }, + { account_number: '3001', account_name: 'Försäljning 25%' }, + ], + error: null, + }, + ], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-1') @@ -120,42 +123,37 @@ describe('generateGeneralLedger', () => { }) it('computes opening balance from prior period entries', async () => { - results = [ - // 0: fiscal_periods.single() - { data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }, - // 1: journal_entries for this period - { - data: [ - { id: 'e1', entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' }, - ], - error: null, - }, - // 2: journal_entry_lines - { - data: [ - { 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, - }, - // 3: chart_of_accounts - { - data: [ - { account_number: '1930', account_name: 'Företagskonto' }, - { account_number: '5410', account_name: 'Förbrukningsinventarier' }, - ], - error: null, - }, - // 4: prior entries - { data: [{ id: 'prior-1' }], error: null }, - // 5: prior lines - { - data: [ - { account_number: '1930', debit_amount: 10000, credit_amount: 0 }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines (from getOpeningBalances fallback) + { + data: [ + { account_number: '1930', debit_amount: 10000, credit_amount: 0 }, + ], + error: null, + }, + // 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto' }, + { account_number: '5410', account_name: 'Förbrukningsinventarier' }, + ], + error: null, + }, + ], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-2') @@ -166,30 +164,27 @@ describe('generateGeneralLedger', () => { }) it('filters accounts by account_from and account_to', async () => { - results = [ - // 0: fiscal_periods.single() - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - // 1: journal_entries - { - data: [ - { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' }, - ], - error: null, - }, - // 2: lines across multiple accounts - { - data: [ - { 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, - }, - // 3: chart_of_accounts - { data: [], error: null }, - // 4: prior entries (none) - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines — empty + { data: [], error: null }, + // 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [], error: null }, + ], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-1', '1500', '1999') @@ -198,32 +193,27 @@ describe('generateGeneralLedger', () => { }) it('sorts lines within account by date then voucher number', async () => { - results = [ - // 0: fiscal_periods.single() - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - // 1: entries out of order - { - data: [ - { id: 'e2', entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' }, - { id: 'e1', 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, - }, - // 2: lines all on same account - { - data: [ - { account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e2' }, - { account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entry_id: 'e1' }, - { account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entry_id: 'e3' }, - ], - error: null, - }, - // 3: chart_of_accounts - { data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null }, - // 4: prior entries - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines — empty + { data: [], error: null }, + // 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null }, + ], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-1') const acc = report.accounts[0] @@ -235,23 +225,23 @@ describe('generateGeneralLedger', () => { }) it('uses Math.round for monetary precision', async () => { - results = [ - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - { - data: [ - { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' }, - ], - error: null, - }, - { - data: [ - { account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entry_id: 'e1' }, - ], - error: null, - }, - { data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null }, - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { + 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null }, + ], + } const report = await generateGeneralLedger(supabase, 'user-1', 'period-1') const acc = report.accounts[0] diff --git a/lib/reports/__tests__/journal-register.test.ts b/lib/reports/__tests__/journal-register.test.ts index 5a2cd567..cee36086 100644 --- a/lib/reports/__tests__/journal-register.test.ts +++ b/lib/reports/__tests__/journal-register.test.ts @@ -1,25 +1,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // ============================================================ -// Mock — sequential result queue +// Mock — table-keyed result queues // ============================================================ -let resultIdx: number -let results: Array<{ data?: unknown; error?: unknown }> +type MockResult = { data?: unknown; error?: unknown } +let mockResults: Record -function makeBuilder() { +function makeBuilder(tableName: string) { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'order', 'range']) { + for (const m of ['select', 'eq', 'in', 'neq', 'order', 'range']) { b[m] = vi.fn().mockReturnValue(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 }) + const consume = (): MockResult => { + const queue = mockResults[tableName] + if (!queue || queue.length === 0) return { data: null, error: null } + return queue.shift()! + } + b.single = vi.fn().mockImplementation(async () => consume()) + b.then = (resolve: (v: unknown) => void) => resolve(consume()) return b } function makeClient() { return { - from: vi.fn().mockImplementation(() => makeBuilder()), + from: vi.fn().mockImplementation((table: string) => makeBuilder(table)), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any } @@ -30,16 +35,15 @@ let supabase: ReturnType beforeEach(() => { vi.clearAllMocks() - resultIdx = 0 - results = [] + mockResults = {} supabase = makeClient() }) describe('generateJournalRegister', () => { it('returns empty report when no fiscal period found', async () => { - results = [ - { data: null, error: null }, - ] + mockResults = { + fiscal_periods: [{ data: null, error: null }], + } const report = await generateJournalRegister(supabase, 'user-1', 'period-1') expect(report.entries).toEqual([]) @@ -48,10 +52,14 @@ describe('generateJournalRegister', () => { }) it('returns empty report when no entries in period', async () => { - results = [ - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + ], + } const report = await generateJournalRegister(supabase, 'user-1', 'period-1') expect(report.entries).toEqual([]) @@ -60,39 +68,34 @@ describe('generateJournalRegister', () => { }) it('produces entries in registration order with correct totals', async () => { - results = [ - // 0: fiscal_periods.single() - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - // 1: journal_entries (already ordered by series/number) - { - 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, - }, - // 2: journal_entry_lines - { - 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, - }, - // 3: chart_of_accounts - { - data: [ - { account_number: '1510', account_name: 'Kundfordringar' }, - { account_number: '1930', account_name: 'Företagskonto' }, - { account_number: '2611', account_name: 'Utgående moms 25%' }, - { account_number: '3001', account_name: 'Försäljning 25%' }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1510', account_name: 'Kundfordringar' }, + { account_number: '1930', account_name: 'Företagskonto' }, + { account_number: '2611', account_name: 'Utgående moms 25%' }, + { account_number: '3001', account_name: 'Försäljning 25%' }, + ], + error: null, + }, + ], + } const report = await generateJournalRegister(supabase, 'user-1', 'period-1') @@ -116,26 +119,25 @@ describe('generateJournalRegister', () => { }) it('includes reversed entries with correct status', async () => { - results = [ - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - { - 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, - }, - { - data: [ - { 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, - }, - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [], error: null }, + ], + } const report = await generateJournalRegister(supabase, 'user-1', 'period-1') @@ -145,28 +147,28 @@ describe('generateJournalRegister', () => { }) it('resolves account names from chart_of_accounts', async () => { - results = [ - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - { - data: [ - { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' }, - ], - error: null, - }, - { - data: [ - { 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, - }, - { - data: [ - { account_number: '1930', account_name: 'Företagskonto' }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto' }, + ], + error: null, + }, + ], + } const report = await generateJournalRegister(supabase, 'user-1', 'period-1') @@ -179,23 +181,23 @@ describe('generateJournalRegister', () => { }) it('defaults voucher_series to A when null', async () => { - results = [ - { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null }, - { - data: [ - { id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' }, - ], - error: null, - }, - { - data: [ - { 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, - }, - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', period_end: '2024-12-31' }, 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' } }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [], error: null }, + ], + } const report = await generateJournalRegister(supabase, 'user-1', 'period-1') expect(report.entries[0].voucher_series).toBe('A') diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index 4eef71c3..72cf86f8 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -1,25 +1,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // ============================================================ -// Mock — sequential result queue (no RPC — direct joined queries) +// Mock — table-keyed result queues +// Each table has its own FIFO queue. Calls to the same table +// consume results in order, regardless of global query ordering. // ============================================================ -let resultIdx: number -let results: Array<{ data?: unknown; error?: unknown }> +type MockResult = { data?: unknown; error?: unknown } +let mockResults: Record -function makeBuilder() { +function makeBuilder(tableName: string) { const b: Record = {} - for (const m of ['select', 'eq', 'in', 'range']) { + for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range']) { b[m] = vi.fn().mockReturnValue(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 }) + const consume = (): MockResult => { + const queue = mockResults[tableName] + if (!queue || queue.length === 0) return { data: null, error: null } + return queue.shift()! + } + b.single = vi.fn().mockImplementation(async () => consume()) + b.then = (resolve: (v: unknown) => void) => resolve(consume()) return b } function makeClient() { return { - from: vi.fn().mockImplementation(() => makeBuilder()), + from: vi.fn().mockImplementation((table: string) => makeBuilder(table)), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any } @@ -30,17 +37,22 @@ let supabase: ReturnType beforeEach(() => { vi.clearAllMocks() - resultIdx = 0 - results = [] + mockResults = {} supabase = makeClient() }) describe('generateTrialBalance', () => { it('returns empty report when no lines exist', async () => { - results = [ - // 0: journal_entry_lines (joined, page 1 — empty) - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: null, error: null }, + ], + // getOpeningBalances gets null period → returns empty + // period lines query → empty + journal_entry_lines: [ + { data: [], error: null }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') @@ -51,26 +63,34 @@ describe('generateTrialBalance', () => { }) it('aggregates lines by account and sorts by account_number', async () => { - results = [ - // 0: journal_entry_lines (joined, page 1) - { - data: [ - { account_number: '3001', debit_amount: 0, credit_amount: 500 }, - { account_number: '1930', debit_amount: 300, credit_amount: 0 }, - { account_number: '3001', debit_amount: 0, credit_amount: 200 }, - { account_number: '1930', debit_amount: 450, credit_amount: 0 }, - ], - error: null, - }, - // 1: chart_of_accounts (page 1) - { - data: [ - { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, - { account_number: '3001', account_name: 'Försäljning', account_class: 3 }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines (from getOpeningBalances fallback) — empty for first year + { data: [], error: null }, + // period lines + { + data: [ + { account_number: '3001', debit_amount: 0, credit_amount: 500 }, + { account_number: '1930', debit_amount: 300, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 200 }, + { account_number: '1930', debit_amount: 450, credit_amount: 0 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, + { account_number: '3001', account_name: 'Försäljning', account_class: 3 }, + ], + error: null, + }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') @@ -79,25 +99,151 @@ describe('generateTrialBalance', () => { expect(result.rows[0].account_number).toBe('1930') expect(result.rows[1].account_number).toBe('3001') - // Aggregated correctly + // Aggregated correctly — opening is 0 (first year) + expect(result.rows[0].opening_debit).toBe(0) + expect(result.rows[0].opening_credit).toBe(0) + expect(result.rows[0].period_debit).toBe(750) expect(result.rows[0].closing_debit).toBe(750) expect(result.rows[0].closing_credit).toBe(0) expect(result.rows[1].closing_debit).toBe(0) expect(result.rows[1].closing_credit).toBe(700) }) + it('computes opening balances from prior period entries', async () => { + mockResults = { + fiscal_periods: [ + { data: { period_start: '2025-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + // prior lines (from getOpeningBalances fallback) + { + data: [ + { account_number: '1930', debit_amount: 10000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 10000 }, + ], + error: null, + }, + // period lines + { + data: [ + { account_number: '1930', debit_amount: 0, credit_amount: 500 }, + { account_number: '5410', debit_amount: 500, credit_amount: 0 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, + { account_number: '2099', account_name: 'Årets resultat', account_class: 2 }, + { account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5 }, + ], + error: null, + }, + ], + } + + const result = await generateTrialBalance(supabase, 'user-1', 'period-2') + + // 1930: opening debit 10000, period credit 500 → closing debit 10000, credit 500 + const acc1930 = result.rows.find((r) => r.account_number === '1930')! + expect(acc1930.opening_debit).toBe(10000) + expect(acc1930.opening_credit).toBe(0) + expect(acc1930.period_debit).toBe(0) + expect(acc1930.period_credit).toBe(500) + expect(acc1930.closing_debit).toBe(10000) + expect(acc1930.closing_credit).toBe(500) + + // 2099: opening credit 10000, no period activity → closing credit 10000 + const acc2099 = result.rows.find((r) => r.account_number === '2099')! + expect(acc2099.opening_debit).toBe(0) + expect(acc2099.opening_credit).toBe(10000) + expect(acc2099.period_debit).toBe(0) + expect(acc2099.closing_debit).toBe(0) + expect(acc2099.closing_credit).toBe(10000) + + // 5410: no opening, period debit 500 + const acc5410 = result.rows.find((r) => r.account_number === '5410')! + expect(acc5410.opening_debit).toBe(0) + expect(acc5410.period_debit).toBe(500) + expect(acc5410.closing_debit).toBe(500) + + expect(result.isBalanced).toBe(true) + }) + + it('uses opening_balance_entry when available', async () => { + mockResults = { + fiscal_periods: [ + { data: { period_start: '2025-01-01', opening_balance_entry_id: 'ob-entry-1' }, error: null }, + ], + journal_entry_lines: [ + // OB entry lines (from getOpeningBalances) + { + data: [ + { account_number: '1930', debit_amount: 8000, credit_amount: 0 }, + { account_number: '2099', debit_amount: 0, credit_amount: 8000 }, + ], + error: null, + }, + // period lines (OB entry excluded via .neq) + { + data: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, + { account_number: '2099', account_name: 'Årets resultat', account_class: 2 }, + { account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 }, + ], + error: null, + }, + ], + } + + const result = await generateTrialBalance(supabase, 'user-1', 'period-2') + + // 1930: opening 8000 debit + period 1000 debit = closing 9000 debit + const acc1930 = result.rows.find((r) => r.account_number === '1930')! + expect(acc1930.opening_debit).toBe(8000) + expect(acc1930.closing_debit).toBe(9000) + expect(acc1930.closing_credit).toBe(0) + + // 2099: opening 8000 credit, no period activity + const acc2099 = result.rows.find((r) => r.account_number === '2099')! + expect(acc2099.opening_credit).toBe(8000) + expect(acc2099.closing_credit).toBe(8000) + + // 3001: no opening, period 1000 credit + const acc3001 = result.rows.find((r) => r.account_number === '3001')! + expect(acc3001.opening_debit).toBe(0) + expect(acc3001.closing_credit).toBe(1000) + }) + it('falls back to "Konto {number}" when account not in chart_of_accounts', async () => { - results = [ - // 0: journal_entry_lines - { - data: [ - { account_number: '9999', debit_amount: 100, credit_amount: 0 }, - ], - error: null, - }, - // 1: chart_of_accounts — empty - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { + data: [ + { account_number: '9999', debit_amount: 100, credit_amount: 0 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [], error: null }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') @@ -105,17 +251,23 @@ describe('generateTrialBalance', () => { }) it('derives account_class from first digit when account not in chart', async () => { - results = [ - // 0: journal_entry_lines - { - data: [ - { account_number: '5410', debit_amount: 200, credit_amount: 0 }, - ], - error: null, - }, - // 1: chart_of_accounts — empty - { data: [], error: null }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { + data: [ + { account_number: '5410', debit_amount: 200, credit_amount: 0 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { data: [], error: null }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') @@ -123,26 +275,32 @@ describe('generateTrialBalance', () => { }) it('uses Math.round for monetary precision', async () => { - results = [ - // 0: journal_entry_lines — values that cause floating point issues - { - data: [ - { account_number: '1930', debit_amount: 33.33, credit_amount: 0 }, - { account_number: '1930', debit_amount: 33.33, credit_amount: 0 }, - { account_number: '1930', debit_amount: 33.34, credit_amount: 0 }, - { account_number: '3001', debit_amount: 0, credit_amount: 100 }, - ], - error: null, - }, - // 1: chart_of_accounts - { - data: [ - { account_number: '1930', account_name: 'Bank', account_class: 1 }, - { account_number: '3001', account_name: 'Revenue', account_class: 3 }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { + data: [ + { account_number: '1930', debit_amount: 33.33, credit_amount: 0 }, + { account_number: '1930', debit_amount: 33.33, credit_amount: 0 }, + { account_number: '1930', debit_amount: 33.34, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 100 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Bank', account_class: 1 }, + { account_number: '3001', account_name: 'Revenue', account_class: 3 }, + ], + error: null, + }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') @@ -153,24 +311,30 @@ describe('generateTrialBalance', () => { }) it('detects unbalanced entries (isBalanced=false)', async () => { - results = [ - // 0: journal_entry_lines - { - data: [ - { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, - { account_number: '3001', debit_amount: 0, credit_amount: 999 }, - ], - error: null, - }, - // 1: chart_of_accounts - { - data: [ - { account_number: '1930', account_name: 'Bank', account_class: 1 }, - { account_number: '3001', account_name: 'Revenue', account_class: 3 }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { + data: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 999 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Bank', account_class: 1 }, + { account_number: '3001', account_name: 'Revenue', account_class: 3 }, + ], + error: null, + }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') @@ -180,33 +344,44 @@ describe('generateTrialBalance', () => { }) it('throws when lines query errors', async () => { - results = [ - // 0: journal_entry_lines query errors - { data: null, error: { message: 'DB error' } }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { data: null, error: { message: 'DB error' } }, + ], + } await expect(generateTrialBalance(supabase, 'user-1', 'period-1')).rejects.toThrow('DB error') }) it('handles balanced two-account entry', async () => { - results = [ - // 0: journal_entry_lines - { - data: [ - { account_number: '1930', debit_amount: 5000, credit_amount: 0 }, - { account_number: '3001', debit_amount: 0, credit_amount: 5000 }, - ], - error: null, - }, - // 1: chart_of_accounts - { - data: [ - { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, - { account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 }, - ], - error: null, - }, - ] + mockResults = { + fiscal_periods: [ + { data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null }, + ], + journal_entry_lines: [ + { data: [], error: null }, + { + data: [ + { account_number: '1930', debit_amount: 5000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 5000 }, + ], + error: null, + }, + ], + chart_of_accounts: [ + { + data: [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1 }, + { account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 }, + ], + error: null, + }, + ], + } const result = await generateTrialBalance(supabase, 'user-1', 'period-1') diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts index 8107d0aa..a9c94697 100644 --- a/lib/reports/general-ledger.ts +++ b/lib/reports/general-ledger.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { getOpeningBalances } from './opening-balances' export interface GeneralLedgerLine { date: string @@ -30,6 +31,17 @@ export interface GeneralLedgerReport { /** * Generate general ledger (huvudbok) for a fiscal period. * BFL 5 kap. 1 § — systematisk ordning: all transactions grouped by account. + * + * Uses joined queries with pagination to handle any number of entries. + * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. + * + * Opening balances use the opening_balance_entry set by year-end closing + * when available; falls back to summing prior-period entries. + * + * The account range filter (accountFrom/accountTo) is applied post-hoc + * during result building, not in the queries. Opening balances are computed + * for all accounts — the wasted Map entries for filtered-out accounts are + * trivially cheap compared to the cost of the queries themselves. */ export async function generateGeneralLedger( supabase: SupabaseClient, @@ -39,10 +51,10 @@ export async function generateGeneralLedger( accountTo?: string ): Promise { - // Get fiscal period dates + // Get fiscal period dates and opening_balance_entry_id const { data: period } = await supabase .from('fiscal_periods') - .select('period_start, period_end') + .select('period_start, period_end, opening_balance_entry_id') .eq('id', periodId) .eq('user_id', userId) .single() @@ -51,28 +63,52 @@ export async function generateGeneralLedger( return { accounts: [], period: { start: '', end: '' } } } - // Fetch posted and reversed entries for this period (reversed entries must appear alongside their storno) - const { data: entries } = await supabase - .from('journal_entries') - .select('id, entry_date, voucher_number, voucher_series, description, source_type') - .eq('user_id', userId) - .eq('fiscal_period_id', periodId) - .in('status', ['posted', 'reversed']) + // ── Opening balances (IB) ────────────────────────────────────── + const { balances: openingByAccount, obEntryId } = await getOpeningBalances( + supabase, userId, period + ) - if (!entries || entries.length === 0) { - return { accounts: [], period: { start: period.period_start, end: period.period_end } } + // Convert to net balance (debit - credit) for GL running balance + const openingBalances = new Map() + for (const [accNum, { debit, credit }] of openingByAccount) { + openingBalances.set(accNum, debit - credit) } - const entryIds = entries.map((e) => e.id) - const entryMap = new Map(entries.map((e) => [e.id, e])) + // ── Period lines via joined query (excluding OB entry) ───────── + // Race condition note: if year-end closing runs concurrently and creates + // the OB entry between the period query and this query, the entry could + // be missed. The window is sub-second and the consequence is a single + // stale report — acceptable. + // Supabase types !inner joins as arrays; for many-to-one (line → entry) + // it returns a single object at runtime. Cast via `as any` on the query. + const rawLines = await fetchAllRows<{ + account_number: string + debit_amount: number + credit_amount: number + journal_entries: { + entry_date: string + voucher_number: number + voucher_series: string + description: string + source_type: string + } + }>(({ from, to }) => { + let query = supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, user_id, fiscal_period_id, status)') + .eq('journal_entries.user_id', userId) + .eq('journal_entries.fiscal_period_id', periodId) + .in('journal_entries.status', ['posted', 'reversed']) - // Fetch lines for these entries - const { data: lines } = await supabase - .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entry_id') - .in('journal_entry_id', entryIds) + if (obEntryId) { + query = query.neq('journal_entry_id', obEntryId) + } - if (!lines) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return query.range(from, to) as any + }) + + if (rawLines.length === 0 && openingBalances.size === 0) { return { accounts: [], period: { start: period.period_start, end: period.period_end } } } @@ -90,39 +126,11 @@ export async function generateGeneralLedger( accountNameMap.set(acc.account_number, acc.account_name) } - // Compute opening balances: sum all posted/reversed lines from entries before this period - const { data: priorEntries } = await supabase - .from('journal_entries') - .select('id') - .eq('user_id', userId) - .in('status', ['posted', 'reversed']) - .lt('entry_date', period.period_start) - - const openingBalances = new Map() - - if (priorEntries && priorEntries.length > 0) { - const priorIds = priorEntries.map((e) => e.id) - const { data: priorLines } = await supabase - .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount') - .in('journal_entry_id', priorIds) - - for (const line of priorLines || []) { - const current = openingBalances.get(line.account_number) || 0 - openingBalances.set( - line.account_number, - current + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0) - ) - } - } - // Group lines by account const accountLines = new Map() - for (const line of lines) { - const entry = entryMap.get(line.journal_entry_id) - if (!entry) continue - + for (const line of rawLines) { + const entry = line.journal_entries const accNum = line.account_number if (!accountLines.has(accNum)) { accountLines.set(accNum, []) @@ -140,6 +148,13 @@ export async function generateGeneralLedger( }) } + // Include accounts that have opening balance but no period lines + for (const [accNum, balance] of openingBalances) { + if (!accountLines.has(accNum) && Math.abs(balance) > 0.005) { + accountLines.set(accNum, []) + } + } + // Build account summaries const result: GeneralLedgerAccount[] = [] diff --git a/lib/reports/journal-register.ts b/lib/reports/journal-register.ts index 12148669..91d1a16a 100644 --- a/lib/reports/journal-register.ts +++ b/lib/reports/journal-register.ts @@ -31,6 +31,13 @@ export interface JournalRegisterReport { /** * Generate journal register (grundbok) for a fiscal period. * BFL 5 kap. 1 § — registreringsordning: all vouchers in chronological registration order. + * + * Uses a joined query with pagination to handle any number of entries. + * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. + * + * Unlike the general ledger and trial balance, the grundbok includes ALL + * entries — the opening_balance_entry is NOT excluded, because it is a + * real voucher that should appear in registration order. */ export async function generateJournalRegister( supabase: SupabaseClient, @@ -50,28 +57,39 @@ export async function generateJournalRegister( return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: '', end: '' } } } - // Fetch posted/reversed entries ordered by voucher series then number (registration order) - const { data: entries } = await supabase - .from('journal_entries') - .select('id, entry_date, voucher_number, voucher_series, description, source_type, status') - .eq('user_id', userId) - .eq('fiscal_period_id', periodId) - .in('status', ['posted', 'reversed']) - .order('voucher_series', { ascending: true }) - .order('voucher_number', { ascending: true }) + // Fetch all lines with joined entry data — single paginated query, + // no entry ID array, no truncation at 1000 rows + // Supabase types !inner joins as arrays; for many-to-one (line → entry) + // it returns a single object at runtime. Cast via `as any` on the query. + const rawLines = await fetchAllRows<{ + account_number: string + debit_amount: number + credit_amount: number + journal_entry_id: string + journal_entries: { + id: string + entry_date: string + voucher_number: number + voucher_series: string + description: string + source_type: string + status: string + } + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(id, entry_date, voucher_number, voucher_series, description, source_type, status, user_id, fiscal_period_id)') + .eq('journal_entries.user_id', userId) + .eq('journal_entries.fiscal_period_id', periodId) + .in('journal_entries.status', ['posted', 'reversed']) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .range(from, to) as any + ) - if (!entries || entries.length === 0) { + if (rawLines.length === 0) { return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } } } - const entryIds = entries.map((e) => e.id) - - // Fetch lines for these entries - const { data: lines } = await supabase - .from('journal_entry_lines') - .select('account_number, debit_amount, credit_amount, journal_entry_id') - .in('journal_entry_id', entryIds) - // Fetch account names const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) => supabase @@ -86,13 +104,23 @@ export async function generateJournalRegister( accountNameMap.set(acc.account_number, acc.account_name) } - // Group lines by entry + // Extract unique entries and group lines by entry + const entryMap = new Map() const linesByEntry = new Map() - for (const line of lines || []) { - if (!linesByEntry.has(line.journal_entry_id)) { - linesByEntry.set(line.journal_entry_id, []) + + for (const line of rawLines) { + const entryId = line.journal_entry_id + const entry = line.journal_entries + + if (!entryMap.has(entryId)) { + entryMap.set(entryId, entry) } - linesByEntry.get(line.journal_entry_id)!.push({ + + if (!linesByEntry.has(entryId)) { + linesByEntry.set(entryId, []) + } + + linesByEntry.get(entryId)!.push({ account_number: line.account_number, account_name: accountNameMap.get(line.account_number) || `Konto ${line.account_number}`, debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100, @@ -100,9 +128,16 @@ export async function generateJournalRegister( }) } - // Build result - const result: JournalRegisterEntry[] = entries.map((entry) => { - const entryLines = linesByEntry.get(entry.id) || [] + // Build entries sorted by voucher_series, then voucher_number (registration order) + const sortedEntries = Array.from(entryMap.entries()) + .sort(([, a], [, b]) => { + const seriesCompare = (a.voucher_series || 'A').localeCompare(b.voucher_series || 'A') + if (seriesCompare !== 0) return seriesCompare + return a.voucher_number - b.voucher_number + }) + + const result: JournalRegisterEntry[] = sortedEntries.map(([entryId, entry]) => { + const entryLines = linesByEntry.get(entryId) || [] // Sort lines by account number within each entry entryLines.sort((a, b) => a.account_number.localeCompare(b.account_number)) diff --git a/lib/reports/opening-balances.ts b/lib/reports/opening-balances.ts new file mode 100644 index 00000000..133da072 --- /dev/null +++ b/lib/reports/opening-balances.ts @@ -0,0 +1,88 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchAllRows } from '@/lib/supabase/fetch-all' + +/** + * Get opening balances (ingående balans) for a fiscal period. + * + * Uses the opening_balance_entry set by year-end closing when available + * (O(accounts) — typically ~50 rows). Falls back to summing all entries + * prior to the period start date via a joined query (O(all_prior_lines) — + * expensive for companies that haven't run year-end closing). + * + * Returns per-account debit/credit opening balances and the OB entry ID + * (if any) so the caller can exclude it from period queries to prevent + * double-counting. + * + * NOTE: The account range filter (accountFrom/accountTo in the GL) is + * applied post-hoc by the caller, not here. This is consistent with the + * existing behavior and avoids complicating the queries for the common + * unfiltered case. + */ +export async function getOpeningBalances( + supabase: SupabaseClient, + userId: string, + period: { period_start: string; opening_balance_entry_id: string | null } | null +): Promise<{ + balances: Map + obEntryId: string | null +}> { + const balances = new Map() + + if (!period) { + return { balances, obEntryId: null } + } + + const obEntryId = period.opening_balance_entry_id + + if (obEntryId) { + // Use the explicit opening balance entry (set by year-end closing). + // Typically ~50 rows — one per balance sheet account. Uses fetchAllRows + // for consistency (avoids silent truncation) and joins journal_entries + // to enforce user_id ownership (defense in depth alongside RLS). + const obLines = await fetchAllRows<{ + account_number: string + debit_amount: number + credit_amount: number + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id)') + .eq('journal_entry_id', obEntryId) + .eq('journal_entries.user_id', userId) + .range(from, to) + ) + + for (const line of obLines) { + const existing = balances.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + balances.set(line.account_number, existing) + } + } else { + // Fallback: compute from all entries dated before this period's start. + // This is expensive for multi-year companies that haven't run year-end + // closing — consider prompting the user to close prior periods. + const priorLines = await fetchAllRows<{ + account_number: string + debit_amount: number + credit_amount: number + }>(({ from, to }) => + supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id, status, entry_date)') + .eq('journal_entries.user_id', userId) + .in('journal_entries.status', ['posted', 'reversed']) + .lt('journal_entries.entry_date', period.period_start) + .range(from, to) + ) + + for (const line of priorLines) { + const existing = balances.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + balances.set(line.account_number, existing) + } + } + + return { balances, obEntryId } +} diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index 38fdd9ae..1a057a40 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -1,13 +1,17 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { getOpeningBalances } from './opening-balances' import type { TrialBalanceRow } from '@/types' /** * Generate trial balance (Saldobalans) for a fiscal period. * - * Uses a single joined query (journal_entry_lines → journal_entries) - * with pagination to handle any number of entries. Avoids the broken - * .in(entryIds) pattern that silently truncated at 1000 rows. + * Computes IB (ingående balans), period movements, and UB (utgående balans) + * per BFNAR 2013:2 requirements. Uses the opening_balance_entry set by + * year-end closing when available; falls back to summing prior-period entries. + * + * Uses joined queries with pagination to handle any number of entries. + * Avoids the broken .in(entryIds) pattern that silently truncated at 1000 rows. */ export async function generateTrialBalance( supabase: SupabaseClient, @@ -20,22 +24,46 @@ export async function generateTrialBalance( isBalanced: boolean }> { - // Single joined query — no entry ID array, no URL length limit + // Fetch period for opening balance computation + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, opening_balance_entry_id') + .eq('id', fiscalPeriodId) + .eq('user_id', userId) + .single() + + // ── Opening balances (IB) ────────────────────────────────────── + const { balances: openingBalances, obEntryId } = await getOpeningBalances( + supabase, userId, period + ) + + // ── Period lines (excluding opening balance entry) ───────────── + // If year-end closing set an OB entry, exclude it from period lines so + // its values aren't double-counted (they're already captured as IB). + // Race condition note: if year-end closing runs concurrently and sets + // obEntryId between the period query and this query, the OB entry could + // be missed from both IB and period. The window is sub-second and the + // consequence is a single stale report — acceptable. const lines = await fetchAllRows<{ account_number: string debit_amount: number credit_amount: number - }>(({ from, to }) => - supabase + }>(({ from, to }) => { + let query = supabase .from('journal_entry_lines') .select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id, fiscal_period_id, status)') .eq('journal_entries.user_id', userId) .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .in('journal_entries.status', ['posted', 'reversed']) - .range(from, to) - ) - if (lines.length === 0) { + if (obEntryId) { + query = query.neq('journal_entry_id', obEntryId) + } + + return query.range(from, to) + }) + + if (lines.length === 0 && openingBalances.size === 0) { return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true } } @@ -60,19 +88,24 @@ export async function generateTrialBalance( }) } - // Aggregate by account - const balances = new Map() + // Aggregate period activity by account + const periodBalances = new Map() for (const line of lines) { - const existing = balances.get(line.account_number) || { debit: 0, credit: 0 } + const existing = periodBalances.get(line.account_number) || { debit: 0, credit: 0 } existing.debit += Number(line.debit_amount) || 0 existing.credit += Number(line.credit_amount) || 0 - balances.set(line.account_number, existing) + periodBalances.set(line.account_number, existing) } - // Build rows + // Merge account numbers from both opening and period + const allAccountNumbers = new Set([...openingBalances.keys(), ...periodBalances.keys()]) + + // Build rows: IB + period = UB const rows: TrialBalanceRow[] = [] - for (const [accountNumber, balance] of balances) { + for (const accountNumber of allAccountNumbers) { + const opening = openingBalances.get(accountNumber) || { debit: 0, credit: 0 } + const periodActivity = periodBalances.get(accountNumber) || { debit: 0, credit: 0 } const accountInfo = accountMap.get(accountNumber) || { name: `Konto ${accountNumber}`, class: parseInt(accountNumber[0]) || 0, @@ -82,12 +115,12 @@ export async function generateTrialBalance( account_number: accountNumber, account_name: accountInfo.name, account_class: accountInfo.class, - opening_debit: 0, - opening_credit: 0, - period_debit: Math.round(balance.debit * 100) / 100, - period_credit: Math.round(balance.credit * 100) / 100, - closing_debit: Math.round(balance.debit * 100) / 100, - closing_credit: Math.round(balance.credit * 100) / 100, + opening_debit: Math.round(opening.debit * 100) / 100, + opening_credit: Math.round(opening.credit * 100) / 100, + period_debit: Math.round(periodActivity.debit * 100) / 100, + period_credit: Math.round(periodActivity.credit * 100) / 100, + closing_debit: Math.round((opening.debit + periodActivity.debit) * 100) / 100, + closing_credit: Math.round((opening.credit + periodActivity.credit) * 100) / 100, }) }