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>
206 lines
6.9 KiB
TypeScript
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 },
|
|
}
|
|
}
|