982 lines
33 KiB
TypeScript
982 lines
33 KiB
TypeScript
/**
|
|
* 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/.
|
|
*/
|
|
|
|
// Dummy NPA (Non-Performing Asset) data layer for the NPA Reports / Analysis / Classification
|
|
// screens. Everything downstream — classification, provisioning, interest reversal, GNPA/NNPA —
|
|
// is *derived* from LOAN_ACCOUNTS via pure functions below, so no two reports can ever disagree
|
|
// on the same account's numbers.
|
|
//
|
|
// The book is generated with a seeded RNG (not Math.random()) so the "large, real-world-CBS"
|
|
// dataset stays identical across reloads instead of reshuffling every time the page opens.
|
|
|
|
export type NpaStage =
|
|
| 'STANDARD'
|
|
| 'SMA0'
|
|
| 'SMA1'
|
|
| 'SMA2'
|
|
| 'SUBSTANDARD'
|
|
| 'DOUBTFUL1'
|
|
| 'DOUBTFUL2'
|
|
| 'DOUBTFUL3'
|
|
| 'LOSS';
|
|
|
|
export const STAGE_ORDER: NpaStage[] = [
|
|
'STANDARD',
|
|
'SMA0',
|
|
'SMA1',
|
|
'SMA2',
|
|
'SUBSTANDARD',
|
|
'DOUBTFUL1',
|
|
'DOUBTFUL2',
|
|
'DOUBTFUL3',
|
|
'LOSS'
|
|
];
|
|
|
|
export const STAGE_LABELS: Record<NpaStage, string> = {
|
|
STANDARD: 'Standard',
|
|
SMA0: 'SMA-0',
|
|
SMA1: 'SMA-1',
|
|
SMA2: 'SMA-2',
|
|
SUBSTANDARD: 'Sub-Standard',
|
|
DOUBTFUL1: 'Doubtful-1',
|
|
DOUBTFUL2: 'Doubtful-2',
|
|
DOUBTFUL3: 'Doubtful-3',
|
|
LOSS: 'Loss Asset'
|
|
};
|
|
|
|
export function isNpaStage(stage: NpaStage): boolean {
|
|
return stage !== 'STANDARD' && stage !== 'SMA0' && stage !== 'SMA1' && stage !== 'SMA2';
|
|
}
|
|
|
|
export function isSmaStage(stage: NpaStage): boolean {
|
|
return stage === 'SMA0' || stage === 'SMA1' || stage === 'SMA2';
|
|
}
|
|
|
|
export function stageBadgeClass(stage: NpaStage): string {
|
|
return `npa-badge npa-badge--${stage.toLowerCase()}`;
|
|
}
|
|
|
|
/** Recovery-workflow stage tracked separately from IRAC classification — a Doubtful/Loss
|
|
* account can sit at any of these while its classification stays driven purely by overdueDays. */
|
|
export type LegalActionStatus = 'NONE' | 'NOTICE_ISSUED' | 'SARFAESI_INITIATED' | 'SUIT_FILED' | 'OTS_NEGOTIATION';
|
|
|
|
export const LEGAL_ACTION_LABELS: Record<LegalActionStatus, string> = {
|
|
NONE: 'No Action Yet',
|
|
NOTICE_ISSUED: 'Demand Notice Issued',
|
|
SARFAESI_INITIATED: 'SARFAESI Action Initiated',
|
|
SUIT_FILED: 'Recovery Suit Filed',
|
|
OTS_NEGOTIATION: 'One-Time Settlement Under Negotiation'
|
|
};
|
|
|
|
export interface RecoveryTxn {
|
|
date: string;
|
|
amount: number;
|
|
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;
|
|
guarantorName: string;
|
|
branchCode: string;
|
|
branchName: string;
|
|
productCode: string;
|
|
productName: string;
|
|
secured: boolean;
|
|
repaymentType: RepaymentType;
|
|
sanctionedAmount: number;
|
|
disbursementDate: string;
|
|
interestRate: number;
|
|
outstandingBalance: number;
|
|
overdueAmount: number;
|
|
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. */
|
|
recoveryOfficer: string;
|
|
legalActionStatus: LegalActionStatus;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Date helpers
|
|
// -------------------------------------------------------------------------
|
|
function fmt(d: Date): string {
|
|
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
|
}
|
|
|
|
function addDays(d: Date, days: number): Date {
|
|
const r = new Date(d);
|
|
r.setDate(r.getDate() + days);
|
|
return r;
|
|
}
|
|
|
|
/** Parses a "DD-MM-YYYY" string back into a Date, so generated dates can be compared/ordered
|
|
* against each other (e.g. "is this recovery more recent than the last-payment estimate?"). */
|
|
function parseDMY(s: string): Date {
|
|
const [
|
|
d,
|
|
m,
|
|
y
|
|
] = s.split('-').map(Number);
|
|
return new Date(y, m - 1, d);
|
|
}
|
|
|
|
export function todayDDMMYYYY(): string {
|
|
return fmt(new Date());
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Seeded RNG — keeps the generated book stable across reloads
|
|
// -------------------------------------------------------------------------
|
|
function mulberry32(seed: number): () => number {
|
|
return function random(): number {
|
|
seed |= 0;
|
|
seed = (seed + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
const rng = mulberry32(19870614);
|
|
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
|
|
const between = (min: number, max: number): number => min + rng() * (max - min);
|
|
const round = (n: number): number => Math.round(n);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Masters
|
|
// -------------------------------------------------------------------------
|
|
export interface BranchMaster {
|
|
code: string;
|
|
name: string;
|
|
}
|
|
// BR01/BR02 are deliberately named "Branch 1"/"Branch 2" — identical to the two branches in
|
|
// cash-clearing-transfer-summary.component.ts — so a single pair of real office names in the DB
|
|
// gets recognised by BOTH reports instead of each module expecting a different branch name.
|
|
export const BRANCHES: BranchMaster[] = [
|
|
{ code: 'BR01', name: 'Branch 1' },
|
|
{ code: 'BR02', name: 'Branch 2' },
|
|
{ code: 'BR03', name: 'Borivali Branch' },
|
|
{ code: 'BR04', name: 'Thane Branch' },
|
|
{ code: 'BR05', name: 'Pune Branch' },
|
|
{ code: 'BR06', name: 'Nashik Branch' },
|
|
{ code: 'BR07', name: 'Nagpur Branch' },
|
|
{ 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;
|
|
secured: boolean;
|
|
minAmt: number;
|
|
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[] = [
|
|
{
|
|
code: '01',
|
|
name: 'Cash Credit (MSME)',
|
|
secured: true,
|
|
minAmt: 500000,
|
|
maxAmt: 5000000,
|
|
minRate: 11,
|
|
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: '09',
|
|
name: 'SHG / Micro Finance Loan',
|
|
secured: false,
|
|
minAmt: 20000,
|
|
maxAmt: 200000,
|
|
minRate: 12,
|
|
maxRate: 14,
|
|
repaymentType: 'EMI',
|
|
tenureMonths: 24
|
|
},
|
|
{
|
|
code: '10',
|
|
name: 'Overdraft Against Property',
|
|
secured: true,
|
|
minAmt: 500000,
|
|
maxAmt: 2500000,
|
|
minRate: 11,
|
|
maxRate: 13,
|
|
repaymentType: 'OVERDRAWN',
|
|
tenureMonths: 12
|
|
}
|
|
];
|
|
|
|
const FIRST_NAMES = [
|
|
'Ramesh',
|
|
'Suresh',
|
|
'Mahesh',
|
|
'Ganesh',
|
|
'Dinesh',
|
|
'Vijay',
|
|
'Sanjay',
|
|
'Ajay',
|
|
'Prakash',
|
|
'Vinod',
|
|
'Sunita',
|
|
'Anita',
|
|
'Kavita',
|
|
'Savita',
|
|
'Rekha',
|
|
'Meena',
|
|
'Pooja',
|
|
'Sangita',
|
|
'Nirmala',
|
|
'Usha',
|
|
'Ravindra',
|
|
'Mahendra',
|
|
'Narendra',
|
|
'Jitendra',
|
|
'Rajendra',
|
|
'Bhagwan',
|
|
'Balasaheb',
|
|
'Vasant',
|
|
'Shivaji',
|
|
'Yogesh',
|
|
'Kiran',
|
|
'Manisha',
|
|
'Deepak',
|
|
'Sandeep',
|
|
'Pradeep',
|
|
'Ashok',
|
|
'Vishal',
|
|
'Nitin',
|
|
'Amol',
|
|
'Santosh'
|
|
];
|
|
const LAST_NAMES = [
|
|
'Patil',
|
|
'More',
|
|
'Jadhav',
|
|
'Deshmukh',
|
|
'Pawar',
|
|
'Kulkarni',
|
|
'Shinde',
|
|
'Gaikwad',
|
|
'Chavan',
|
|
'Bhosale',
|
|
'Kale',
|
|
'Salunkhe',
|
|
'Sawant',
|
|
'Kadam',
|
|
'Joshi',
|
|
'Wagh',
|
|
'Thorat',
|
|
'Waghmare',
|
|
'Mane',
|
|
'Shaikh'
|
|
];
|
|
|
|
const NPA_REASONS = [
|
|
'Crop Failure / Agri Distress',
|
|
'Business Loss',
|
|
'Health Issue / Death of Borrower',
|
|
'Diversion of Funds',
|
|
'Wilful Default',
|
|
'Natural Calamity',
|
|
'Loss of Employment',
|
|
'Family Dispute / Litigation',
|
|
'Delayed Receivables from Debtors',
|
|
'Unit Closure'
|
|
];
|
|
|
|
/** Recovery officers are bank staff, kept distinct from the FIRST_NAMES/LAST_NAMES pool used for
|
|
* customers so a name never accidentally reads as "the officer is also the borrower". */
|
|
const RECOVERY_OFFICERS = [
|
|
'Anil Deshpande',
|
|
'Sunil Kadam',
|
|
'Vikas Pathak',
|
|
'Rajendra More',
|
|
'Sachin Bhagat',
|
|
'Prashant Jagtap',
|
|
'Milind Kulkarni',
|
|
'Ravi Ghadge'
|
|
];
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Weighted stage buckets — used only to seed a realistic distribution of
|
|
// overdue-day counts; the actual classification is always re-derived from
|
|
// `overdueDays` via classifyDays(), never stored directly.
|
|
// -------------------------------------------------------------------------
|
|
interface StageBucket {
|
|
weight: number;
|
|
minDays: number;
|
|
maxDays: number;
|
|
isLossPool: boolean;
|
|
}
|
|
const BUCKETS: StageBucket[] = [
|
|
{ weight: 60, minDays: 0, maxDays: 0, isLossPool: false },
|
|
{ weight: 10, minDays: 1, maxDays: 30, isLossPool: false },
|
|
{ weight: 7, minDays: 31, maxDays: 60, isLossPool: false },
|
|
{ weight: 6, minDays: 61, maxDays: 90, isLossPool: false },
|
|
{ weight: 9, minDays: 91, maxDays: 365, isLossPool: false },
|
|
{ weight: 4, minDays: 366, maxDays: 730, isLossPool: false },
|
|
{ weight: 2.5, minDays: 731, maxDays: 1095, isLossPool: false },
|
|
{ weight: 1.5, minDays: 1096, maxDays: 1900, isLossPool: true }
|
|
];
|
|
const TOTAL_WEIGHT = BUCKETS.reduce((s, b) => s + b.weight, 0);
|
|
|
|
function pickBucket(): StageBucket {
|
|
let r = rng() * TOTAL_WEIGHT;
|
|
for (const b of BUCKETS) {
|
|
if (r < b.weight) {
|
|
return b;
|
|
}
|
|
r -= b.weight;
|
|
}
|
|
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 {
|
|
if (!isNpaStage(stage)) {
|
|
return 'NONE';
|
|
}
|
|
const r = rng();
|
|
if (stage === 'SUBSTANDARD') {
|
|
return r < 0.2 ? 'NOTICE_ISSUED' : 'NONE';
|
|
}
|
|
if (stage === 'DOUBTFUL1') {
|
|
if (r < 0.15) return 'OTS_NEGOTIATION';
|
|
if (r < 0.45) return 'SARFAESI_INITIATED';
|
|
if (r < 0.65) return 'NOTICE_ISSUED';
|
|
return 'NONE';
|
|
}
|
|
// DOUBTFUL2, DOUBTFUL3, LOSS
|
|
if (r < 0.1) return 'OTS_NEGOTIATION';
|
|
if (r < 0.55) return 'SUIT_FILED';
|
|
if (r < 0.8) return 'SARFAESI_INITIATED';
|
|
if (r < 0.9) return 'NOTICE_ISSUED';
|
|
return 'NONE';
|
|
}
|
|
|
|
function generateAccounts(count: number): LoanAccount[] {
|
|
const today = new Date();
|
|
const accounts: LoanAccount[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const branch = pick(BRANCHES);
|
|
const product = pick(LOAN_PRODUCTS);
|
|
const bucket = pickBucket();
|
|
const overdueDays = bucket.maxDays === 0 ? 0 : round(between(bucket.minDays, bucket.maxDays));
|
|
const isLossAsset = bucket.isLossPool && rng() < 0.4;
|
|
// Restructured accounts skew towards the already-stressed buckets (SMA-2 and worse),
|
|
// matching how RBI restructuring relief is actually used in practice.
|
|
const stressed = classifyDays(overdueDays, isLossAsset);
|
|
const isRestructured =
|
|
rng() < (stressed === 'STANDARD' || stressed === 'SMA0' || stressed === 'SMA1' ? 0.04 : 0.18);
|
|
|
|
const sanctionedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000;
|
|
// The loan must be old enough to have actually accrued `overdueDays` of delinquency, plus a
|
|
// minimum "good" performing stretch beforehand — otherwise a badly-overdue account could end
|
|
// up with a disbursement date that falls AFTER its own last-payment/NPA date, which would look
|
|
// wrong no matter what day this report is viewed on.
|
|
const minGoodPeriodDays = round(between(30, 180));
|
|
const loanAgeDays = Math.max(round(between(0.5, 8) * 365), overdueDays + minGoodPeriodDays);
|
|
const disbursementDate = fmt(addDays(today, -loanAgeDays));
|
|
const interestRate = +between(product.minRate, product.maxRate).toFixed(2);
|
|
const outstandingBalance = round(sanctionedAmount * between(0.25, 0.9));
|
|
const overdueFrac = overdueDays === 0 ? 0 : Math.min(0.85, overdueDays / 1200 + between(0, 0.15));
|
|
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);
|
|
|
|
// Quarter-ago overdue count is capped at how old the loan actually was 90 days back — a loan
|
|
// can't have been overdue for longer than it had existed at that point in time.
|
|
const drift = round((rng() - 0.4) * 130);
|
|
const maxQuarterAgoOverdueDays = Math.max(0, loanAgeDays - 90);
|
|
const overdueDaysQuarterAgo = Math.min(Math.max(0, overdueDays - drift), maxQuarterAgoOverdueDays);
|
|
|
|
const recoveries: RecoveryTxn[] = [];
|
|
if (overdueDays > 90 && rng() < 0.5) {
|
|
const numRecoveries = 1 + Math.floor(rng() * 3);
|
|
for (let r = 0; r < numRecoveries; r++) {
|
|
recoveries.push({
|
|
date: fmt(addDays(today, -round(between(10, Math.min(overdueDays, 400))))),
|
|
amount: round(outstandingBalance * between(0.02, 0.12)),
|
|
mode: pick([
|
|
'Cash Deposit',
|
|
'Cheque',
|
|
'NEFT/RTGS',
|
|
'Auction / SARFAESI',
|
|
'One-Time Settlement'
|
|
])
|
|
});
|
|
}
|
|
}
|
|
|
|
// A recorded recovery IS a payment — if one landed more recently than the formula-based
|
|
// last-payment estimate, the account's "last payment" must reflect that, otherwise the two
|
|
// fields on the Account Detail screen would visibly contradict each other.
|
|
const formulaLastPaymentDate = fmt(addDays(today, -Math.max(5, overdueDays - round(between(0, 20)))));
|
|
const latestRecoveryDate = recoveries.reduce<Date | null>((latest, r) => {
|
|
const d = parseDMY(r.date);
|
|
return !latest || d > latest ? d : latest;
|
|
}, null);
|
|
const lastPaymentDate =
|
|
latestRecoveryDate && latestRecoveryDate > parseDMY(formulaLastPaymentDate)
|
|
? 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}`,
|
|
customerName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
|
|
guarantorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
|
|
branchCode: branch.code,
|
|
branchName: branch.name,
|
|
productCode: product.code,
|
|
productName: product.name,
|
|
secured: product.secured,
|
|
repaymentType: product.repaymentType,
|
|
sanctionedAmount,
|
|
disbursementDate,
|
|
interestRate,
|
|
outstandingBalance,
|
|
overdueAmount,
|
|
overdueDays,
|
|
overdueDaysQuarterAgo,
|
|
collateralValue,
|
|
collateralMarketValue,
|
|
emiAmount,
|
|
drawingPower,
|
|
maturityDate,
|
|
lastPaymentDate,
|
|
isLossAsset,
|
|
isRestructured,
|
|
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
|
|
recoveries,
|
|
collectionTimeline,
|
|
npaReason,
|
|
recoveryOfficer,
|
|
legalActionStatus
|
|
});
|
|
}
|
|
return accounts;
|
|
}
|
|
|
|
// 8 branches x ~80 accounts/branch on average — sized to look like a real cooperative bank's
|
|
// loan book rather than a token demo sample. Pagination in the reports UI is what makes a book
|
|
// this size usable, not a smaller dataset.
|
|
export const LOAN_ACCOUNTS: LoanAccount[] = generateAccounts(640);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Classification & provisioning (RBI IRAC norms, simplified for a demo book)
|
|
// -------------------------------------------------------------------------
|
|
export function classifyDays(days: number, isLossAsset: boolean): NpaStage {
|
|
if (isLossAsset && days > 1095) {
|
|
return 'LOSS';
|
|
}
|
|
if (days <= 0) {
|
|
return 'STANDARD';
|
|
}
|
|
if (days <= 30) {
|
|
return 'SMA0';
|
|
}
|
|
if (days <= 60) {
|
|
return 'SMA1';
|
|
}
|
|
if (days <= 90) {
|
|
return 'SMA2';
|
|
}
|
|
if (days <= 365) {
|
|
return 'SUBSTANDARD';
|
|
}
|
|
if (days <= 730) {
|
|
return 'DOUBTFUL1';
|
|
}
|
|
if (days <= 1095) {
|
|
return 'DOUBTFUL2';
|
|
}
|
|
return 'DOUBTFUL3';
|
|
}
|
|
|
|
export function currentStage(a: LoanAccount): NpaStage {
|
|
return classifyDays(a.overdueDays, a.isLossAsset);
|
|
}
|
|
|
|
export function previousStage(a: LoanAccount): NpaStage {
|
|
return classifyDays(a.overdueDaysQuarterAgo, a.isLossAsset && a.overdueDaysQuarterAgo > 1095);
|
|
}
|
|
|
|
export function provisionRatePct(stage: NpaStage, secured: boolean): number {
|
|
switch (stage) {
|
|
case 'STANDARD':
|
|
case 'SMA0':
|
|
case 'SMA1':
|
|
return 0.4;
|
|
case 'SMA2':
|
|
return 0.75;
|
|
case 'SUBSTANDARD':
|
|
return secured ? 15 : 25;
|
|
case 'DOUBTFUL1':
|
|
return secured ? 25 : 100;
|
|
case 'DOUBTFUL2':
|
|
return secured ? 40 : 100;
|
|
case 'DOUBTFUL3':
|
|
case 'LOSS':
|
|
return 100;
|
|
}
|
|
}
|
|
|
|
export function provisionRequired(a: LoanAccount): number {
|
|
const stage = currentStage(a);
|
|
if (!isNpaStage(stage)) {
|
|
return round(a.outstandingBalance * (provisionRatePct(stage, a.secured) / 100));
|
|
}
|
|
const securedPortion = Math.min(a.outstandingBalance, a.collateralValue);
|
|
const unsecuredPortion = Math.max(0, a.outstandingBalance - a.collateralValue);
|
|
const securedRate = provisionRatePct(stage, true) / 100;
|
|
const unsecuredRate = provisionRatePct(stage, false) / 100;
|
|
return round(securedPortion * securedRate + unsecuredPortion * unsecuredRate);
|
|
}
|
|
|
|
/** Provision held can run a little above or below what's required (over/under-provisioning
|
|
* happens in real banks) — but it can never exceed the account's own outstanding balance, since
|
|
* a bank cannot hold more provision than the money actually at risk on that one loan. */
|
|
export function provisionHeld(a: LoanAccount): number {
|
|
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
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,
|
|
* never stored separately, so it always agrees with the account's current classification. */
|
|
export function npaSinceDate(a: LoanAccount): string {
|
|
if (!isNpaStage(currentStage(a))) {
|
|
return '—';
|
|
}
|
|
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);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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';
|
|
}
|
|
const prevIdx = STAGE_ORDER.indexOf(prev);
|
|
const currIdx = STAGE_ORDER.indexOf(curr);
|
|
if (currIdx > prevIdx) {
|
|
return !isNpaStage(prev) && isNpaStage(curr) ? 'Fresh Slippage to NPA' : 'Downgraded';
|
|
}
|
|
return isNpaStage(prev) && !isNpaStage(curr) ? 'Upgraded to Standard' : 'Improved';
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Report menu metadata — drives both the shell mega-menu routerLinks and the
|
|
// 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' | 'recovery-mgmt';
|
|
export interface ReportMeta {
|
|
id: string;
|
|
group: ReportGroupId;
|
|
label: string;
|
|
}
|
|
export const REPORT_META: ReportMeta[] = [
|
|
{ id: 'interest', group: 'reports', label: 'NPA Report' },
|
|
{ id: 'status', group: 'reports', label: 'NPA Status Report' },
|
|
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
|
|
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' },
|
|
{ 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' },
|
|
{ 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',
|
|
'recovery-mgmt': 'Recovery Management'
|
|
};
|
|
export const DEFAULT_REPORT_ID = 'summary';
|