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 @@ +
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 @@
} - + + @if (activeReport === 'undebited-interest') { +
+
+ 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. +

+
+ + + + + + + + + + + + + + + + + @for (a of paged(npaAccounts); track a.accountNo) { + + + + + + + + + + + + + } @empty { + + + + } + + + + + + + + + +
Account NoCustomerBranchStageNPA SinceDays NPARateUndebited Interest AccruedInterest Recovered (Cash)Undebited Interest Balance
+ {{ a.accountNo }} + {{ a.customerName }}{{ a.branchName }} + {{ stageLabel(stage(a)) }} + {{ npaSince(a) }}{{ daysSinceNpaOf(a) }}{{ a.interestRate }}%₹{{ interestRev(a) | number: '1.0-0' }}₹{{ interestRecoveredCashOf(a) | number: '1.0-0' }}₹{{ undebitedInterestBalanceOf(a) | number: '1.0-0' }}
No NPA accounts in this branch.
Total ({{ npaAccounts.length }} accounts)₹{{ undebitedInterestAccruedTotal | number: '1.0-0' }}₹{{ interestRecoveredCashTotal | number: '1.0-0' }}₹{{ undebitedInterestBalanceTotal | number: '1.0-0' }}
+
+ @if (npaAccounts.length > pageSize) { +
+ Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }} +
+ + Page {{ currentPage }} of {{ pageCount(npaAccounts) }} + +
+
+ } +
+ } + + @if (activeReport === 'probable') {
@@ -871,7 +961,7 @@
} - + @if (activeReport === 'sma') {
diff --git a/src/app/npa/npa-reports/npa-reports.component.ts b/src/app/npa/npa-reports/npa-reports.component.ts index 887737dda..5f0402336 100644 --- a/src/app/npa/npa-reports/npa-reports.component.ts +++ b/src/app/npa/npa-reports/npa-reports.component.ts @@ -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 [ { diff --git a/src/app/npa/npa-shared.scss b/src/app/npa/npa-shared.scss index 64e3d6cfe..9a911491b 100644 --- a/src/app/npa/npa-shared.scss +++ b/src/app/npa/npa-shared.scss @@ -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;