* 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>
203 lines
6.8 KiB
TypeScript
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 },
|
|
}
|
|
}
|