Files
cbsnexora/src/app/npa/npa-data.ts
T

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';