/** * 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 = { 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 = { 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 = (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 = { 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((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 = { reports: 'NPA Reports', analysis: 'NPA Analysis', classification: 'NPA Classification', 'recovery-mgmt': 'Recovery Management' }; export const DEFAULT_REPORT_ID = 'summary';