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 journal_entry_id: string 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, companyId: string, periodId: string, accountFrom?: string, accountTo?: string ): Promise { // 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('company_id', companyId) .single() if (!period) { return { accounts: [], period: { start: '', end: '' } } } // ── Opening balances (IB) ────────────────────────────────────── const { balances: openingByAccount, obEntryId } = await getOpeningBalances( supabase, companyId, period ) // Convert to net balance (debit - credit) for GL running balance const openingBalances = new Map() 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<{ id: string account_number: string debit_amount: number credit_amount: number journal_entry_id: string 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('id, account_number, debit_amount, credit_amount, journal_entry_id, journal_entries!inner(entry_date, voucher_number, voucher_series, description, source_type, company_id, fiscal_period_id, status)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', periodId) .in('journal_entries.status', ['posted', 'reversed']) if (obEntryId) { query = query.neq('journal_entry_id', obEntryId) } // Stable total order on the line PK — paging is only correct with a // deterministic order, else rows duplicate/skip across pages and balances // double or accounts vanish (see fetch-all.ts ordering invariant). The // report re-sorts lines per account below, so this order is invisible. // eslint-disable-next-line @typescript-eslint/no-explicit-any return query.order('id', { ascending: true }).range(from, to) as any }, { dedupeBy: (r) => r.id }) 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('company_id', companyId) .order('account_number', { ascending: true }) .range(from, to) ) const accountNameMap = new Map() for (const acc of accounts) { accountNameMap.set(acc.account_number, acc.account_name) } // Group lines by account const accountLines = new Map() 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, journal_entry_id: line.journal_entry_id, 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 }, } }