/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * Derives all seven General Ledger reports from the posting ledger. * * Each report is a pure function of (postings, date range, branch), so changing * the day / month / branch filter genuinely recomputes the figures. Results are * memoised per filter key because the ledger runs to roughly twenty thousand * postings and Angular re-evaluates template getters on every change detection. */ import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data'; export interface GlDateRange { from: string; to: string; } export interface TrialBalanceRow { code: string; name: string; group: string; openingDebit: number; openingCredit: number; periodDebit: number; periodCredit: number; closingDebit: number; closingCredit: number; } export interface PnlRow { code: string; name: string; group: string; amount: number; } export interface BalanceSheetRow { code: string; name: string; group: string; amount: number; } export interface ComparativeRow { code: string; name: string; group: string; side: 'ASSET' | 'LIABILITY'; current: number; previous: number; variance: number; variancePct: number | null; } export interface GlWiseRow { code: string; name: string; group: string; type: string; debit: number; credit: number; net: number; vouchers: number; } export interface MiscExpenseRow { date: string; voucherNo: string; branchCode: string; code: string; name: string; narration: string; amount: number; } /** Natural side of an account — assets and expenses sit on the debit side. */ function isDebitSide(account: GlAccount): boolean { return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT'; } function matchesBranch(p: GlPosting, branchCode: string): boolean { return !branchCode || p.branchCode === branchCode; } /** * Both scans below walk the whole ~21k-posting ledger, so their results are cached * per (date, branch). Angular re-evaluates template getters on every change-detection * pass; without this a screen with a few hundred mapped lines would spend seconds per * click rescanning the ledger and appear frozen. */ const balanceCache = new Map>(); const turnoverCache = new Map }>>(); /** Signed balance on the account's natural side, for postings up to and including `to`. */ function cumulativeBalances(to: string, branchCode: string): Map { const key = `${to}|${branchCode}`; const hit = balanceCache.get(key); if (hit) { return hit; } const out = new Map(); for (const p of glPostings()) { if (p.date > to || !matchesBranch(p, branchCode)) { continue; } const account = glAccount(p.accountCode); if (!account) { continue; } const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit; out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta); } balanceCache.set(key, out); return out; } /** Debit and credit turnover strictly inside the range. */ function periodTurnover( range: GlDateRange, branchCode: string ): Map }> { const key = `${range.from}|${range.to}|${branchCode}`; const hit = turnoverCache.get(key); if (hit) { return hit; } const out = new Map }>(); for (const p of glPostings()) { if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) { continue; } let entry = out.get(p.accountCode); if (!entry) { entry = { debit: 0, credit: 0, vouchers: new Set() }; out.set(p.accountCode, entry); } entry.debit += p.debit; entry.credit += p.credit; entry.vouchers.add(p.voucherNo); } turnoverCache.set(key, out); return out; } /** The day before `date`, ISO formatted — used for opening balances. */ function previousDay(date: string): string { const d = new Date(`${date}T00:00:00Z`); d.setUTCDate(d.getUTCDate() - 1); return d.toISOString().slice(0, 10); } // ── Reports ────────────────────────────────────────────────────────────────── export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] { const opening = cumulativeBalances(previousDay(range.from), branchCode); const turnover = periodTurnover(range, branchCode); const rows: TrialBalanceRow[] = []; for (const account of GL_ACCOUNTS) { // Contingent accounts are memoranda and never appear in the trial balance. if (account.type === 'CONTINGENT') { continue; } const open = opening.get(account.code) ?? 0; const move = turnover.get(account.code); const periodDebit = move?.debit ?? 0; const periodCredit = move?.credit ?? 0; if (!open && !periodDebit && !periodCredit) { continue; } const debitSide = isDebitSide(account); const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit); const closingSigned = debitSide ? closingNatural : -closingNatural; rows.push({ code: account.code, name: account.name, group: account.group, openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0, openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0, periodDebit, periodCredit, closingDebit: closingSigned > 0 ? closingSigned : 0, closingCredit: closingSigned < 0 ? -closingSigned : 0 }); } return rows; } export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } { const turnover = periodTurnover(range, branchCode); const income: PnlRow[] = []; const expense: PnlRow[] = []; for (const account of GL_ACCOUNTS) { if (account.type !== 'INCOME' && account.type !== 'EXPENSE') { continue; } const move = turnover.get(account.code); if (!move) { continue; } const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit; if (!amount) { continue; } const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount }; (account.type === 'INCOME' ? income : expense).push(row); } return { income, expense }; } /** * Balance sheet as at the period end. The current-period surplus is folded in as * a liability line so the two sides tally the way the printed statement does. */ export function balanceSheet( range: GlDateRange, branchCode: string ): { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number; } { const closing = cumulativeBalances(range.to, branchCode); const assets: BalanceSheetRow[] = []; const liabilities: BalanceSheetRow[] = []; let income = 0; let expense = 0; for (const account of GL_ACCOUNTS) { const bal = closing.get(account.code) ?? 0; if (account.type === 'INCOME') { income += bal; continue; } if (account.type === 'EXPENSE') { expense += bal; continue; } if (account.type === 'CONTINGENT' || !bal) { continue; } const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal }; (account.type === 'ASSET' ? assets : liabilities).push(row); } return { assets, liabilities, surplus: income - expense }; } /** Current period against the equivalent span immediately before it. */ export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] { const priorTo = previousDay(range.from); const current = cumulativeBalances(range.to, branchCode); const previous = cumulativeBalances(priorTo, branchCode); const rows: ComparativeRow[] = []; for (const account of GL_ACCOUNTS) { if (account.type !== 'ASSET' && account.type !== 'LIABILITY') { continue; } const cur = current.get(account.code) ?? 0; const prev = previous.get(account.code) ?? 0; if (!cur && !prev) { continue; } const variance = cur - prev; rows.push({ code: account.code, name: account.name, group: account.group, side: account.type, current: cur, previous: prev, variance, variancePct: prev ? (variance / Math.abs(prev)) * 100 : null }); } return rows; } export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] { const closing = cumulativeBalances(range.to, branchCode); return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT') .map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 })) .filter((r) => r.amount !== 0); } export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] { const turnover = periodTurnover(range, branchCode); const rows: GlWiseRow[] = []; for (const account of GL_ACCOUNTS) { const move = turnover.get(account.code); if (!move || (!move.debit && !move.credit)) { continue; } rows.push({ code: account.code, name: account.name, group: account.group, type: account.type, debit: move.debit, credit: move.credit, net: move.debit - move.credit, vouchers: move.vouchers.size }); } return rows; } export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] { const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code)); const rows: MiscExpenseRow[] = []; for (const p of glPostings()) { if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) { continue; } if (!miscCodes.has(p.accountCode) || !p.debit) { continue; } rows.push({ date: p.date, voucherNo: p.voucherNo, branchCode: p.branchCode, code: p.accountCode, name: glAccount(p.accountCode)?.name ?? p.accountCode, narration: p.narration, amount: p.debit }); } return rows.sort((a, b) => a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date) ); } export interface AverageBalanceRow { code: string; name: string; group: string; type: string; opening: number; closing: number; average: number; peak: number; lowest: number; /** Days on which the account moved. */ activeDays: number; } export interface AccountBranchRow { code: string; name: string; group: string; /** Closing balance per branch code, plus a `total`. */ byBranch: Record; total: number; } export interface DistributionRow { group: string; amount: number; share: number; accounts: number; } /** Calendar days in the range, inclusive. */ function daysInRange(range: GlDateRange): string[] { const out: string[] = []; const cur = new Date(`${range.from}T00:00:00Z`); const end = new Date(`${range.to}T00:00:00Z`); while (cur <= end) { out.push(cur.toISOString().slice(0, 10)); cur.setUTCDate(cur.getUTCDate() + 1); } return out; } /** * Average daily balance per account across the period, plus the peak and lowest * balance touched. Banks use average balances rather than closing snapshots for * interest, CRR/SLR and profitability work, because a closing figure can be * window-dressed on the last day. * * Computed by carrying a running balance forward day by day rather than * recomputing a cumulative balance per day, which would be quadratic. */ export function averageBalances(range: GlDateRange, branchCode: string): AverageBalanceRow[] { const opening = cumulativeBalances(previousDay(range.from), branchCode); const days = daysInRange(range); // Net movement per account per day, on the account's natural side. const dailyDelta = new Map>(); const movedOn = new Map>(); for (const p of glPostings()) { if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) { continue; } const account = glAccount(p.accountCode); if (!account) { continue; } const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit; let perDay = dailyDelta.get(p.accountCode); if (!perDay) { perDay = new Map(); dailyDelta.set(p.accountCode, perDay); } perDay.set(p.date, (perDay.get(p.date) ?? 0) + delta); let seen = movedOn.get(p.accountCode); if (!seen) { seen = new Set(); movedOn.set(p.accountCode, seen); } seen.add(p.date); } const rows: AverageBalanceRow[] = []; for (const account of GL_ACCOUNTS) { const open = opening.get(account.code) ?? 0; const perDay = dailyDelta.get(account.code); if (!open && !perDay) { continue; } let running = open; let total = 0; let peak = open; let lowest = open; for (const d of days) { running += perDay?.get(d) ?? 0; total += running; peak = Math.max(peak, running); lowest = Math.min(lowest, running); } rows.push({ code: account.code, name: account.name, group: account.group, type: account.type, opening: open, closing: running, average: days.length ? total / days.length : 0, peak, lowest, activeDays: movedOn.get(account.code)?.size ?? 0 }); } return rows; } /** * Closing balance of every account broken out by branch. Answers "which branch is * carrying this balance", which a single consolidated column cannot. */ export function accountWiseByBranch(range: GlDateRange, branchCodes: string[]): AccountBranchRow[] { const perBranch = new Map>(); for (const b of branchCodes) { perBranch.set(b, cumulativeBalances(range.to, b)); } const rows: AccountBranchRow[] = []; for (const account of GL_ACCOUNTS) { const byBranch: Record = {}; let total = 0; let any = false; for (const b of branchCodes) { const v = perBranch.get(b)?.get(account.code) ?? 0; byBranch[b] = v; total += v; if (v) { any = true; } } if (!any) { continue; } rows.push({ code: account.code, name: account.name, group: account.group, byBranch, total }); } return rows; } /** * Share of the closing book held by each account group, for one side of the * balance sheet. Used to see concentration at a glance. */ export function balanceDistribution( range: GlDateRange, branchCode: string, side: 'ASSET' | 'LIABILITY' ): DistributionRow[] { const closing = cumulativeBalances(range.to, branchCode); const byGroup = new Map(); let grand = 0; for (const account of GL_ACCOUNTS) { if (account.type !== side) { continue; } const v = closing.get(account.code) ?? 0; if (!v) { continue; } const entry = byGroup.get(account.group) ?? { amount: 0, accounts: 0 }; entry.amount += v; entry.accounts += 1; byGroup.set(account.group, entry); grand += v; } return [...byGroup.entries()] .map( ([ group, e ]) => ({ group, amount: e.amount, accounts: e.accounts, share: grand ? (e.amount / grand) * 100 : 0 }) ) .sort((a, b) => b.amount - a.amount); } export interface ClientBalanceRow { cif: string; name: string; branchCode: string; accountNo: string; accountCode: string; product: string; /** DEPOSIT or ADVANCE — which side of the relationship this is. */ side: 'DEPOSIT' | 'ADVANCE'; balance: number; } export interface BankerHeadRow { head: string; opening: number; debit: number; credit: number; closing: number; accounts: number; } export interface SundryItemRow { date: string; voucherNo: string; branchCode: string; code: string; name: string; narration: string; amount: number; /** Days outstanding as at the period end. */ ageDays: number; bucket: string; } export interface VoucherRow { voucherNo: string; date: string; branchCode: string; narration: string; debitAccounts: string; creditAccounts: string; amount: number; legs: number; /** False when debits and credits on the voucher do not agree. */ balanced: boolean; } /** * Client-wise balances, derived from the GL rather than stored separately so the two * can never disagree. Each customer's balance is its share of the branch's closing * balance in the head it sits under. */ export function clientWiseBalances(range: GlDateRange, branchCode: string): ClientBalanceRow[] { const rows: ClientBalanceRow[] = []; const branchClosings = new Map>(); for (const c of glCustomers()) { if (branchCode && c.branchCode !== branchCode) { continue; } let closing = branchClosings.get(c.branchCode); if (!closing) { closing = cumulativeBalances(range.to, c.branchCode); branchClosings.set(c.branchCode, closing); } const headBalance = closing.get(c.accountCode) ?? 0; const balance = headBalance * c.share; if (Math.abs(balance) < 0.005) { continue; } const account = glAccount(c.accountCode); rows.push({ cif: c.cif, name: c.name, branchCode: c.branchCode, accountNo: c.accountNo, accountCode: c.accountCode, product: account?.name ?? c.accountCode, side: c.accountCode.startsWith('13') ? 'ADVANCE' : 'DEPOSIT', balance }); } return rows.sort((a, b) => b.balance - a.balance); } /** * Head-wise abstract: the trial balance rolled up to principal heads, the summary a * banker reads before the detail. Opening, turnover and closing per head. */ export function bankerHeadSummary(range: GlDateRange, branchCode: string): BankerHeadRow[] { const opening = cumulativeBalances(previousDay(range.from), branchCode); const turnover = periodTurnover(range, branchCode); const byHead = new Map(); for (const account of GL_ACCOUNTS) { // Contingents are single-sided off-balance-sheet memoranda. Including them would // make the abstract's debit and credit turnover disagree and stop it reconciling // to the trial balance, which also excludes them. They have their own report. if (account.type === 'CONTINGENT') { continue; } const open = opening.get(account.code) ?? 0; const move = turnover.get(account.code); if (!open && !move) { continue; } const debit = move?.debit ?? 0; const credit = move?.credit ?? 0; const closing = open + (isDebitSide(account) ? debit - credit : credit - debit); const row = byHead.get(account.group) ?? { head: account.group, opening: 0, debit: 0, credit: 0, closing: 0, accounts: 0 }; row.opening += open; row.debit += debit; row.credit += credit; row.closing += closing; row.accounts += 1; byHead.set(account.group, row); } return [...byHead.values()]; } const SUNDRY_CODES = [ '140104', '230103', '140199', '230102' ]; /** * Outstanding items in the sundry and suspense heads, aged from the posting date. * These accounts are where unreconciled entries accumulate, so ageing is the point * of the register. */ export function sundryRegister(range: GlDateRange, branchCode: string): SundryItemRow[] { const wanted = new Set(SUNDRY_CODES); const end = new Date(`${range.to}T00:00:00Z`).getTime(); const rows: SundryItemRow[] = []; for (const p of glPostings()) { if (p.date > range.to || !matchesBranch(p, branchCode) || !wanted.has(p.accountCode)) { continue; } const ageDays = Math.max(0, Math.round((end - new Date(`${p.date}T00:00:00Z`).getTime()) / 86400000)); const bucket = ageDays <= 30 ? 'Up to 30 days' : ageDays <= 90 ? '31 to 90 days' : ageDays <= 180 ? '91 to 180 days' : 'Over 180 days'; rows.push({ date: p.date, voucherNo: p.voucherNo, branchCode: p.branchCode, code: p.accountCode, name: glAccount(p.accountCode)?.name ?? p.accountCode, narration: p.narration, amount: p.debit || p.credit, ageDays, bucket }); } return rows.sort((a, b) => b.ageDays - a.ageDays); } /** * Every voucher raised in the period with its debit and credit legs collapsed onto * one line, plus a per-voucher double-entry check. */ export function voucherRegister(range: GlDateRange, branchCode: string): VoucherRow[] { const grouped = new Map(); for (const p of glPostings()) { if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) { continue; } const list = grouped.get(p.voucherNo); if (list) { list.push(p); } else { grouped.set(p.voucherNo, [p]); } } const rows: VoucherRow[] = []; for (const [ voucherNo, legs ] of grouped) { const debits = legs.filter((l) => l.debit > 0); const credits = legs.filter((l) => l.credit > 0); const drTotal = debits.reduce((s, l) => s + l.debit, 0); const crTotal = credits.reduce((s, l) => s + l.credit, 0); const nameOf = (codes: GlPosting[], side: 'debit' | 'credit') => [...new Set(codes.map((l) => `${l.accountCode} ${glAccount(l.accountCode)?.name ?? ''}`.trim()))].join(', '); rows.push({ voucherNo, date: legs[0].date, branchCode: legs[0].branchCode, narration: legs[0].narration, debitAccounts: nameOf(debits, 'debit') || '—', creditAccounts: nameOf(credits, 'credit') || '—', amount: Math.max(drTotal, crTotal), legs: legs.length, // Contingent and opening memoranda are single-sided by design. balanced: voucherNo.startsWith('CNT/') || voucherNo.startsWith('OB/') || Math.round(drTotal - crTotal) === 0 }); } return rows.sort((a, b) => a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date) ); } // ── Memoisation ────────────────────────────────────────────────────────────── export interface GlReportSet { trialBalance: TrialBalanceRow[]; pnl: { income: PnlRow[]; expense: PnlRow[] }; balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number }; comparative: ComparativeRow[]; contingent: BalanceSheetRow[]; glWise: GlWiseRow[]; miscExpense: MiscExpenseRow[]; } let cacheKey = ''; let cached: GlReportSet | null = null; /** All seven reports for one filter selection, computed once per selection. */ export function buildReports(range: GlDateRange, branchCode: string): GlReportSet { const key = `${range.from}|${range.to}|${branchCode}`; if (cached && cacheKey === key) { return cached; } cached = { trialBalance: trialBalance(range, branchCode), pnl: profitAndLoss(range, branchCode), balanceSheet: balanceSheet(range, branchCode), comparative: comparativeBalanceSheet(range, branchCode), contingent: contingentLiabilities(range, branchCode), glWise: glWiseDrCr(range, branchCode), miscExpense: miscExpenseLedger(range, branchCode) }; cacheKey = key; return cached; } /** Clamp a range to the generated ledger so filters cannot query empty space. */ export function clampToLedger(range: GlDateRange): GlDateRange { return { from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from, to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to }; } /** * Value of a set of GL accounts for a period, used when an RBI return line item is * mapped to the chart of accounts. * * Balance-sheet accounts contribute their closing balance as at the period end; * income and expenditure accounts contribute their movement within the period. * That mirrors how the two kinds of figure are read on a return. */ export function glValueForCodes(codes: string[], range: GlDateRange, branchCode = ''): number { if (!codes.length) { return 0; } const wanted = new Set(codes); const closing = cumulativeBalances(range.to, branchCode); const movement = periodTurnover(range, branchCode); let total = 0; for (const code of wanted) { const account = glAccount(code); if (!account) { continue; } if (account.type === 'INCOME' || account.type === 'EXPENSE') { const move = movement.get(code); if (!move) { continue; } total += account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit; } else { total += closing.get(code) ?? 0; } } return total; } export function sum(rows: T[], pick: (row: T) => number): number { return rows.reduce((acc, r) => acc + pick(r), 0); } /** Groups rows by their `group` field, preserving chart-of-accounts order. */ export function groupBy(rows: T[]): { group: string; rows: T[]; total: number }[] { const order: string[] = []; const map = new Map(); for (const r of rows) { if (!map.has(r.group)) { map.set(r.group, []); order.push(r.group); } map.get(r.group)!.push(r); } return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 })); }