oss file updated, profitl loss as per the day month and also with date select

This commit is contained in:
2026-08-01 11:46:11 +05:30
parent 4261f9659e
commit bd753f4983
33 changed files with 8748 additions and 404 deletions
+496
View File
@@ -0,0 +1,496 @@
/**
* 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/.
*/
/**
* Data layer for the General Ledger report suite.
*
* Rather than hard-coding seven finished reports, this file holds a chart of
* accounts plus a generated ledger of double-entry postings. Every report is
* then computed from those postings, which is what makes the day / month /
* branch filters genuinely recompute instead of just re-labelling a static table.
*
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
* figures are identical on every reload and across users. Amounts are in rupees.
*/
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
export interface GlAccount {
code: string;
name: string;
type: GlAccountType;
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
group: string;
/** Included in the Misc Expense Ledger report. */
miscExpense?: boolean;
}
export interface GlBranch {
code: string;
name: string;
}
/** One side of a double-entry voucher. */
export interface GlPosting {
/** ISO yyyy-MM-dd — the posting (value) date. */
date: string;
branchCode: string;
accountCode: string;
debit: number;
credit: number;
voucherNo: string;
narration: string;
}
export const GL_BRANCHES: GlBranch[] = [
{ code: 'HO', name: 'Head Office' },
{ code: '0002', name: 'Andheri (E)' },
{ code: '0003', name: 'Bandra (W)' },
{ code: '0004', name: 'Borivali' },
{ code: '0005', name: 'Dadar' },
{ code: '0006', name: 'Thane' }
];
export const GL_ACCOUNTS: GlAccount[] = [
// ── Assets ────────────────────────────────────────────────────────────────
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
// ── Liabilities & Capital ─────────────────────────────────────────────────
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
// ── Income ────────────────────────────────────────────────────────────────
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
// ── Expenditure ───────────────────────────────────────────────────────────
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '430101', name: 'Printing & Stationery', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430102', name: 'Postage, Telegram & Telephone', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430107', name: 'Legal & Professional Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430108', name: 'Insurance (DICGC & Other)', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430109', name: 'Bank Charges & Commission Paid', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
{ code: '430112', name: 'Other Miscellaneous Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
];
/** Financial year covered by the generated ledger. */
export const GL_PERIOD = {
/** FY 2026-27 opens here. */
fyStart: '2026-04-01',
/** Postings run to the day before the app's "today" (01 Aug 2026). */
fyEnd: '2026-07-31',
label: 'FY 2026-27'
};
/**
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
* account opens on its natural side (debit for assets/expenses, credit for the
* rest). Head Office carries the investments and reserves; branches carry the
* customer-facing book.
*/
const OPENING_BALANCES: Record<string, number> = {
'110101': 9_996_000,
'110102': 12_400_000,
'110103': 13_667_000,
// Surplus deposits parked with correspondent banks. Sized so the opening
// balance sheet tallies exactly — see the assertion in glPostings().
'110104': 54_206_000,
'120101': 63_839_000,
'120102': 147_895_000,
'120201': 22_500_000,
'120202': 24_234_000,
'130101': 166_614_000,
'130102': 125_082_000,
'130103': 70_236_000,
'130104': 29_356_000,
'130105': 10_241_000,
'130106': 8_450_000,
'140101': 33_541_000,
'140102': 3_185_000,
'140103': 1_763_000,
'140104': 1_240_000,
'140105': 657_000,
'210101': 27_278_000,
'210201': 31_149_000,
'210202': 13_642_000,
'210203': 3_750_000,
'210204': 24_341_000,
'220101': 208_116_000,
'220102': 19_592_000,
'220103': 406_601_000,
'220104': 24_880_000,
'230101': 731_000,
'230102': 291_000,
'230103': 5_515_000,
'230104': 1_120_000,
'230105': 32_096_000,
'910101': 1_450_000,
'910102': 980_000,
'910103': 620_000,
'910104': 1_180_000,
'910105': 2_638_000,
'910106': 0,
'910107': 340_000
};
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function isoDate(d: Date): string {
const y = d.getUTCFullYear();
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
const day = `${d.getUTCDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
export function ledgerDates(): string[] {
const out: string[] = [];
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
while (cur <= end) {
if (cur.getUTCDay() !== 0) {
out.push(isoDate(cur));
}
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/** Templates for the vouchers the generator posts each day. */
const VOUCHER_TEMPLATES: {
debit: string;
credit: string;
narration: string;
/** Rough rupee scale of the voucher. */
scale: number;
/** Chance the voucher is raised on any given branch-day. */
frequency: number;
}[] = [
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
// Interest on advances is the dominant earning line — sized so the bank runs a
// ~13% net margin rather than the loss the first-cut figures produced.
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
{ debit: '410101', credit: '230101', narration: 'Interest provided on savings deposits', scale: 128_000, frequency: 0.8 },
{ debit: '410102', credit: '230101', narration: 'Interest provided on term deposits', scale: 265_000, frequency: 0.9 },
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
{ debit: '110102', credit: '110101', narration: 'Cash remitted to RBI currency chest', scale: 1_800_000, frequency: 0.2 },
{ debit: '110101', credit: '110103', narration: 'Funds drawn from correspondent bank', scale: 1_400_000, frequency: 0.2 }
];
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
{ debit: '120102', credit: '110102', narration: 'Purchase of State Govt securities', scale: 1_900_000, frequency: 0.12 },
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
];
/**
* Contingent-account movements are memoranda, each posted single-sided.
* Guarantees, LCs and collection bills are raised at the counter, so these run
* at every branch — not just Head Office.
*/
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
];
/**
* How the opening contingent book is spread across the network. Head Office
* carries the capital commitments and large claims; the branches carry the
* counter-issued guarantees. Weights are applied per account and the rounding
* remainder is absorbed by Head Office so each account's total stays exact.
*/
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
HO: 0.2,
'0002': 0.18,
'0003': 0.16,
'0004': 0.16,
'0005': 0.15,
'0006': 0.15
};
let cachedPostings: GlPosting[] | null = null;
/**
* The full generated ledger. Built once and memoised — every report reads from
* this array, so the reports can never disagree with one another.
*/
export function glPostings(): GlPosting[] {
if (cachedPostings) {
return cachedPostings;
}
const rand = lcg(20260401);
const out: GlPosting[] = [];
const dates = ledgerDates();
let voucherSeq = 1;
// Opening entries, posted at Head Office on day one so the books start in balance.
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
let openingDebit = 0;
let openingCredit = 0;
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
if (!amount) {
continue;
}
const account = GL_ACCOUNTS.find((a) => a.code === code);
if (!account) {
continue;
}
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
// Contingent accounts are single-sided memoranda held at the issuing branch,
// so their opening book is spread across the network rather than sat at HO.
if (account.type === 'CONTINGENT') {
let allocated = 0;
for (const branch of GL_BRANCHES) {
if (branch.code === 'HO') {
continue;
}
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
if (!share) {
continue;
}
allocated += share;
out.push({
date: GL_PERIOD.fyStart,
branchCode: branch.code,
accountCode: code,
debit: share,
credit: 0,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
}
// Head Office takes its own share plus whatever rounding left behind.
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: code,
debit: amount - allocated,
credit: 0,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
continue;
}
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: code,
debit: debitSide ? amount : 0,
credit: debitSide ? 0 : amount,
voucherNo: openingVoucher,
narration: 'Opening balance brought forward'
});
if (debitSide) {
openingDebit += amount;
} else {
openingCredit += amount;
}
}
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
// in a visibly-named suspense account rather than letting the trial balance drift.
const openingResidual = openingCredit - openingDebit;
if (openingResidual !== 0) {
out.push({
date: GL_PERIOD.fyStart,
branchCode: 'HO',
accountCode: '140199',
debit: openingResidual > 0 ? openingResidual : 0,
credit: openingResidual < 0 ? -openingResidual : 0,
voucherNo: openingVoucher,
narration: 'Unreconciled opening balance difference'
});
}
const round = (n: number): number => Math.round(n / 100) * 100;
for (const date of dates) {
for (const branch of GL_BRANCHES) {
// Head Office posts fewer counter transactions than the branches.
const weight = branch.code === 'HO' ? 0.45 : 1;
const templates = branch.code === 'HO' ? [...VOUCHER_TEMPLATES, ...HO_TEMPLATES] : VOUCHER_TEMPLATES;
for (const tpl of templates) {
if (rand() > tpl.frequency * weight) {
continue;
}
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
if (amount <= 0) {
continue;
}
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
out.push({
date,
branchCode: branch.code,
accountCode: tpl.debit,
debit: amount,
credit: 0,
voucherNo,
narration: tpl.narration
});
out.push({
date,
branchCode: branch.code,
accountCode: tpl.credit,
debit: 0,
credit: amount,
voucherNo,
narration: tpl.narration
});
}
// Contingent memoranda are raised at every branch, weighted by its size.
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
for (const tpl of CONTINGENT_TEMPLATES) {
// Claims against the bank and capital commitments are Head Office matters.
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
if (headOfficeOnly && branch.code !== 'HO') {
continue;
}
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
if (rand() > frequency) {
continue;
}
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
out.push({
date,
branchCode: branch.code,
accountCode: tpl.account,
debit: amount,
credit: 0,
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
narration: tpl.narration
});
}
}
}
cachedPostings = out;
return out;
}
export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code);
}
export function glBranchName(code: string): string {
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
}
@@ -0,0 +1,34 @@
/**
* 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/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
Route.withShell([
{
path: 'gl/reports',
// Lazy — the generated ledger and ExcelJS should not weigh down the eager bundle.
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
},
{
path: 'gl/reports/:report',
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GlReportsRoutingModule {}
@@ -0,0 +1,523 @@
<!--
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/.
-->
<div class="glr-root">
<!-- HEADER -->
<div class="glr-head">
<div class="glr-head-left">
<fa-icon icon="book-open" class="glr-head-ic"></fa-icon>
<div>
<h2 class="glr-head-t">General Ledger — {{ reportLabel }}</h2>
<div class="glr-head-s">
CBSNexora Bank Ltd &middot; {{ branchLabel }} &middot; Period <strong>{{ rangeLabel }}</strong> &middot;
{{ ledgerPeriod.label }}
</div>
</div>
</div>
<button class="glr-btn glr-btn-xls" [disabled]="generating" (click)="exportExcel()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
<!-- FILTER BAR -->
<div class="glr-filters">
<div class="glr-filter">
<label>Period</label>
<div class="glr-seg">
<button [class.active]="periodMode === 'day'" (click)="periodMode = 'day'">Day-wise</button>
<button [class.active]="periodMode === 'month'" (click)="periodMode = 'month'">Month-wise</button>
<button [class.active]="periodMode === 'range'" (click)="periodMode = 'range'">Date Range</button>
</div>
</div>
@if (periodMode === 'day') {
<div class="glr-filter">
<label>Date</label>
<input
class="glr-inp"
type="date"
[(ngModel)]="filterDay"
[min]="ledgerPeriod.fyStart"
[max]="ledgerPeriod.fyEnd"
/>
</div>
}
@if (periodMode === 'month') {
<div class="glr-filter">
<label>Month</label>
<input class="glr-inp" type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
</div>
}
@if (periodMode === 'range') {
<div class="glr-filter">
<label>From</label>
<input
class="glr-inp"
type="date"
[(ngModel)]="filterFrom"
[min]="ledgerPeriod.fyStart"
[max]="ledgerPeriod.fyEnd"
/>
</div>
<div class="glr-filter">
<label>To</label>
<input
class="glr-inp"
type="date"
[(ngModel)]="filterTo"
[min]="ledgerPeriod.fyStart"
[max]="ledgerPeriod.fyEnd"
/>
</div>
}
<div class="glr-filter">
<label>Branch</label>
<select class="glr-inp" [(ngModel)]="branchCode">
<option value="">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
</div>
<button class="glr-btn" (click)="resetFilters()">Reset</button>
</div>
<!-- REPORT SELECTOR -->
<div class="glr-tabs">
@for (r of reports; track r.code) {
<button class="glr-tab" [class.active]="activeReport === r.code" (click)="showReport(r.code)">
<fa-icon [icon]="r.icon"></fa-icon> {{ r.label }}
</button>
}
</div>
<!-- ══════════════ TRIAL BALANCE ══════════════ -->
@if (activeReport === 'trial-balance') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="!tbBalanced">
<fa-icon [icon]="tbBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Closing Dr <strong>&#8377;{{ tbClosingDebit | number: '1.2-2' }}</strong> &middot; Closing Cr
<strong>&#8377;{{ tbClosingCredit | number: '1.2-2' }}</strong> —
{{ tbBalanced ? 'trial balance tallies' : 'OUT OF BALANCE — investigate before closing the period' }}
</span>
</div>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Group</th>
<th class="ta-r">Opening Dr</th>
<th class="ta-r">Opening Cr</th>
<th class="ta-r">Period Dr</th>
<th class="ta-r">Period Cr</th>
<th class="ta-r">Closing Dr</th>
<th class="ta-r">Closing Cr</th>
</tr>
</thead>
<tbody>
@for (r of data.trialBalance; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
<td class="ta-r mono">{{ r.openingDebit ? (r.openingDebit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.openingCredit ? (r.openingCredit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.periodDebit ? (r.periodDebit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.periodCredit ? (r.periodCredit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.closingDebit ? (r.closingDebit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.closingCredit ? (r.closingCredit | number: '1.2-2') : '' }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="3">TOTAL ({{ data.trialBalance.length }} accounts)</td>
<td class="ta-r mono">{{ tbOpeningDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbOpeningCredit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbPeriodDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbPeriodCredit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbClosingDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ tbClosingCredit | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ PROFIT AND LOSS ══════════════ -->
@if (activeReport === 'profit-and-loss') {
<div class="glr-section">
<div class="glr-kpis">
<div class="glr-kpi glr-kpi--teal">
<div class="glr-kpi-n">&#8377;{{ totalIncome | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Total Income</div>
</div>
<div class="glr-kpi glr-kpi--amber">
<div class="glr-kpi-n">&#8377;{{ totalExpense | number: '1.2-2' }}</div>
<div class="glr-kpi-l">Total Expenditure</div>
</div>
<div class="glr-kpi" [class.glr-kpi--green]="netProfit >= 0" [class.glr-kpi--red]="netProfit < 0">
<div class="glr-kpi-n">&#8377;{{ netProfit | number: '1.2-2' }}</div>
<div class="glr-kpi-l">{{ netProfit >= 0 ? 'Net Profit' : 'Net Loss' }}</div>
<div class="glr-kpi-sub">{{ netProfitPct | number: '1.2-2' }}% of income</div>
</div>
</div>
<div class="glr-two-col">
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Expenditure</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of pnlExpense; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">No expenditure posted in this period.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total Expenditure</td>
<td class="ta-r mono">{{ totalExpense | number: '1.2-2' }}</td>
</tr>
@if (netProfit >= 0) {
<tr class="total-row glr-profit-row">
<td colspan="2">Net Profit carried to Balance Sheet</td>
<td class="ta-r mono">{{ netProfit | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Income</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of pnlIncome; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="glr-empty">No income posted in this period.</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total Income</td>
<td class="ta-r mono">{{ totalIncome | number: '1.2-2' }}</td>
</tr>
@if (netProfit < 0) {
<tr class="total-row glr-loss-row">
<td colspan="2">Net Loss carried to Balance Sheet</td>
<td class="ta-r mono">{{ -netProfit | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<!-- ══════════════ CONSOLIDATED BALANCE SHEET ══════════════ -->
@if (activeReport === 'balance-sheet') {
<div class="glr-section">
<div class="glr-balance" [class.glr-balance--bad]="!bsBalanced">
<fa-icon [icon]="bsBalanced ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span>
Total Assets <strong>&#8377;{{ bsTotalAssets | number: '1.2-2' }}</strong> &middot; Total Liabilities
<strong>&#8377;{{ bsTotalLiabilities | number: '1.2-2' }}</strong> —
{{ bsBalanced ? 'balance sheet tallies' : 'OUT OF BALANCE' }}
</span>
</div>
<div class="glr-two-col">
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Capital &amp; Liabilities &mdash; as at {{ range.to }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of bsLiabilities; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
<tr>
<td class="mono">—</td>
<td>Surplus for the period<span class="muted"> · Profit &amp; Loss</span></td>
<td class="ta-r mono">{{ bsSurplus | number: '1.2-2' }}</td>
</tr>
<tr class="total-row">
<td colspan="2">Total Capital &amp; Liabilities</td>
<td class="ta-r mono">{{ bsTotalLiabilities | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">Assets &mdash; as at {{ range.to }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of bsAssets; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="total-row">
<td colspan="2">Total Assets</td>
<td class="ta-r mono">{{ bsTotalAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
}
<!-- ══════════════ COMPARATIVE BALANCE SHEET ══════════════ -->
@if (activeReport === 'comparative-balance-sheet') {
<div class="glr-section">
<p class="glr-note">
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before
this period began. Change the filter above to compare a different span.
</p>
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) {
<div class="glr-tbl-wrap">
<div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th class="ta-r">Current (&#8377;)</th>
<th class="ta-r">Previous (&#8377;)</th>
<th class="ta-r">Variance (&#8377;)</th>
<th class="ta-r">Variance %</th>
</tr>
</thead>
<tbody>
@for (r of block.rows; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
{{ r.variance | number: '1.2-2' }}
</td>
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
{{ r.variancePct === null ? '—' : (r.variancePct | number: '1.2-2') + '%' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="glr-empty">Nothing to compare for this period and branch.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- ══════════════ CONTINGENT LIABILITIES ══════════════ -->
@if (activeReport === 'contingent-liabilities') {
<div class="glr-section">
<p class="glr-note">
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts
and are deliberately excluded from the Trial Balance and Balance Sheet totals.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Particulars</th>
<th>Group</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of data.contingent; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.group }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="glr-empty">No contingent exposures outstanding for this selection.</td>
</tr>
}
<tr class="total-row">
<td colspan="3">Total Contingent Liabilities</td>
<td class="ta-r mono">{{ contingentTotal | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ GL WISE DR / CR ══════════════ -->
@if (activeReport === 'gl-wise-dr-cr') {
<div class="glr-section">
<p class="glr-note">
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every
voucher is double-entry, so the debit and credit totals below must match.
</p>
<div class="glr-tbl-wrap">
<table class="glr-tbl">
<thead>
<tr>
<th>GL Code</th>
<th>Account</th>
<th>Type</th>
<th>Group</th>
<th class="ta-r">Debit (&#8377;)</th>
<th class="ta-r">Credit (&#8377;)</th>
<th class="ta-r">Net (Dr &minus; Cr)</th>
<th class="ta-c">Vouchers</th>
</tr>
</thead>
<tbody>
@for (r of data.glWise; track r.code) {
<tr>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td>
<span class="type-pill" [class]="'type-' + r.type.toLowerCase()">{{ r.type }}</span>
</td>
<td class="muted">{{ r.group }}</td>
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
<td class="ta-r mono" [class.var-up]="r.net > 0" [class.var-down]="r.net < 0">
{{ r.net | number: '1.2-2' }}
</td>
<td class="ta-c mono">{{ r.vouchers }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="glr-empty">No postings for this period and branch.</td>
</tr>
}
<tr class="total-row">
<td colspan="4">TOTAL ({{ data.glWise.length }} accounts)</td>
<td class="ta-r mono">{{ glWiseDebit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ glWiseCredit | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ glWiseDebit - glWiseCredit | number: '1.2-2' }}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
}
<!-- ══════════════ MISC EXPENSE LEDGER ══════════════ -->
@if (activeReport === 'misc-expense-ledger') {
<div class="glr-section">
<p class="glr-note">
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}
&mdash; {{ miscRows.length | number }} entries.
</p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl">
<thead>
<tr>
<th>Date</th>
<th>Voucher No</th>
<th>Branch</th>
<th>GL Code</th>
<th>Account</th>
<th>Narration</th>
<th class="ta-r">Amount (&#8377;)</th>
</tr>
</thead>
<tbody>
@for (r of miscRows; track r.voucherNo + r.code) {
<tr>
<td class="mono">{{ r.date }}</td>
<td class="mono">{{ r.voucherNo }}</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="mono">{{ r.code }}</td>
<td>{{ r.name }}</td>
<td class="muted">{{ r.narration }}</td>
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="glr-empty">No miscellaneous expenses posted for this selection.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6">Total Miscellaneous Expenses</td>
<td class="ta-r mono">{{ miscTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- TOAST -->
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,506 @@
/**
* 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/.
*/
:host {
--g-navy: #1a237e;
--g-blue: #1d4ed8;
--g-blue-l: #eff6ff;
--g-teal: #0e7e6b;
--g-teal-l: #e0f4f0;
--g-green: #15803d;
--g-green-l: #dcfce7;
--g-amber: #b45309;
--g-amber-l: #fef3c7;
--g-red: #c0392b;
--g-red-l: #fdecea;
--g-g50: #f8fafc;
--g-g100: #f1f5f9;
--g-g200: #e2e8f0;
--g-g300: #cbd5e1;
--g-g500: #64748b;
--g-g700: #334155;
--g-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--g-g50);
color: var(--g-g900);
font-size: 14px;
line-height: 1.5;
}
.glr-root {
padding: 18px 22px;
max-width: 1700px;
margin: 0 auto;
}
/* HEADER */
.glr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.glr-head-left {
display: flex;
align-items: center;
gap: 14px;
}
.glr-head-ic {
font-size: 28px;
color: var(--g-navy);
}
.glr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.glr-head-s {
font-size: 12px;
color: var(--g-g500);
}
/* BUTTONS */
.glr-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border: 1px solid var(--g-g300);
background: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--g-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
&:hover:not(:disabled) {
background: var(--g-g50);
border-color: var(--g-g500);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.glr-btn-xls {
background: #16a34a;
border-color: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
border-color: #15803d;
color: #fff;
}
}
/* FILTER BAR */
.glr-filters {
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 12px;
padding: 14px;
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
}
.glr-filter {
display: flex;
flex-direction: column;
gap: 5px;
label {
font-size: 11px;
font-weight: 700;
color: var(--g-g500);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.glr-inp {
padding: 8px 12px;
border: 1px solid var(--g-g200);
border-radius: 8px;
font-size: 13px;
background: #fff;
font-family: inherit;
min-width: 190px;
&:focus {
outline: none;
border-color: var(--g-navy);
box-shadow: 0 0 0 3px rgb(26 35 126 / 12%);
}
}
/* Day / Month / Range segmented control */
.glr-seg {
display: inline-flex;
border: 1px solid var(--g-g200);
border-radius: 8px;
overflow: hidden;
button {
padding: 8px 14px;
border: none;
background: #fff;
font-size: 13px;
font-weight: 600;
color: var(--g-g500);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
+ button {
border-left: 1px solid var(--g-g200);
}
&:hover {
background: var(--g-g50);
}
&.active {
background: var(--g-navy);
color: #fff;
}
}
}
/* REPORT SELECTOR */
.glr-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 10px;
padding: 4px;
margin-bottom: 16px;
overflow-x: auto;
}
.glr-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: var(--g-g500);
cursor: pointer;
border-radius: 7px;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
&:hover {
color: var(--g-g900);
}
&.active {
background: var(--g-navy);
color: #fff;
box-shadow: 0 2px 6px rgb(26 35 126 / 25%);
}
}
.glr-section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* BALANCE BANNER */
.glr-balance {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 10px;
background: var(--g-green-l);
border: 1px solid #86efac;
color: #14532d;
font-size: 13px;
&--bad {
background: var(--g-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
.glr-note {
margin: 0;
font-size: 12px;
color: var(--g-g500);
}
/* KPIs */
.glr-kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.glr-kpi {
background: #fff;
border: 1px solid var(--g-g200);
border-left: 4px solid var(--g-g300);
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
&--teal {
border-left-color: var(--g-teal);
}
&--amber {
border-left-color: var(--g-amber);
}
&--green {
border-left-color: var(--g-green);
}
&--red {
border-left-color: var(--g-red);
}
}
.glr-kpi-n {
font-size: 20px;
font-weight: 700;
font-family: 'DM Mono', monospace;
color: var(--g-g900);
}
.glr-kpi-l {
font-size: 12px;
color: var(--g-g700);
font-weight: 600;
margin-top: 2px;
}
.glr-kpi-sub {
font-size: 11px;
color: var(--g-g500);
}
/* TABLES */
.glr-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.glr-tbl-wrap {
background: #fff;
border: 1px solid var(--g-g200);
border-radius: 12px;
overflow: auto;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&--tall {
max-height: 70vh;
}
}
.glr-tbl-h {
background: var(--g-blue-l);
color: var(--g-navy);
padding: 10px 16px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--g-g200);
position: sticky;
top: 0;
z-index: 2;
}
.glr-tbl {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 9px 12px;
font-size: 11px;
font-weight: 700;
color: var(--g-g500);
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--g-g50);
border-bottom: 1px solid var(--g-g200);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--g-g100);
font-size: 13px;
color: var(--g-g700);
}
tbody tr:hover td {
background: var(--g-g50);
}
tfoot td {
position: sticky;
bottom: 0;
}
}
.total-row td {
background: var(--g-g100) !important;
font-weight: 700;
color: var(--g-g900);
border-top: 2px solid var(--g-g300);
}
.glr-profit-row td {
background: var(--g-green-l) !important;
color: #14532d;
}
.glr-loss-row td {
background: var(--g-red-l) !important;
color: #7f1d1d;
}
.ta-c {
text-align: center;
}
.ta-r {
text-align: right;
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 12px;
}
.muted {
color: var(--g-g500);
font-size: 11px;
}
.var-up {
color: var(--g-green);
font-weight: 600;
}
.var-down {
color: var(--g-red);
font-weight: 600;
}
.glr-empty {
text-align: center;
padding: 34px;
color: var(--g-g500);
font-style: italic;
}
/* Account-type pills on the GL Wise report */
.type-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
background: var(--g-g100);
color: var(--g-g700);
&.type-asset {
background: var(--g-blue-l);
color: var(--g-blue);
}
&.type-liability {
background: var(--g-teal-l);
color: var(--g-teal);
}
&.type-income {
background: var(--g-green-l);
color: var(--g-green);
}
&.type-expense {
background: var(--g-amber-l);
color: var(--g-amber);
}
&.type-contingent {
background: var(--g-red-l);
color: var(--g-red);
}
}
/* TOAST */
.glr-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--g-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition: all 0.25s;
z-index: 1500;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (width <= 1200px) {
.glr-two-col {
grid-template-columns: 1fr;
}
.glr-kpis {
grid-template-columns: 1fr;
}
}
+470
View File
@@ -0,0 +1,470 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
import {
BalanceSheetRow,
ComparativeRow,
GlDateRange,
GlReportSet,
MiscExpenseRow,
PnlRow,
buildReports,
clampToLedger,
sum
} from './gl-reports.compute';
export type GlReportCode =
| 'trial-balance'
| 'profit-and-loss'
| 'balance-sheet'
| 'comparative-balance-sheet'
| 'contingent-liabilities'
| 'gl-wise-dr-cr'
| 'misc-expense-ledger';
type PeriodMode = 'day' | 'month' | 'range';
@Component({
selector: 'mifosx-gl-reports',
templateUrl: './gl-reports.component.html',
styleUrls: ['./gl-reports.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
{ code: 'profit-and-loss', label: 'Profit and Loss', icon: 'chart-line' },
{ code: 'balance-sheet', label: 'Consolidated Balance Sheet', icon: 'file-alt' },
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' }
];
activeReport: GlReportCode = 'trial-balance';
branches = GL_BRANCHES;
ledgerPeriod = GL_PERIOD;
// ── Filter state ───────────────────────────────────────────────────────────
periodMode: PeriodMode = 'month';
/** Last full month of the generated ledger. */
filterDay = '2026-07-31';
filterMonth = '2026-07';
filterFrom = GL_PERIOD.fyStart;
filterTo = GL_PERIOD.fyEnd;
branchCode = '';
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
ngOnInit(): void {
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null;
if (param && this.reports.some((r) => r.code === param)) {
this.activeReport = param;
}
}
showReport(code: GlReportCode): void {
this.activeReport = code;
// Keep the URL deep-linkable so the GL menu items land on the right report.
this.router.navigate(['/gl/reports', code], { replaceUrl: true });
}
get reportLabel(): string {
return this.reports.find((r) => r.code === this.activeReport)?.label ?? '';
}
// ── Filter resolution ──────────────────────────────────────────────────────
/** Turns the filter controls into the concrete date range the reports use. */
get range(): GlDateRange {
if (this.periodMode === 'day') {
return clampToLedger({ from: this.filterDay, to: this.filterDay });
}
if (this.periodMode === 'month') {
const [
y,
m
] = this.filterMonth.split('-').map(Number);
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return clampToLedger({ from: `${this.filterMonth}-01`, to: `${this.filterMonth}-${`${last}`.padStart(2, '0')}` });
}
return clampToLedger({ from: this.filterFrom, to: this.filterTo });
}
get rangeLabel(): string {
const r = this.range;
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
}
get branchLabel(): string {
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
}
resetFilters(): void {
this.periodMode = 'month';
this.filterMonth = '2026-07';
this.filterDay = '2026-07-31';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd;
this.branchCode = '';
}
/** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet {
return buildReports(this.range, this.branchCode);
}
// ── Trial Balance ──────────────────────────────────────────────────────────
get tbOpeningDebit(): number {
return sum(this.data.trialBalance, (r) => r.openingDebit);
}
get tbOpeningCredit(): number {
return sum(this.data.trialBalance, (r) => r.openingCredit);
}
get tbPeriodDebit(): number {
return sum(this.data.trialBalance, (r) => r.periodDebit);
}
get tbPeriodCredit(): number {
return sum(this.data.trialBalance, (r) => r.periodCredit);
}
get tbClosingDebit(): number {
return sum(this.data.trialBalance, (r) => r.closingDebit);
}
get tbClosingCredit(): number {
return sum(this.data.trialBalance, (r) => r.closingCredit);
}
/** A trial balance that does not tally is the single most important thing to surface. */
get tbBalanced(): boolean {
return Math.round(this.tbClosingDebit) === Math.round(this.tbClosingCredit);
}
// ── Profit and Loss ────────────────────────────────────────────────────────
get pnlIncome(): PnlRow[] {
return this.data.pnl.income;
}
get pnlExpense(): PnlRow[] {
return this.data.pnl.expense;
}
get totalIncome(): number {
return sum(this.pnlIncome, (r) => r.amount);
}
get totalExpense(): number {
return sum(this.pnlExpense, (r) => r.amount);
}
get netProfit(): number {
return this.totalIncome - this.totalExpense;
}
get netProfitPct(): number {
return this.totalIncome ? (this.netProfit / this.totalIncome) * 100 : 0;
}
// ── Balance Sheet ──────────────────────────────────────────────────────────
get bsAssets(): BalanceSheetRow[] {
return this.data.balanceSheet.assets;
}
get bsLiabilities(): BalanceSheetRow[] {
return this.data.balanceSheet.liabilities;
}
get bsSurplus(): number {
return this.data.balanceSheet.surplus;
}
get bsTotalAssets(): number {
return sum(this.bsAssets, (r) => r.amount);
}
get bsTotalLiabilities(): number {
return sum(this.bsLiabilities, (r) => r.amount) + this.bsSurplus;
}
get bsBalanced(): boolean {
return Math.round(this.bsTotalAssets) === Math.round(this.bsTotalLiabilities);
}
// ── Comparative Balance Sheet ──────────────────────────────────────────────
get compAssets(): ComparativeRow[] {
return this.data.comparative.filter((r) => r.side === 'ASSET');
}
get compLiabilities(): ComparativeRow[] {
return this.data.comparative.filter((r) => r.side === 'LIABILITY');
}
// ── Contingent Liabilities ─────────────────────────────────────────────────
get contingentTotal(): number {
return sum(this.data.contingent, (r) => r.amount);
}
// ── GL Wise DR/CR ──────────────────────────────────────────────────────────
get glWiseDebit(): number {
return sum(this.data.glWise, (r) => r.debit);
}
get glWiseCredit(): number {
return sum(this.data.glWise, (r) => r.credit);
}
// ── Misc Expense Ledger ────────────────────────────────────────────────────
get miscRows(): MiscExpenseRow[] {
return this.data.miscExpense;
}
get miscTotal(): number {
return sum(this.miscRows, (r) => r.amount);
}
branchName(code: string): string {
return glBranchName(code);
}
// ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */
async exportExcel(): Promise<void> {
this.generating = true;
try {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
const sheet = wb.addWorksheet(this.reportLabel.slice(0, 31));
const { headers, rows, widths } = this.exportShape();
sheet.columns = widths.map((w) => ({ width: w }));
const title = sheet.addRow([this.reportLabel]);
sheet.mergeCells(title.number, 1, title.number, headers.length);
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
title.alignment = { horizontal: 'center', vertical: 'middle' };
title.height = 24;
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
const meta = sheet.addRow([`Period: ${this.rangeLabel} · Branch: ${this.branchLabel}`]);
sheet.mergeCells(meta.number, 1, meta.number, headers.length);
meta.font = { italic: true, size: 10 };
sheet.addRow([]);
const head = sheet.addRow(headers);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => {
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
c.border = { bottom: { style: 'thin' } };
});
for (const r of rows) {
const row = sheet.addRow(r);
for (let c = 1; c <= headers.length; c++) {
if (typeof r[c - 1] === 'number') {
row.getCell(c).numFmt = '#,##0.00';
}
}
}
sheet.views = [{ state: 'frozen', ySplit: 4 }];
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.reportLabel.replace(/[^\w]+/g, '-')}-${this.range.from}-to-${this.range.to}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.toast(`${this.reportLabel} exported for ${this.rangeLabel}`);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
/** Header row, data rows and column widths for the active report. */
private exportShape(): { headers: string[]; rows: (string | number | null)[][]; widths: number[] } {
switch (this.activeReport) {
case 'trial-balance':
return {
headers: [
'GL Code',
'Account',
'Group',
'Opening Dr',
'Opening Cr',
'Period Dr',
'Period Cr',
'Closing Dr',
'Closing Cr'
],
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
rows: [
...this.data.trialBalance.map((r) => [
r.code,
r.name,
r.group,
r.openingDebit,
r.openingCredit,
r.periodDebit,
r.periodCredit,
r.closingDebit,
r.closingCredit
]),
[
'TOTAL',
'',
'',
this.tbOpeningDebit,
this.tbOpeningCredit,
this.tbPeriodDebit,
this.tbPeriodCredit,
this.tbClosingDebit,
this.tbClosingCredit
]
]
};
case 'profit-and-loss':
return {
headers: [
'Side',
'GL Code',
'Account',
'Group',
'Amount'
],
widths: [14, 12, 42, 26, 18],
rows: [
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]),
['Total Expenditure', '', '', '', this.totalExpense],
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]),
['Total Income', '', '', '', this.totalIncome],
['Net Profit', '', '', '', this.netProfit]
]
};
case 'balance-sheet':
return {
headers: [
'Side',
'GL Code',
'Account',
'Group',
'Amount'
],
widths: [14, 12, 42, 26, 18],
rows: [
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]),
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus],
['Total Liabilities', '', '', '', this.bsTotalLiabilities],
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]),
['Total Assets', '', '', '', this.bsTotalAssets]
]
};
case 'comparative-balance-sheet':
return {
headers: [
'Side',
'GL Code',
'Account',
'Current',
'Previous',
'Variance',
'Variance %'
],
widths: [12, 12, 42, 18, 18, 18, 14],
rows: this.data.comparative.map((r) => [
r.side,
r.code,
r.name,
r.current,
r.previous,
r.variance,
r.variancePct === null ? '—' : Number(r.variancePct.toFixed(2))
])
};
case 'contingent-liabilities':
return {
headers: [
'GL Code',
'Particulars',
'Group',
'Amount'
],
widths: [12, 48, 26, 18],
rows: [
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
['TOTAL', '', '', this.contingentTotal]
]
};
case 'gl-wise-dr-cr':
return {
headers: [
'GL Code',
'Account',
'Type',
'Group',
'Debit',
'Credit',
'Net (Dr-Cr)',
'Vouchers'
],
widths: [12, 42, 14, 26, 18, 18, 18, 12],
rows: [
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]),
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null]
]
};
case 'misc-expense-ledger':
default:
return {
headers: [
'Date',
'Voucher No',
'Branch',
'GL Code',
'Account',
'Narration',
'Amount'
],
widths: [14, 20, 18, 12, 36, 46, 18],
rows: [
...this.miscRows.map((r) => [
r.date,
r.voucherNo,
this.branchName(r.branchCode),
r.code,
r.name,
r.narration,
r.amount
]),
['TOTAL', '', '', '', '', '', this.miscTotal]
]
};
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
+379
View File
@@ -0,0 +1,379 @@
/**
* 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,
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;
}
/** 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 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);
}
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 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);
}
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)));
}
// ── 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
};
}
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 }));
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 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/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { GlReportsRoutingModule } from './gl-reports-routing.module';
@NgModule({
imports: [
SharedModule,
GlReportsRoutingModule
]
})
export class GlReportsModule {}