feat(npa): add Recovery Management System reports + Recovery Journey popup

Adds loan-type-aware recovery tracking (EMI/Overdrawn/Bullet) with a Recovery
Management Dashboard, Day-wise Overdue/Overdrawn reports, an Agriculture/Gold
market-value report, and a security-value exception check — all derived from
new repaymentType/emiAmount/drawingPower/maturityDate/collateralMarketValue
fields on LoanAccount, wired into Export to Excel and the NPA mega-menu.

Also replaces the always-visible Recovery/Collection Journey card on the
Account Detail Report with a "Recovery Journey" popup: a date-led, icon-coded
timeline of every SMS/call/visit/notice and payment run against an account.
This commit is contained in:
2026-08-02 11:33:01 +05:30
parent 27cdaf7fc2
commit b4608cbbe4
6 changed files with 1737 additions and 23 deletions
+349 -13
View File
@@ -79,6 +79,26 @@ export interface RecoveryTxn {
mode: string;
}
/** One entry in an account's collection/recovery journey — the real-world touchpoints a CBS
* Recovery Management System logs against an overdue loan, in the order they actually happened. */
export type RecoveryEventType =
| 'SMS Reminder'
| 'IVR / Auto Call'
| 'Tele-calling'
| 'Overdue Call'
| 'Field Visit'
| 'Formal Overdue Letter'
| 'Recovery Agent Assigned'
| 'Demand Notice'
| 'Payment Received';
export interface RecoveryEvent {
date: string;
type: RecoveryEventType;
channel: string;
remarks: string;
}
export interface LoanAccount {
accountNo: string;
customerName: string;
@@ -88,6 +108,7 @@ export interface LoanAccount {
productCode: string;
productName: string;
secured: boolean;
repaymentType: RepaymentType;
sanctionedAmount: number;
disbursementDate: string;
interestRate: number;
@@ -96,11 +117,27 @@ export interface LoanAccount {
overdueDays: number;
overdueDaysQuarterAgo: number;
collateralValue: number;
/** Current estimated market value of the pledged security — can run above or below
* `collateralValue` (the value recorded at sanction/last valuation), which is exactly what the
* Exception Check / Agriculture-Gold Market Value reports exist to catch. 0 for unsecured loans. */
collateralMarketValue: number;
/** Fixed EMI for an EMI-repayment loan (standard reducing-balance formula on the sanctioned
* amount and tenure) — 0 for OVERDRAWN/BULLET loans, which aren't due on a fixed instalment. */
emiAmount: number;
/** Drawing limit for an OVERDRAWN (CC/OD/KCC) account — kept below the sanctioned amount by a
* margin, same as a real cash-credit facility. 0 for EMI/BULLET loans. */
drawingPower: number;
/** Bullet-repayment due date (principal falls due in full on this date) — '—' for EMI/OVERDRAWN
* loans, which don't have a single bullet maturity. */
maturityDate: string;
lastPaymentDate: string;
isLossAsset: boolean;
isRestructured: boolean;
provisionCoverageRatio: number;
recoveries: RecoveryTxn[];
/** Chronological log of collection touchpoints (SMS/call/visit/notice) plus actual recoveries —
* empty for an account that has never gone overdue. */
collectionTimeline: RecoveryEvent[];
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
npaReason: string;
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
@@ -174,6 +211,17 @@ export const BRANCHES: BranchMaster[] = [
{ code: 'BR08', name: 'Kolhapur Branch' }
];
/** How the loan is contractually repaid — drives the Recovery Management System's per-type
* overdue check (EMI shortfall / drawing-power breach / bullet-maturity lapse) and the
* Expected-vs-Actual Recovery figures, since each type is "due" on a different basis. */
export type RepaymentType = 'EMI' | 'OVERDRAWN' | 'BULLET';
export const REPAYMENT_TYPE_LABELS: Record<RepaymentType, string> = {
EMI: 'Term Loan (EMI)',
OVERDRAWN: 'Overdrawn (CC / OD)',
BULLET: 'Bullet Repayment'
};
interface ProductMaster {
code: string;
name: string;
@@ -182,6 +230,11 @@ interface ProductMaster {
maxAmt: number;
minRate: number;
maxRate: number;
repaymentType: RepaymentType;
/** Sanctioned tenure in months — drives the EMI formula for EMI-type products and the
* bullet maturity date for BULLET-type products. Renewal cycle (not a repayment schedule)
* for OVERDRAWN/revolving products. */
tenureMonths: number;
}
export const LOAN_PRODUCTS: ProductMaster[] = [
{
@@ -191,15 +244,87 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 500000,
maxAmt: 5000000,
minRate: 11,
maxRate: 13.5
maxRate: 13.5,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
},
{
code: '02',
name: 'Term Loan - MSME',
secured: true,
minAmt: 300000,
maxAmt: 3000000,
minRate: 11,
maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 60
},
{
code: '03',
name: 'Housing Loan',
secured: true,
minAmt: 1000000,
maxAmt: 4000000,
minRate: 8.5,
maxRate: 10.5,
repaymentType: 'EMI',
tenureMonths: 180
},
{
code: '04',
name: 'Vehicle Loan',
secured: true,
minAmt: 300000,
maxAmt: 1500000,
minRate: 9,
maxRate: 11.5,
repaymentType: 'EMI',
tenureMonths: 84
},
{
code: '05',
name: 'Gold Loan',
secured: true,
minAmt: 50000,
maxAmt: 1000000,
minRate: 9,
maxRate: 12,
repaymentType: 'BULLET',
tenureMonths: 12
},
{
code: '06',
name: 'Kisan Credit Card',
secured: true,
minAmt: 100000,
maxAmt: 500000,
minRate: 7,
maxRate: 9,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
},
{
code: '07',
name: 'Personal Loan',
secured: false,
minAmt: 50000,
maxAmt: 500000,
minRate: 13,
maxRate: 16,
repaymentType: 'EMI',
tenureMonths: 36
},
{
code: '08',
name: 'Education Loan',
secured: false,
minAmt: 200000,
maxAmt: 1500000,
minRate: 10,
maxRate: 12,
repaymentType: 'EMI',
tenureMonths: 84
},
{ code: '02', name: 'Term Loan - MSME', secured: true, minAmt: 300000, maxAmt: 3000000, minRate: 11, maxRate: 14 },
{ code: '03', name: 'Housing Loan', secured: true, minAmt: 1000000, maxAmt: 4000000, minRate: 8.5, maxRate: 10.5 },
{ code: '04', name: 'Vehicle Loan', secured: true, minAmt: 300000, maxAmt: 1500000, minRate: 9, maxRate: 11.5 },
{ code: '05', name: 'Gold Loan', secured: true, minAmt: 50000, maxAmt: 1000000, minRate: 9, maxRate: 12 },
{ code: '06', name: 'Kisan Credit Card', secured: true, minAmt: 100000, maxAmt: 500000, minRate: 7, maxRate: 9 },
{ code: '07', name: 'Personal Loan', secured: false, minAmt: 50000, maxAmt: 500000, minRate: 13, maxRate: 16 },
{ code: '08', name: 'Education Loan', secured: false, minAmt: 200000, maxAmt: 1500000, minRate: 10, maxRate: 12 },
{
code: '09',
name: 'SHG / Micro Finance Loan',
@@ -207,7 +332,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 20000,
maxAmt: 200000,
minRate: 12,
maxRate: 14
maxRate: 14,
repaymentType: 'EMI',
tenureMonths: 24
},
{
code: '10',
@@ -216,7 +343,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
minAmt: 500000,
maxAmt: 2500000,
minRate: 11,
maxRate: 13
maxRate: 13,
repaymentType: 'OVERDRAWN',
tenureMonths: 12
}
];
@@ -345,6 +474,113 @@ function pickBucket(): StageBucket {
return BUCKETS[0];
}
/** Standard reducing-balance EMI formula on the original sanctioned amount and tenure — the same
* fixed instalment the loan was actually sanctioned with, not something recomputed off the
* current (partly-repaid) outstanding balance. */
function calcEmi(principal: number, annualRatePct: number, tenureMonths: number): number {
const r = annualRatePct / 12 / 100;
if (r === 0) {
return round(principal / tenureMonths);
}
const factor = Math.pow(1 + r, tenureMonths);
return round((principal * r * factor) / (factor - 1));
}
interface TimelineCheckpoint {
atDay: number;
type: RecoveryEventType;
channel: string;
remarks: string;
}
/** The real-world escalation ladder a CBS Recovery Management System runs an overdue account
* through — each checkpoint only fires once the account has actually been overdue that long, so
* a 10-day-overdue account shows just the SMS/call steps while a 200-day NPA shows the full
* ladder up to the demand notice. */
const TIMELINE_CHECKPOINTS: TimelineCheckpoint[] = [
{
atDay: 3,
type: 'SMS Reminder',
channel: 'SMS Gateway',
remarks: 'Auto SMS reminder sent for the overdue instalment/interest.'
},
{
atDay: 7,
type: 'IVR / Auto Call',
channel: 'IVR System',
remarks: 'Automated voice-call reminder placed to the registered mobile number.'
},
{
atDay: 15,
type: 'Tele-calling',
channel: 'Branch Tele-calling Desk',
remarks: 'Branch staff called the customer to follow up on the continuing overdue.'
},
{
atDay: 25,
type: 'Overdue Call',
channel: 'Recovery Officer',
remarks: 'Recovery officer placed a personal follow-up call.'
},
{
atDay: 30,
type: 'Field Visit',
channel: 'Branch Staff',
remarks: 'Field visit conducted at the registered address — account under SMA monitoring.'
},
{
atDay: 45,
type: 'SMS Reminder',
channel: 'SMS Gateway',
remarks: 'Second reminder SMS sent — account still overdue.'
},
{
atDay: 60,
type: 'Formal Overdue Letter',
channel: 'Registered Post',
remarks: 'Formal overdue letter dispatched — account crossed into SMA-2 monitoring.'
},
{
atDay: 75,
type: 'Recovery Agent Assigned',
channel: 'External Recovery Agency',
remarks: 'Case assigned to a recovery agent for intensified follow-up.'
},
{
atDay: 91,
type: 'Demand Notice',
channel: 'Recovery Officer',
remarks: 'Account classified NPA — formal demand notice process initiated.'
}
];
/** Builds the account's full collection journey — every escalation checkpoint the account has
* actually reached, interleaved date-wise with its real cash recoveries — so the Account Detail
* screen can show one combined, chronological timeline instead of two disconnected lists. */
function buildCollectionTimeline(overdueDays: number, recoveries: RecoveryTxn[], today: Date): RecoveryEvent[] {
const events: RecoveryEvent[] = [];
for (const cp of TIMELINE_CHECKPOINTS) {
if (cp.atDay <= overdueDays) {
events.push({
date: fmt(addDays(today, -(overdueDays - cp.atDay))),
type: cp.type,
channel: cp.channel,
remarks: cp.remarks
});
}
}
for (const r of recoveries) {
events.push({
date: r.date,
type: 'Payment Received',
channel: r.mode,
remarks: `Recovery of ₹${r.amount.toLocaleString('en-IN')} received via ${r.mode}.`
});
}
events.sort((a, b) => parseDMY(a.date).getTime() - parseDMY(b.date).getTime());
return events;
}
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
* advanced/stressed the classification, the more likely the bank has already escalated. */
function legalActionFor(stage: NpaStage): LegalActionStatus {
@@ -398,6 +634,26 @@ function generateAccounts(count: number): LoanAccount[] {
const overdueAmount = round(outstandingBalance * overdueFrac);
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
// Current market value of the pledged security vs its value at sanction: gold trends upward
// over time (real-world gold-price appreciation); crop/agri collateral (KCC) is volatile in
// both directions, matching how "Crop Failure" shows up as an NPA reason; other secured
// collateral (property/vehicle) moves in a narrower band. Unsecured loans have none.
let marketValueFactor = 0;
if (product.code === '05') {
marketValueFactor = between(1.05, 1.35);
} else if (product.code === '06') {
marketValueFactor = between(0.55, 1.2);
} else if (product.secured) {
marketValueFactor = between(0.85, 1.15);
}
const collateralMarketValue = product.secured ? round(collateralValue * marketValueFactor) : 0;
const emiAmount =
product.repaymentType === 'EMI' ? calcEmi(sanctionedAmount, interestRate, product.tenureMonths) : 0;
const drawingPower = product.repaymentType === 'OVERDRAWN' ? round(sanctionedAmount * between(0.75, 0.9)) : 0;
const maturityDate =
product.repaymentType === 'BULLET' ? fmt(addDays(parseDMY(disbursementDate), product.tenureMonths * 30)) : '—';
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
const legalActionStatus = legalActionFor(stressed);
@@ -439,6 +695,8 @@ function generateAccounts(count: number): LoanAccount[] {
? fmt(latestRecoveryDate)
: formulaLastPaymentDate;
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
const seq = String(1000 + i).padStart(6, '0');
accounts.push({
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
@@ -449,6 +707,7 @@ function generateAccounts(count: number): LoanAccount[] {
productCode: product.code,
productName: product.name,
secured: product.secured,
repaymentType: product.repaymentType,
sanctionedAmount,
disbursementDate,
interestRate,
@@ -457,11 +716,16 @@ function generateAccounts(count: number): LoanAccount[] {
overdueDays,
overdueDaysQuarterAgo,
collateralValue,
collateralMarketValue,
emiAmount,
drawingPower,
maturityDate,
lastPaymentDate,
isLossAsset,
isRestructured,
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
recoveries,
collectionTimeline,
npaReason,
recoveryOfficer,
legalActionStatus
@@ -602,6 +866,72 @@ export function undebitedInterestBalance(a: LoanAccount): number {
return interestReversed(a) - interestRecoveredCash(a);
}
// -------------------------------------------------------------------------
// Recovery Management System — per-repayment-type overdue check, Expected vs
// Actual Recovery, and the collateral-adequacy exception check. `overdueDays`
// stays the single universal "how overdue is this account" signal (so IRAC
// classification never needs a second, type-specific day-counter) — only its
// PLAIN-ENGLISH INTERPRETATION differs by repayment type: an EMI account
// missed its instalment, an OVERDRAWN account has been over its drawing power,
// a BULLET account is past maturity unpaid.
// -------------------------------------------------------------------------
/** True once a bullet loan's maturity date has passed — the point at which the bank checks
* whether the account "self-closed" (fully repaid) or is now overdue. */
export function isBulletMatured(a: LoanAccount): boolean {
return a.repaymentType === 'BULLET' && a.maturityDate !== '—' && parseDMY(a.maturityDate).getTime() <= Date.now();
}
/** A bullet loan past maturity that's still carrying an overdue balance. */
export function isBulletOverdue(a: LoanAccount): boolean {
return isBulletMatured(a) && a.overdueDays > 0;
}
/** A cash-credit/overdraft-style account currently drawn beyond its sanctioned drawing power. */
export function isOverdrawnBreach(a: LoanAccount): boolean {
return a.repaymentType === 'OVERDRAWN' && a.outstandingBalance > a.drawingPower;
}
/** What ought to be recovered this month, by repayment type: the fixed EMI for a term loan, one
* month's interest-servicing for an overdrawn CC/OD/KCC account, or the full outstanding once a
* bullet loan has matured (0 before maturity — nothing is contractually due yet). */
export function expectedRecoveryThisMonth(a: LoanAccount): number {
switch (a.repaymentType) {
case 'EMI':
return a.emiAmount;
case 'OVERDRAWN':
return round((a.outstandingBalance * a.interestRate) / 100 / 12);
case 'BULLET':
return isBulletMatured(a) ? a.outstandingBalance : 0;
}
}
/** What was actually recovered this month — `overdueDays === 0` means the account is fully
* current, i.e. this cycle's EMI/interest/bullet-repayment landed in full; any overdue days at
* all means it didn't, exactly the binary "full amount or mark Overdue" check a real recovery
* desk runs (this demo book doesn't model partial-instalment part-payments). */
export function actualRecoveryThisMonth(a: LoanAccount): number {
if (a.repaymentType === 'BULLET') {
return isBulletMatured(a) && a.overdueDays === 0 ? a.outstandingBalance : 0;
}
return a.overdueDays === 0 ? expectedRecoveryThisMonth(a) : 0;
}
/** Current collateral cover — market value of security as a % of outstanding balance. 0 for an
* unsecured loan (no collateral to speak of). */
export function securityCoverageRatio(a: LoanAccount): number {
if (!a.secured || a.outstandingBalance <= 0) {
return 0;
}
return (a.collateralMarketValue / a.outstandingBalance) * 100;
}
/** True when a secured loan's CURRENT security market value has fallen below what's actually
* outstanding — the "security value is above loan" exception check every recovery review runs. */
export function isUndersecured(a: LoanAccount): boolean {
return a.secured && a.collateralMarketValue < a.outstandingBalance;
}
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
if (prev === curr) {
return 'No Change';
@@ -619,7 +949,7 @@ export function movementLabel(prev: NpaStage, curr: NpaStage): string {
// in-page group/report tab rows, so a menu item and its screen can never
// point at different labels.
// -------------------------------------------------------------------------
export type ReportGroupId = 'reports' | 'analysis' | 'classification';
export type ReportGroupId = 'reports' | 'analysis' | 'classification' | 'recovery-mgmt';
export interface ReportMeta {
id: string;
group: ReportGroupId;
@@ -635,11 +965,17 @@ export const REPORT_META: ReportMeta[] = [
{ 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' }
{ id: 'sma', group: 'classification', label: 'SMA Report' },
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
{ id: 'exception-check', group: 'recovery-mgmt', label: 'Exception Check — Security Value vs Loan' }
];
export const GROUP_LABELS: Record<ReportGroupId, string> = {
reports: 'NPA Reports',
analysis: 'NPA Analysis',
classification: 'NPA Classification'
classification: 'NPA Classification',
'recovery-mgmt': 'Recovery Management'
};
export const DEFAULT_REPORT_ID = 'summary';