Files
accounted/lib/reports/general-ledger.ts
T
Jakob WennbergandClaude Opus 4.6 0742dc7e8d 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>
2026-03-23 19:10:00 +01:00

203 lines
6.8 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getOpeningBalances } from './opening-balances'
export interface GeneralLedgerLine {
date: string
voucher_series: string
voucher_number: number
description: string
source_type: string
debit: number
credit: number
balance: number
}
export interface GeneralLedgerAccount {
account_number: string
account_name: string
opening_balance: number
lines: GeneralLedgerLine[]
closing_balance: number
total_debit: number
total_credit: number
}
export interface GeneralLedgerReport {
accounts: GeneralLedgerAccount[]
period: { start: string; end: string }
}
/**
* 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,
userId: string,
periodId: string,
accountFrom?: string,
accountTo?: string
): Promise<GeneralLedgerReport> {
// Get fiscal period dates and opening_balance_entry_id
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) {
return { accounts: [], period: { start: '', end: '' } }
}
// ── Opening balances (IB) ──────────────────────────────────────
const { balances: openingByAccount, obEntryId } = await getOpeningBalances(
supabase, userId, period
)
// 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)
}
// ── 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'])
if (obEntryId) {
query = query.neq('journal_entry_id', obEntryId)
}
// 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 } }
}
// Fetch account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Group lines by account
const accountLines = new Map<string, GeneralLedgerLine[]>()
for (const line of rawLines) {
const entry = line.journal_entries
const accNum = line.account_number
if (!accountLines.has(accNum)) {
accountLines.set(accNum, [])
}
accountLines.get(accNum)!.push({
date: entry.entry_date,
voucher_series: entry.voucher_series || 'A',
voucher_number: entry.voucher_number,
description: entry.description || '',
source_type: entry.source_type || '',
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
balance: 0, // computed below
})
}
// 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[] = []
for (const [accNum, accLines] of accountLines) {
// Apply optional account range filter
if (accountFrom && accNum < accountFrom) continue
if (accountTo && accNum > accountTo) continue
// Sort by date, then voucher number
accLines.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.voucher_number - b.voucher_number
})
const opening = Math.round((openingBalances.get(accNum) || 0) * 100) / 100
let runningBalance = opening
for (const line of accLines) {
runningBalance += line.debit - line.credit
line.balance = Math.round(runningBalance * 100) / 100
}
const totalDebit = accLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = accLines.reduce((sum, l) => sum + l.credit, 0)
result.push({
account_number: accNum,
account_name: accountNameMap.get(accNum) || `Konto ${accNum}`,
opening_balance: opening,
lines: accLines,
closing_balance: Math.round((opening + totalDebit - totalCredit) * 100) / 100,
total_debit: Math.round(totalDebit * 100) / 100,
total_credit: Math.round(totalCredit * 100) / 100,
})
}
// Sort by account number
result.sort((a, b) => a.account_number.localeCompare(b.account_number))
return {
accounts: result,
period: { start: period.period_start, end: period.period_end },
}
}