* fix: resolve BFL compliance violations in general ledger and trial balance Fix two compliance violations and a pre-existing double-counting bug: 1. .in(entryIds) truncation (BFL 5:2 completeness) — general-ledger.ts and journal-register.ts used .in() with dynamic ID arrays that silently truncate at ~1000 rows. Migrated to joined queries with fetchAllRows pagination, matching the pattern already used by trial-balance.ts. 2. Trial balance missing IB columns (BFNAR 2013:2) — opening_debit and opening_credit were hardcoded to 0. Now computed from the opening_balance_entry (set by year-end closing) or by summing prior- period entries as a fallback. 3. Double-counting after year-end closing — the opening_balance_entry's lines were counted as both IB and period activity. Now excluded from period queries via .neq() when the OB entry exists. Extracted shared getOpeningBalances() helper used by both trial balance and general ledger. Refactored test mocks from positional arrays to table-keyed queues for readability. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add pagination and user_id filter to OB entry query Address review feedback: the obEntryId fast path in getOpeningBalances used a bare single-shot query without fetchAllRows (inconsistent with the PR's truncation fix) and lacked the user_id defense-in-depth filter required by CLAUDE.md guidelines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
9.1 KiB
TypeScript
206 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// ============================================================
|
|
// Mock — table-keyed result queues
|
|
// ============================================================
|
|
|
|
type MockResult = { data?: unknown; error?: unknown }
|
|
let mockResults: Record<string, MockResult[]>
|
|
|
|
function makeBuilder(tableName: string) {
|
|
const b: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'in', 'neq', 'order', 'range']) {
|
|
b[m] = vi.fn().mockReturnValue(b)
|
|
}
|
|
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((table: string) => makeBuilder(table)),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any
|
|
}
|
|
|
|
import { generateJournalRegister } from '../journal-register'
|
|
|
|
let supabase: ReturnType<typeof makeClient>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockResults = {}
|
|
supabase = makeClient()
|
|
})
|
|
|
|
describe('generateJournalRegister', () => {
|
|
it('returns empty report when no fiscal period found', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [{ data: null, error: null }],
|
|
}
|
|
|
|
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
|
|
expect(report.entries).toEqual([])
|
|
expect(report.total_entries).toBe(0)
|
|
expect(report.period).toEqual({ start: '', end: '' })
|
|
})
|
|
|
|
it('returns empty report when no entries in period', async () => {
|
|
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([])
|
|
expect(report.total_entries).toBe(0)
|
|
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
|
|
})
|
|
|
|
it('produces entries in registration order with correct totals', async () => {
|
|
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')
|
|
|
|
expect(report.total_entries).toBe(2)
|
|
expect(report.entries[0].voucher_number).toBe(1)
|
|
expect(report.entries[1].voucher_number).toBe(2)
|
|
|
|
// Entry 1: sale invoice
|
|
expect(report.entries[0].total_debit).toBe(1250)
|
|
expect(report.entries[0].total_credit).toBe(1250)
|
|
expect(report.entries[0].lines).toHaveLength(3)
|
|
|
|
// Lines sorted by account number
|
|
expect(report.entries[0].lines[0].account_number).toBe('1510')
|
|
expect(report.entries[0].lines[1].account_number).toBe('2611')
|
|
expect(report.entries[0].lines[2].account_number).toBe('3001')
|
|
|
|
// Grand totals
|
|
expect(report.total_debit).toBe(2500)
|
|
expect(report.total_credit).toBe(2500)
|
|
})
|
|
|
|
it('includes reversed entries with correct status', async () => {
|
|
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')
|
|
|
|
expect(report.entries[0].status).toBe('reversed')
|
|
expect(report.entries[1].status).toBe('posted')
|
|
expect(report.total_entries).toBe(2)
|
|
})
|
|
|
|
it('resolves account names from chart_of_accounts', async () => {
|
|
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')
|
|
|
|
const line1930 = report.entries[0].lines.find((l) => l.account_number === '1930')!
|
|
expect(line1930.account_name).toBe('Företagskonto')
|
|
|
|
// Unknown account gets fallback name
|
|
const line9999 = report.entries[0].lines.find((l) => l.account_number === '9999')!
|
|
expect(line9999.account_name).toBe('Konto 9999')
|
|
})
|
|
|
|
it('defaults voucher_series to A when null', async () => {
|
|
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')
|
|
})
|
|
})
|