* 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>
252 lines
10 KiB
TypeScript
252 lines
10 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', 'lt', '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 { generateGeneralLedger } from '../general-ledger'
|
||
|
||
let supabase: ReturnType<typeof makeClient>
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
mockResults = {}
|
||
supabase = makeClient()
|
||
})
|
||
|
||
describe('generateGeneralLedger', () => {
|
||
it('returns empty report when no fiscal period found', async () => {
|
||
mockResults = {
|
||
fiscal_periods: [{ data: null, error: null }],
|
||
}
|
||
|
||
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
|
||
expect(report.accounts).toEqual([])
|
||
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', 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([])
|
||
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
|
||
})
|
||
|
||
it('groups lines by account with correct totals and running balance', async () => {
|
||
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')
|
||
|
||
expect(report.accounts).toHaveLength(4)
|
||
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930', '2611', '3001'])
|
||
|
||
// Account 1510: debit 1250, credit 1250 → closing 0
|
||
const acc1510 = report.accounts.find((a) => a.account_number === '1510')!
|
||
expect(acc1510.total_debit).toBe(1250)
|
||
expect(acc1510.total_credit).toBe(1250)
|
||
expect(acc1510.closing_balance).toBe(0)
|
||
expect(acc1510.lines).toHaveLength(2)
|
||
expect(acc1510.lines[0].balance).toBe(1250)
|
||
expect(acc1510.lines[1].balance).toBe(0)
|
||
|
||
// Account 1930: debit 1250, credit 0 → closing 1250
|
||
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
|
||
expect(acc1930.total_debit).toBe(1250)
|
||
expect(acc1930.total_credit).toBe(0)
|
||
expect(acc1930.closing_balance).toBe(1250)
|
||
})
|
||
|
||
it('computes opening balance from prior period entries', async () => {
|
||
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')
|
||
|
||
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
|
||
expect(acc1930.opening_balance).toBe(10000)
|
||
expect(acc1930.closing_balance).toBe(9500) // 10000 - 500
|
||
expect(acc1930.lines[0].balance).toBe(9500)
|
||
})
|
||
|
||
it('filters accounts by account_from and account_to', async () => {
|
||
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')
|
||
|
||
// Only accounts in 1500–1999 range
|
||
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
|
||
})
|
||
|
||
it('sorts lines within account by date then voucher number', async () => {
|
||
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]
|
||
|
||
// e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2)
|
||
expect(acc.lines[0].description).toBe('Earlier date')
|
||
expect(acc.lines[1].description).toBe('First')
|
||
expect(acc.lines[2].description).toBe('Second')
|
||
})
|
||
|
||
it('uses Math.round for monetary precision', async () => {
|
||
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]
|
||
expect(acc.total_debit).toBe(33.33)
|
||
expect(acc.closing_balance).toBe(33.33)
|
||
})
|
||
})
|