From c935e2a62aac03673687d5d22bc060da7d5e9739 Mon Sep 17 00:00:00 2001
From: Nitesh Yadav
Date: Sat, 1 Aug 2026 13:15:17 +0530
Subject: [PATCH 1/7] feat(reconciliation): build full 16-screen Reconciliation
module
Wires every item in the Reconciliation mega-menu (Core & Books, Bank &
Clearing, Digital & Payments, Instruments & Members, Compliance &
Financials) to a working screen, mirroring the NPA module's pattern: one
route-param-driven component over a shared two-sided (Side A vs Side B)
matching model, with pagination and a "Mark as Reconciled" action on every
screen.
Access is split by real-world scope: activity that happens at a branch
(vouchers, cheques, digital transactions, share members, fixed assets) is
branch-wise with the same Head-Office/branch login behavior as NPA; activity
with no branch dimension in real banking (GL/P&L consolidation, treasury's
own bank accounts, statutory tax filing, vendor procurement, inter-branch
netting) is Head-Office-only, with branch logins shown a restricted notice
instead of the data.
Also adds the .menu-item-live "working" indicator to mat-menu-item buttons
(same convention as .gl-link--live) and applies it across the reconciliation
menu and the pre-existing DD/Pay Order Reconciliation link.
---
src/app/app.module.ts | 2 +
src/app/core/shell/shell.component.html | 34 +-
src/app/core/shell/shell.component.scss | 20 +
src/app/reconciliation/reconciliation-data.ts | 703 ++++++++++++++++++
.../reconciliation-report.component.html | 228 ++++++
.../reconciliation-report.component.ts | 246 ++++++
.../reconciliation-routing.module.ts | 41 +
.../reconciliation/reconciliation-shared.scss | 107 +++
.../reconciliation/reconciliation.module.ts | 22 +
9 files changed, 1386 insertions(+), 17 deletions(-)
create mode 100644 src/app/reconciliation/reconciliation-data.ts
create mode 100644 src/app/reconciliation/reconciliation-report/reconciliation-report.component.html
create mode 100644 src/app/reconciliation/reconciliation-report/reconciliation-report.component.ts
create mode 100644 src/app/reconciliation/reconciliation-routing.module.ts
create mode 100644 src/app/reconciliation/reconciliation-shared.scss
create mode 100644 src/app/reconciliation/reconciliation.module.ts
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 7ec73cbd5..9d7c9aab5 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -67,6 +67,7 @@ import { FiuModule } from './fiu/fiu.module';
import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
+import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
SftModule,
InvestmentModule,
NpaModule,
+ ReconciliationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 30f3faa1a..825abc657 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -1299,27 +1299,27 @@
diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss
index e6aa08e9f..19552a68d 100644
--- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss
+++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.scss
@@ -71,3 +71,68 @@
background: #eff6ff;
}
}
+
+// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
+// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
+// pair from the next.
+.cctr-journal-row {
+ &--first td {
+ border-bottom: none;
+ padding-bottom: 4px;
+ }
+
+ &--second td {
+ border-top: none;
+ padding-top: 4px;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ &--first,
+ &--second {
+ &:hover td {
+ background: #f8fafc;
+ }
+ }
+}
+
+.cctr-drcr {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ padding: 1px 8px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.03em;
+
+ &--dr {
+ background: #eff6ff;
+ color: #1d4ed8;
+ border: 1px solid #bfdbfe;
+ }
+
+ &--cr {
+ background: #f0fdf4;
+ color: #15803d;
+ border: 1px solid #bbf7d0;
+ }
+}
+
+.cctr-contra {
+ color: #64748b;
+ font-style: italic;
+}
+
+.cctr-balanced-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ background: #f0fdf4;
+ color: #15803d;
+ border: 1px solid #bbf7d0;
+ white-space: nowrap;
+}
diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts
index 0399b5d66..65fbf5d25 100644
--- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts
+++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
+import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
interface SummaryRow {
code: string;
@@ -70,6 +71,15 @@ export class CashClearingTransferSummaryComponent {
showDetails = false;
/** GL/channel filter for the Transaction Details view — null means "All". */
glFilter: string | null = null;
+ /** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */
+ generating = false;
+
+ private readonly tabLabels: Record = {
+ cash: 'Cash',
+ transfer: 'Transfer',
+ clearing: 'Clearing',
+ digital: 'Digital'
+ };
/** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */
isHeadOffice = false;
@@ -1481,6 +1491,52 @@ export class CashClearingTransferSummaryComponent {
return this.activeTab === 'digital' ? row.code : row.name;
}
+ /** GL name for the code tracked on this txn row, so the Transaction Details journal can show
+ * "which GL" alongside the account, not just a bare code. */
+ glName(code: string): string {
+ return this.rowsForActiveTab.find((r) => r.code === code)?.name || code;
+ }
+
+ /**
+ * Every transaction here is only ever recorded as ONE leg (whichever GL this Cash/Clearing/
+ * Transfer/Digital screen tracks) — the offsetting leg (the customer's own account, a
+ * settlement/clearing-house account, etc.) was never modelled as a separate GL row. Rather
+ * than leaving that side blank, this derives a sensible label for it so the Transaction
+ * Details view can show the FULL double entry (Dr leg + Cr leg, always equal amounts) —
+ * exactly what a real CBS "Day Book"/Journal Register shows, and what makes it possible to
+ * see at a glance that a transaction is complete and ready for reconciliation.
+ *
+ * For internal cash-movement postings (ATM cash-in-transit, currency chest, denomination
+ * shortages, CTS grid rounding) the account name already describes the SAME leg rather than a
+ * distinct counter-party, so those get a purpose-built contra label. Everything else already
+ * has a descriptive counter-party name (customer, vendor, bank branch, government department)
+ * that IS the natural contra account, so it's used as-is — WITHOUT repeating the account/
+ * reference number, since that number is already shown against the tracked GL leg on the
+ * other line; showing it a second time here would make both lines look like the same account,
+ * defeating the point of a "Balanced" two-sided entry.
+ */
+ contraLabel(t: TxnRow): string {
+ const p = t.particulars.toLowerCase();
+ if (p.includes('atm') && (p.includes('vault') || p.includes('loading') || p.includes('surrendered'))) {
+ return 'ATM Cash-in-Transit Account';
+ }
+ if (p.includes('currency chest')) {
+ return 'Currency Chest Account (Sponsor Bank)';
+ }
+ if (p.includes('denomination') || p.includes('shortage adjustment')) {
+ return 'Cash Difference / Shortage Suspense Account';
+ }
+ if (p.includes('cts grid rounding')) {
+ return 'CTS Grid Rounding Suspense Account';
+ }
+ return t.accountName;
+ }
+
+ /** The single non-zero amount on this leg — always what the derived contra leg mirrors. */
+ legAmount(t: TxnRow): number {
+ return t.debit > 0 ? t.debit : t.credit;
+ }
+
/** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */
rowCredit(row: SummaryRow, txns: TxnRow[]): number {
return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0);
@@ -1566,4 +1622,181 @@ export class CashClearingTransferSummaryComponent {
return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit };
});
}
+
+ /**
+ * Exports the full report for the currently viewed scope (own branch, a drilled-into branch,
+ * or "All Branches" consolidated) — every category (Cash/Transfer/Clearing/Digital), not just
+ * the tab on screen, so the workbook is a complete record. "Summary" mirrors the Opening/
+ * Credit/Debit/Closing view; "Transaction Details" mirrors the full double-entry journal view
+ * (same glName/contraLabel/legAmount derivations, so the export can never disagree with what's
+ * on screen); "Branch Breakup" is added only when Head Office is viewing the consolidated view.
+ */
+ async exportExcel(): Promise {
+ this.generating = true;
+ try {
+ const summaryRows: (string | number | null)[][] = [];
+ const journalRows: (string | number | null)[][] = [];
+
+ for (const tab of this.allTabs) {
+ const label = this.tabLabels[tab];
+ const rows = this.effectiveRows(tab);
+ const txns = this.effectiveTxns(tab);
+
+ rows.forEach((r) => {
+ summaryRows.push([
+ label,
+ r.code,
+ r.name,
+ r.opening,
+ this.rowCredit(r, txns),
+ this.rowDebit(r, txns),
+ this.closing(r, txns)
+ ]);
+ });
+ summaryRows.push([
+ `${label} Total`,
+ '',
+ '',
+ this.total(rows, txns, 'opening'),
+ this.total(rows, txns, 'credit'),
+ this.total(rows, txns, 'debit'),
+ this.totalClosing(rows, txns)
+ ]);
+
+ txns.forEach((t) => {
+ const glText = rows.find((r) => r.code === t.glCode)?.name || t.glCode;
+ journalRows.push([
+ label,
+ this.asOnDate,
+ this.display(t.voucherNo),
+ 'Dr',
+ t.debit > 0 ? t.glCode : '',
+ t.debit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t),
+ this.legAmount(t),
+ null,
+ 'Balanced'
+ ]);
+ journalRows.push([
+ label,
+ this.asOnDate,
+ this.display(t.voucherNo),
+ 'Cr',
+ t.credit > 0 ? t.glCode : '',
+ t.credit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t),
+ null,
+ this.legAmount(t),
+ 'Balanced'
+ ]);
+ });
+ }
+ summaryRows.push([
+ 'Grand Total',
+ '',
+ '',
+ this.grandOpening,
+ this.grandCredit,
+ this.grandDebit,
+ this.grandClosing
+ ]);
+
+ const sheets: ExportSheetSpec[] = [
+ {
+ sheetName: 'Summary',
+ title: 'Cash / Clearing / Transfer - Summary Report',
+ subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`,
+ headers: [
+ 'Category',
+ 'GL Code',
+ 'Particulars',
+ 'Opening',
+ 'Credit',
+ 'Debit',
+ 'Closing'
+ ],
+ widths: [
+ 12,
+ 12,
+ 34,
+ 16,
+ 16,
+ 16,
+ 16
+ ],
+ rows: summaryRows
+ },
+ {
+ sheetName: 'Transaction Details',
+ title: 'Transaction Details — Full Double Entry',
+ subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`,
+ headers: [
+ 'Category',
+ 'Date',
+ 'Voucher / Ref No',
+ 'Dr / Cr',
+ 'GL Code',
+ 'GL / Account',
+ 'Debit',
+ 'Credit',
+ 'Status'
+ ],
+ widths: [
+ 12,
+ 12,
+ 20,
+ 8,
+ 10,
+ 46,
+ 16,
+ 16,
+ 12
+ ],
+ rows: journalRows
+ }
+ ];
+
+ if (this.isHeadOffice && this.selectedBranchCode === 'ALL') {
+ sheets.push({
+ sheetName: 'Branch Breakup',
+ title: 'Branch-wise Break-up',
+ subtitle: `As On: ${this.asOnDate}`,
+ headers: [
+ 'Branch',
+ 'Opening',
+ 'Credit',
+ 'Debit',
+ 'Closing'
+ ],
+ widths: [
+ 22,
+ 16,
+ 16,
+ 16,
+ 16
+ ],
+ rows: [
+ ...this.branchBreakup.map((b) => [
+ b.name,
+ b.opening,
+ b.credit,
+ b.debit,
+ b.closing
+ ]),
+ [
+ 'All Branches',
+ this.grandOpening,
+ this.grandCredit,
+ this.grandDebit,
+ this.grandClosing
+ ]
+ ]
+ });
+ }
+
+ await exportWorkbook(sheets, `Cash-Clearing-Transfer-Summary-${this.scopeName}-${this.asOnDate}`);
+ } catch (err) {
+ console.error('Cash/Clearing/Transfer export failed', err);
+ } finally {
+ this.generating = false;
+ }
+ }
}
diff --git a/src/app/cashier/cashier-export.ts b/src/app/cashier/cashier-export.ts
new file mode 100644
index 000000000..813e33c2b
--- /dev/null
+++ b/src/app/cashier/cashier-export.ts
@@ -0,0 +1,88 @@
+/**
+ * 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/.
+ */
+
+// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
+// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
+// names, Blob download) is written once and every cashier report's export behaves the same way.
+
+import * as ExcelJS from 'exceljs';
+
+export interface ExportSheetSpec {
+ /** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
+ sheetName: string;
+ /** Bold title row, merged across all columns. */
+ title: string;
+ /** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
+ subtitle?: string;
+ headers: string[];
+ rows: (string | number | null)[][];
+ widths?: number[];
+}
+
+/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
+function safeSheetName(name: string): string {
+ return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
+}
+
+function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
+ const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
+ sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
+
+ const title = sheet.addRow([spec.title]);
+ sheet.mergeCells(title.number, 1, title.number, spec.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: 'FF0F4F3C' } };
+
+ if (spec.subtitle) {
+ const meta = sheet.addRow([spec.subtitle]);
+ sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
+ meta.font = { italic: true, size: 10 };
+ }
+ sheet.addRow([]);
+
+ const head = sheet.addRow(spec.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 spec.rows) {
+ const row = sheet.addRow(r);
+ r.forEach((v, idx) => {
+ if (typeof v === 'number') {
+ row.getCell(idx + 1).numFmt = '#,##0.00';
+ }
+ });
+ }
+ sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
+}
+
+/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
+export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise {
+ const wb = new ExcelJS.Workbook();
+ wb.creator = 'CBSNexora';
+ wb.created = new Date();
+ sheets.forEach((s) => buildSheet(wb, s));
+
+ 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 = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
From 90beea64cbcdc8a37ac81d934554f9a6542c4d5d Mon Sep 17 00:00:00 2001
From: Nitesh Yadav
Date: Sat, 1 Aug 2026 20:58:42 +0530
Subject: [PATCH 5/7] feat(npa): add Export to Excel across all 9 NPA reports
Every report screen (Interest, Status, Recovery, Summary, Account Detail,
Movement, Provision, Probable NPA, SMA) now has an Export to Excel button
that builds a workbook from the same derived getters the on-screen tables
use, reusing the shared cashier-export.ts builder.
---
.../npa-reports/npa-reports.component.html | 3 +
.../npa/npa-reports/npa-reports.component.ts | 596 ++++++++++++++++++
2 files changed, 599 insertions(+)
diff --git a/src/app/npa/npa-reports/npa-reports.component.html b/src/app/npa/npa-reports/npa-reports.component.html
index 0c75cb956..2b6e76c22 100644
--- a/src/app/npa/npa-reports/npa-reports.component.html
+++ b/src/app/npa/npa-reports/npa-reports.component.html
@@ -38,6 +38,9 @@
Generate
+
+ {{ generating ? 'Exporting…' : 'Export to Excel' }}
+
diff --git a/src/app/npa/npa-reports/npa-reports.component.ts b/src/app/npa/npa-reports/npa-reports.component.ts
index 5d6eb24e5..887737dda 100644
--- a/src/app/npa/npa-reports/npa-reports.component.ts
+++ b/src/app/npa/npa-reports/npa-reports.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
+import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import {
LOAN_ACCOUNTS,
BRANCHES,
@@ -127,6 +128,9 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
mode: 'Cash Deposit'
};
+ /** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */
+ generating = false;
+
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType | undefined;
@@ -479,6 +483,598 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
this.toast(`${this.reportLabel(this.activeReport)} generated as on ${this.asOnDate}.`);
}
+ // -------------------- Export to Excel (whichever report is currently on screen) --------------------
+ async exportExcel(): Promise {
+ this.generating = true;
+ try {
+ const sheets = this.buildExportSheets();
+ await exportWorkbook(sheets, `${this.reportLabel(this.activeReport)}-${this.scopeName}-${this.asOnDate}`);
+ } catch (err) {
+ console.error('NPA export failed', err);
+ } finally {
+ this.generating = false;
+ }
+ }
+
+ /** Builds the workbook for whichever report is active — every sheet is built from the same
+ * derived getters the on-screen tables use, so the export can never disagree with the screen. */
+ private buildExportSheets(): ExportSheetSpec[] {
+ const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
+
+ switch (this.activeReport) {
+ case 'interest':
+ return [
+ {
+ sheetName: 'NPA Interest',
+ title: 'NPA Interest — Income Reversed on Non-Performing Accounts',
+ subtitle,
+ headers: [
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Product',
+ 'Stage',
+ 'Outstanding',
+ 'Rate %',
+ 'Overdue Days',
+ 'Interest Reversed'
+ ],
+ widths: [
+ 16,
+ 22,
+ 16,
+ 16,
+ 14,
+ 16,
+ 10,
+ 12,
+ 18
+ ],
+ rows: [
+ ...this.npaAccounts.map((a) => [
+ a.accountNo,
+ a.customerName,
+ a.branchName,
+ a.productName,
+ this.stageLabel(this.stage(a)),
+ a.outstandingBalance,
+ a.interestRate,
+ a.overdueDays,
+ this.interestRev(a)
+ ]),
+ [
+ `Total (${this.npaAccounts.length} accounts)`,
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.sumInterest(this.npaAccounts)
+ ]
+ ]
+ }
+ ];
+
+ case 'status':
+ return [
+ {
+ sheetName: 'NPA Status',
+ title: 'NPA Status Report — Full Non-Performing Book',
+ subtitle,
+ headers: [
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Product',
+ 'Secured',
+ 'Outstanding',
+ 'Overdue Amount',
+ 'Overdue Days',
+ 'NPA Since',
+ 'Stage',
+ 'Restructured',
+ 'Legal Action',
+ 'Provision Required'
+ ],
+ widths: [
+ 16,
+ 22,
+ 16,
+ 16,
+ 10,
+ 16,
+ 16,
+ 12,
+ 12,
+ 14,
+ 12,
+ 22,
+ 18
+ ],
+ rows: this.npaAccounts.map((a) => [
+ a.accountNo,
+ a.customerName,
+ a.branchName,
+ a.productName,
+ a.secured ? 'Yes' : 'No',
+ a.outstandingBalance,
+ a.overdueAmount,
+ a.overdueDays,
+ this.npaSince(a),
+ this.stageLabel(this.stage(a)),
+ a.isRestructured ? 'Yes' : 'No',
+ this.legalActionLabel(a.legalActionStatus),
+ this.provisionReq(a)
+ ])
+ }
+ ];
+
+ case 'recovery':
+ return [
+ {
+ sheetName: 'NPA Recovery',
+ title: 'NPA Recovery Register',
+ subtitle,
+ headers: [
+ 'Date',
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Mode',
+ 'Recovered Amount'
+ ],
+ widths: [
+ 12,
+ 16,
+ 22,
+ 16,
+ 18,
+ 18
+ ],
+ rows: [
+ ...this.recoveryRows.map((r) => [
+ r.date,
+ r.accountNo,
+ r.customerName,
+ r.branchName,
+ r.mode,
+ r.amount
+ ]),
+ [
+ 'Total Recovered',
+ '',
+ '',
+ '',
+ '',
+ this.totalRecoveredAmount
+ ]
+ ]
+ }
+ ];
+
+ case 'summary': {
+ const sheets: ExportSheetSpec[] = [
+ {
+ sheetName: 'Stage Summary',
+ title: 'NPA Summary Report — Stage-wise',
+ subtitle,
+ headers: [
+ 'Classification',
+ 'Accounts',
+ 'Outstanding',
+ 'Provision Required'
+ ],
+ widths: [
+ 22,
+ 12,
+ 18,
+ 18
+ ],
+ rows: [
+ ...this.stageSummary.map((row) => [
+ row.label,
+ row.count,
+ row.outstanding,
+ row.provisionReq
+ ]),
+ [
+ 'Total',
+ this.grandTotalAccounts,
+ this.grandTotalOutstanding,
+ this.grandTotalProvision
+ ]
+ ]
+ }
+ ];
+ if (this.isHeadOffice && !this.branchFilter) {
+ sheets.push({
+ sheetName: 'Branch Summary',
+ title: 'Branch-wise NPA Summary — All Branches',
+ subtitle: `As On: ${this.asOnDate}`,
+ headers: [
+ 'Branch',
+ 'Total Accounts',
+ 'Total Advances',
+ 'NPA Accounts',
+ 'GNPA Outstanding',
+ 'Provision Required',
+ 'GNPA %'
+ ],
+ widths: [
+ 20,
+ 14,
+ 18,
+ 14,
+ 18,
+ 18,
+ 10
+ ],
+ rows: this.branchSummary.map((row) => [
+ row.branchName,
+ row.totalAccounts,
+ row.totalAdvances,
+ row.npaCount,
+ row.npaOutstanding,
+ row.provisionReq,
+ Number(row.gnpaRatio.toFixed(2))
+ ])
+ });
+ }
+ return sheets;
+ }
+
+ case 'account-detail': {
+ const a = this.selectedAccount;
+ const detailRows: (string | number | null)[][] = a
+ ? [
+ [
+ 'Account No',
+ a.accountNo
+ ],
+ [
+ 'Customer',
+ a.customerName
+ ],
+ [
+ 'Branch',
+ a.branchName
+ ],
+ [
+ 'Product',
+ a.productName
+ ],
+ [
+ 'Secured',
+ a.secured ? 'Yes' : 'No'
+ ],
+ [
+ 'Guarantor',
+ a.guarantorName
+ ],
+ [
+ 'Sanctioned Amount',
+ a.sanctionedAmount
+ ],
+ [
+ 'Disbursement Date',
+ a.disbursementDate
+ ],
+ [
+ 'Interest Rate %',
+ a.interestRate
+ ],
+ [
+ 'Outstanding Balance',
+ a.outstandingBalance
+ ],
+ [
+ 'Overdue Amount',
+ a.overdueAmount
+ ],
+ [
+ 'Overdue Days',
+ a.overdueDays
+ ],
+ [
+ 'NPA Since',
+ this.npaSince(a)
+ ],
+ [
+ 'Restructured',
+ a.isRestructured ? 'Yes' : 'No'
+ ],
+ [
+ 'Last Payment Date',
+ a.lastPaymentDate
+ ],
+ [
+ 'Collateral Value',
+ a.collateralValue
+ ],
+ [
+ 'Stage',
+ this.stageLabel(this.stage(a))
+ ],
+ [
+ 'Provisioning Rate %',
+ this.provisionRate(a)
+ ],
+ [
+ 'Provision Required',
+ this.provisionReq(a)
+ ],
+ [
+ 'Provision Held',
+ this.provisionHeldAmt(a)
+ ],
+ [
+ 'Interest Reversed',
+ this.interestRev(a)
+ ],
+ [
+ 'Total Recovered',
+ this.recovered(a)
+ ],
+ [
+ 'Movement (vs Last Quarter)',
+ this.movement(a)
+ ],
+ [
+ 'NPA Reason',
+ a.npaReason
+ ],
+ [
+ 'Recovery Officer',
+ a.recoveryOfficer
+ ],
+ [
+ 'Legal Action Status',
+ this.legalActionLabel(a.legalActionStatus)
+ ]
+ ]
+ : [];
+ return [
+ {
+ sheetName: 'Account Detail',
+ title: a ? `${a.accountNo} — ${a.customerName}` : 'NPA Account Detail Report',
+ subtitle,
+ headers: [
+ 'Field',
+ 'Value'
+ ],
+ widths: [
+ 26,
+ 32
+ ],
+ rows: detailRows
+ },
+ {
+ sheetName: 'Recovery History',
+ title: a ? `Recovery History — ${a.accountNo}` : 'Recovery History',
+ subtitle,
+ headers: [
+ 'Date',
+ 'Mode',
+ 'Amount'
+ ],
+ widths: [
+ 14,
+ 20,
+ 18
+ ],
+ rows: (a?.recoveries || []).map((r) => [
+ r.date,
+ r.mode,
+ r.amount
+ ])
+ }
+ ];
+ }
+
+ case 'movement':
+ return [
+ {
+ sheetName: 'NPA Movement',
+ title: 'Classification Movement — Current Quarter vs Previous Quarter',
+ subtitle,
+ headers: [
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Previous Stage',
+ 'Current Stage',
+ 'Movement',
+ 'Outstanding'
+ ],
+ widths: [
+ 16,
+ 22,
+ 16,
+ 16,
+ 16,
+ 26,
+ 16
+ ],
+ rows: this.movedAccounts.map((a) => [
+ a.accountNo,
+ a.customerName,
+ a.branchName,
+ this.stageLabel(this.prevStage(a)),
+ this.stageLabel(this.stage(a)),
+ this.movement(a),
+ a.outstandingBalance
+ ])
+ }
+ ];
+
+ case 'provision':
+ return [
+ {
+ sheetName: 'NPA Provision',
+ title: 'Provision Held vs Required — Full Loan Book',
+ subtitle,
+ headers: [
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Stage',
+ 'Outstanding',
+ 'Rate %',
+ 'Provision Required',
+ 'Provision Held',
+ 'Shortfall / (Excess)'
+ ],
+ widths: [
+ 16,
+ 22,
+ 16,
+ 14,
+ 16,
+ 10,
+ 18,
+ 16,
+ 18
+ ],
+ rows: [
+ ...this.provisionRows.map((a) => [
+ a.accountNo,
+ a.customerName,
+ a.branchName,
+ this.stageLabel(this.stage(a)),
+ a.outstandingBalance,
+ this.provisionRate(a),
+ this.provisionReq(a),
+ this.provisionHeldAmt(a),
+ this.shortfall(a)
+ ]),
+ [
+ `Total (${this.provisionRows.length} accounts)`,
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.provisionRequiredTotal,
+ this.provisionHeldTotal,
+ this.provisionShortfallTotal
+ ]
+ ]
+ }
+ ];
+
+ case 'probable':
+ return [
+ {
+ sheetName: 'Probable NPA',
+ title: 'Probable NPA — SMA-2 Accounts Likely to Slip Next Quarter',
+ subtitle,
+ headers: [
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Product',
+ 'Outstanding',
+ 'Overdue Amount',
+ 'Overdue Days',
+ 'Last Payment',
+ 'Follow-up'
+ ],
+ widths: [
+ 16,
+ 22,
+ 16,
+ 16,
+ 16,
+ 16,
+ 12,
+ 14,
+ 12
+ ],
+ rows: [
+ ...this.probableNpaAccounts.map((a) => [
+ a.accountNo,
+ a.customerName,
+ a.branchName,
+ a.productName,
+ a.outstandingBalance,
+ a.overdueAmount,
+ a.overdueDays,
+ a.lastPaymentDate,
+ a.overdueDays > 75 ? 'Urgent' : 'Monitor'
+ ]),
+ [
+ `Total (${this.probableNpaAccounts.length} accounts)`,
+ '',
+ '',
+ '',
+ this.sumOutstanding(this.probableNpaAccounts),
+ '',
+ '',
+ '',
+ ''
+ ]
+ ]
+ }
+ ];
+
+ case 'sma':
+ return [
+ {
+ sheetName: 'SMA Report',
+ title: 'Special Mention Accounts — SMA-0 / SMA-1 / SMA-2',
+ subtitle,
+ headers: [
+ 'Account No',
+ 'Customer',
+ 'Branch',
+ 'Product',
+ 'Stage',
+ 'Outstanding',
+ 'Overdue Amount',
+ 'Overdue Days'
+ ],
+ widths: [
+ 16,
+ 22,
+ 16,
+ 16,
+ 14,
+ 16,
+ 16,
+ 12
+ ],
+ rows: [
+ ...this.smaAccounts.map((a) => [
+ a.accountNo,
+ a.customerName,
+ a.branchName,
+ a.productName,
+ this.stageLabel(this.stage(a)),
+ a.outstandingBalance,
+ a.overdueAmount,
+ a.overdueDays
+ ]),
+ [
+ `Total (${this.smaAccounts.length} accounts)`,
+ '',
+ '',
+ '',
+ '',
+ this.sumOutstanding(this.smaAccounts),
+ this.sumOverdue(this.smaAccounts),
+ ''
+ ]
+ ]
+ }
+ ];
+
+ default:
+ return [];
+ }
+ }
+
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
From 27cdaf7fc2337cea5d49d18c93d36e6f451ea10c Mon Sep 17 00:00:00 2001
From: Nitesh Yadav
Date: Sun, 2 Aug 2026 08:34:03 +0530
Subject: [PATCH 6/7] feat(npa): add Undebited Interest Recovery Report, fix
note text width
Adds a 10th NPA report tracking memorandum "undebited interest" per IRAC
norms (accrued vs cash-recovered vs balance), wired into Export to Excel
and the NPA mega-menu. Also fixes a narrow-width text wrap on the report's
description note (new .npa-report-note class overriding pg-sub's 560px cap).
---
src/app/core/shell/shell.component.html | 3 +
src/app/npa/npa-data.ts | 42 +++++++--
.../npa-reports/npa-reports.component.html | 94 ++++++++++++++++++-
.../npa/npa-reports/npa-reports.component.ts | 87 +++++++++++++++++
src/app/npa/npa-shared.scss | 4 +
5 files changed, 220 insertions(+), 10 deletions(-)
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 3643f5d81..58b9f1fc0 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -1264,6 +1264,9 @@
NPA with Provision and Required Provision
+
+ Undebited Interest Recovery Report
+
diff --git a/src/app/npa/npa-data.ts b/src/app/npa/npa-data.ts
index f3e191569..56fb6592d 100644
--- a/src/app/npa/npa-data.ts
+++ b/src/app/npa/npa-data.ts
@@ -553,14 +553,23 @@ export function provisionHeld(a: LoanAccount): number {
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
}
-/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
-export function interestReversed(a: LoanAccount): number {
- const stage = currentStage(a);
- if (!isNpaStage(stage)) {
+/** Days elapsed since the account first crossed 90 days overdue (i.e. became NPA) — 0 for any
+ * account that isn't currently NPA. The single source both interest-reversal and NPA-since-date
+ * are built from, so neither can ever quietly drift out of step with the other. */
+export function daysSinceNpa(a: LoanAccount): number {
+ if (!isNpaStage(currentStage(a))) {
return 0;
}
- const daysSinceNpa = Math.max(0, a.overdueDays - 90);
- return round(a.outstandingBalance * (a.interestRate / 100) * (daysSinceNpa / 365));
+ return Math.max(0, a.overdueDays - 90);
+}
+
+/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
+export function interestReversed(a: LoanAccount): number {
+ const days = daysSinceNpa(a);
+ if (days <= 0) {
+ return 0;
+ }
+ return round(a.outstandingBalance * (a.interestRate / 100) * (days / 365));
}
/** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
@@ -569,14 +578,30 @@ export function npaSinceDate(a: LoanAccount): string {
if (!isNpaStage(currentStage(a))) {
return '—';
}
- const daysSinceNpa = Math.max(0, a.overdueDays - 90);
- return fmt(addDays(new Date(), -daysSinceNpa));
+ return fmt(addDays(new Date(), -daysSinceNpa(a)));
}
export function totalRecovered(a: LoanAccount): number {
return a.recoveries.reduce((s, r) => s + r.amount, 0);
}
+/**
+ * Cash recovered against an NPA account is appropriated to the memorandum "undebited interest"
+ * bucket first — consistent with IRAC norms recognising NPA interest income only on actual cash
+ * receipt, never on accrual — and only the balance beyond that reduces principal. Capped at the
+ * interest actually accrued, so this can never show more interest "recovered" than ever accrued.
+ */
+export function interestRecoveredCash(a: LoanAccount): number {
+ return Math.min(totalRecovered(a), interestReversed(a));
+}
+
+/** Memorandum interest still un-recovered — the "undebited interest" carried outside the
+ * borrower's loan account (per IRAC norms), less whatever cash has actually been appropriated
+ * against it so far. */
+export function undebitedInterestBalance(a: LoanAccount): number {
+ return interestReversed(a) - interestRecoveredCash(a);
+}
+
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
if (prev === curr) {
return 'No Change';
@@ -608,6 +633,7 @@ export const REPORT_META: ReportMeta[] = [
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
+ { id: 'undebited-interest', group: 'analysis', label: 'Undebited Interest Recovery Report' },
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
{ id: 'sma', group: 'classification', label: 'SMA Report' }
];
diff --git a/src/app/npa/npa-reports/npa-reports.component.html b/src/app/npa/npa-reports/npa-reports.component.html
index 2b6e76c22..e342273ae 100644
--- a/src/app/npa/npa-reports/npa-reports.component.html
+++ b/src/app/npa/npa-reports/npa-reports.component.html
@@ -784,7 +784,97 @@
+ Undebited Interest Recovery Report — Memorandum Interest Held Outside the Loan Account (IRAC Norms)
+
+
+ Once an account turns NPA, interest is no longer debited to the borrower's loan account or recognised as income
+ — it is tracked here as memorandum "undebited interest" and recognised only when actually recovered in cash.
+ Cash recovered is appropriated to this bucket first; any balance beyond it reduces principal.
+
+
+
+
+
+
Account No
+
Customer
+
Branch
+
Stage
+
NPA Since
+
Days NPA
+
Rate
+
Undebited Interest Accrued
+
Interest Recovered (Cash)
+
Undebited Interest Balance
+
+
+
+ @for (a of paged(npaAccounts); track a.accountNo) {
+
+ Every loan is checked for "is this cycle's dues fully recovered" the way it's actually due — a Term Loan (EMI)
+ is checked against its instalment, an Overdrawn (CC/OD/KCC) account against its drawing power and interest
+ servicing, and a Bullet loan (Gold) is checked once past maturity.
+
+
+ @for (row of recoveryTypeSummary; track row.type) {
+
+ Exception Check — Security Value vs Loan ({{ exceptionAccounts.length }} accounts undersecured)
+
+
+ Every secured loan's current security market value is expected to stay above its outstanding balance. This lists
+ the exceptions — where it doesn't — so recovery/credit can review before the next classification review.
+
+
+
+
+
+
Account No
+
Customer
+
Branch
+
Product
+
Stage
+
Outstanding
+
Market Value
+
Shortfall
+
Coverage %
+
+
+
+ @for (a of paged(exceptionAccounts); track a.accountNo) {
+