Files
accounted/lib/reports/general-ledger.ts
T
MattssonandClaude Opus 4.6 5113f6eca3 feat: add drill-down navigation between reports and general ledger (#112)
Enable clicking account rows in Trial Balance, Income Statement, and
Balance Sheet to navigate to the Huvudbok filtered to that account.
Voucher numbers in the Huvudbok now link to the journal entry detail
page. Breadcrumb trail supports tracing back through the navigation
chain.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:57:43 +01:00

206 lines
6.9 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
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,
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_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('account_number, debit_amount, credit_amount, journal_entry_id, 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,
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 },
}
}