844 lines
25 KiB
TypeScript
844 lines
25 KiB
TypeScript
/**
|
|
* 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<string, Map<string, number>>();
|
|
const turnoverCache = new Map<string, Map<string, { debit: number; credit: number; vouchers: Set<string> }>>();
|
|
|
|
/** Signed balance on the account's natural side, for postings up to and including `to`. */
|
|
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
|
|
const key = `${to}|${branchCode}`;
|
|
const hit = balanceCache.get(key);
|
|
if (hit) {
|
|
return hit;
|
|
}
|
|
const out = new Map<string, number>();
|
|
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<string, { debit: number; credit: number; vouchers: Set<string> }> {
|
|
const key = `${range.from}|${range.to}|${branchCode}`;
|
|
const hit = turnoverCache.get(key);
|
|
if (hit) {
|
|
return hit;
|
|
}
|
|
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
|
|
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<string>() };
|
|
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<string, number>;
|
|
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<string, Map<string, number>>();
|
|
const movedOn = new Map<string, Set<string>>();
|
|
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<string, number>();
|
|
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<string>();
|
|
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<string, Map<string, number>>();
|
|
for (const b of branchCodes) {
|
|
perBranch.set(b, cumulativeBalances(range.to, b));
|
|
}
|
|
const rows: AccountBranchRow[] = [];
|
|
for (const account of GL_ACCOUNTS) {
|
|
const byBranch: Record<string, number> = {};
|
|
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<string, { amount: number; accounts: number }>();
|
|
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<string, Map<string, number>>();
|
|
|
|
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<string, BankerHeadRow>();
|
|
|
|
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<string, GlPosting[]>();
|
|
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<T>(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<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
|
|
const order: string[] = [];
|
|
const map = new Map<string, T[]>();
|
|
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 }));
|
|
}
|