fix: resolve BFL compliance violations in general ledger and trial balance (#106)
* 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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e5f1350d5c
commit
0742dc7e8d
@@ -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<string, MockResult[]>
|
||||
|
||||
function makeBuilder() {
|
||||
function makeBuilder(tableName: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
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<typeof makeClient>
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<string, MockResult[]>
|
||||
|
||||
function makeBuilder() {
|
||||
function makeBuilder(tableName: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
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<typeof makeClient>
|
||||
|
||||
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')
|
||||
|
||||
@@ -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<string, MockResult[]>
|
||||
|
||||
function makeBuilder() {
|
||||
function makeBuilder(tableName: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
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<typeof makeClient>
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@@ -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<GeneralLedgerReport> {
|
||||
|
||||
// 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<string, number>()
|
||||
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<string, number>()
|
||||
|
||||
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<string, GeneralLedgerLine[]>()
|
||||
|
||||
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[] = []
|
||||
|
||||
|
||||
@@ -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<string, typeof rawLines[0]['journal_entries']>()
|
||||
const linesByEntry = new Map<string, JournalRegisterLine[]>()
|
||||
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))
|
||||
|
||||
|
||||
@@ -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<string, { debit: number; credit: number }>
|
||||
obEntryId: string | null
|
||||
}> {
|
||||
const balances = new Map<string, { debit: number; credit: number }>()
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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<string, { debit: number; credit: number }>()
|
||||
// Aggregate period activity by account
|
||||
const periodBalances = new Map<string, { debit: number; credit: number }>()
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user