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).
This commit is contained in:
@@ -1264,6 +1264,9 @@
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- NPA Classification -->
|
||||
<div class="deposit-menu-group">
|
||||
|
||||
+34
-8
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -784,7 +784,97 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 08 PROBABLE NPA ===================== -->
|
||||
<!-- ===================== 08 UNDEBITED INTEREST RECOVERY REPORT ===================== -->
|
||||
@if (activeReport === 'undebited-interest') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
Undebited Interest Recovery Report — Memorandum Interest Held Outside the Loan Account (IRAC Norms)
|
||||
</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
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.
|
||||
</p>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Stage</th>
|
||||
<th>NPA Since</th>
|
||||
<th class="num">Days NPA</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num col-out">Undebited Interest Accrued</th>
|
||||
<th class="num col-in">Interest Recovered (Cash)</th>
|
||||
<th class="num">Undebited Interest Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(npaAccounts); track a.accountNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||
</td>
|
||||
<td>{{ a.customerName }}</td>
|
||||
<td>{{ a.branchName }}</td>
|
||||
<td>
|
||||
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
||||
</td>
|
||||
<td>{{ npaSince(a) }}</td>
|
||||
<td class="num">{{ daysSinceNpaOf(a) }}</td>
|
||||
<td class="num">{{ a.interestRate }}%</td>
|
||||
<td class="num col-out">₹{{ interestRev(a) | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ interestRecoveredCashOf(a) | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ undebitedInterestBalanceOf(a) | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="pg-empty">No NPA accounts in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="7">Total ({{ npaAccounts.length }} accounts)</td>
|
||||
<td class="num">₹{{ undebitedInterestAccruedTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ interestRecoveredCashTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ undebitedInterestBalanceTotal | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@if (npaAccounts.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, npaAccounts)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(npaAccounts) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(npaAccounts)"
|
||||
(click)="goToPage(currentPage + 1, npaAccounts)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 09 PROBABLE NPA ===================== -->
|
||||
@if (activeReport === 'probable') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
@@ -871,7 +961,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== 09 SMA REPORT ===================== -->
|
||||
<!-- ===================== 10 SMA REPORT ===================== -->
|
||||
@if (activeReport === 'sma') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div>
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
movementLabel,
|
||||
stageBadgeClass,
|
||||
npaSinceDate,
|
||||
daysSinceNpa,
|
||||
interestRecoveredCash,
|
||||
undebitedInterestBalance,
|
||||
todayDDMMYYYY,
|
||||
LegalActionStatus,
|
||||
LEGAL_ACTION_LABELS
|
||||
@@ -268,6 +271,18 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
return npaSinceDate(a);
|
||||
}
|
||||
|
||||
daysSinceNpaOf(a: LoanAccount): number {
|
||||
return daysSinceNpa(a);
|
||||
}
|
||||
|
||||
interestRecoveredCashOf(a: LoanAccount): number {
|
||||
return interestRecoveredCash(a);
|
||||
}
|
||||
|
||||
undebitedInterestBalanceOf(a: LoanAccount): number {
|
||||
return undebitedInterestBalance(a);
|
||||
}
|
||||
|
||||
legalActionLabel(status: LegalActionStatus): string {
|
||||
return LEGAL_ACTION_LABELS[status];
|
||||
}
|
||||
@@ -359,6 +374,19 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
return this.npaAccounts.reduce((s, a) => s + this.recovered(a), 0);
|
||||
}
|
||||
|
||||
// -------------------- Undebited Interest Recovery Report --------------------
|
||||
get undebitedInterestAccruedTotal(): number {
|
||||
return this.npaAccounts.reduce((s, a) => s + this.interestRev(a), 0);
|
||||
}
|
||||
|
||||
get interestRecoveredCashTotal(): number {
|
||||
return this.npaAccounts.reduce((s, a) => s + this.interestRecoveredCashOf(a), 0);
|
||||
}
|
||||
|
||||
get undebitedInterestBalanceTotal(): number {
|
||||
return this.npaAccounts.reduce((s, a) => s + this.undebitedInterestBalanceOf(a), 0);
|
||||
}
|
||||
|
||||
get restructuredAccounts(): LoanAccount[] {
|
||||
return this.allAccounts.filter((a) => a.isRestructured);
|
||||
}
|
||||
@@ -964,6 +992,65 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
];
|
||||
|
||||
case 'undebited-interest':
|
||||
return [
|
||||
{
|
||||
sheetName: 'Undebited Interest',
|
||||
title: 'Undebited Interest Recovery Report — Memorandum Interest (IRAC Norms)',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Stage',
|
||||
'NPA Since',
|
||||
'Days NPA',
|
||||
'Rate %',
|
||||
'Undebited Interest Accrued',
|
||||
'Interest Recovered (Cash)',
|
||||
'Undebited Interest Balance'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
14,
|
||||
12,
|
||||
10,
|
||||
10,
|
||||
22,
|
||||
20,
|
||||
22
|
||||
],
|
||||
rows: [
|
||||
...this.npaAccounts.map((a) => [
|
||||
a.accountNo,
|
||||
a.customerName,
|
||||
a.branchName,
|
||||
this.stageLabel(this.stage(a)),
|
||||
this.npaSince(a),
|
||||
this.daysSinceNpaOf(a),
|
||||
a.interestRate,
|
||||
this.interestRev(a),
|
||||
this.interestRecoveredCashOf(a),
|
||||
this.undebitedInterestBalanceOf(a)
|
||||
]),
|
||||
[
|
||||
`Total (${this.npaAccounts.length} accounts)`,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.undebitedInterestAccruedTotal,
|
||||
this.interestRecoveredCashTotal,
|
||||
this.undebitedInterestBalanceTotal
|
||||
]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
case 'probable':
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since
|
||||
// they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
|
||||
|
||||
.npa-report-note {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.npa-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user