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:
2026-08-02 08:34:03 +05:30
parent 90beea64cb
commit 27cdaf7fc2
5 changed files with 220 additions and 10 deletions
+34 -8
View File
@@ -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' }
];