Files
cbsnexora/src/app/loan-origination/loan-origination-data.ts
T
nitesh 2d93f80d50
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat(loan-origination): guarantor share-account verification, committee resolution gating, legal doc uploads, CIC score-detection fixes, and interest/share rate chart
- Guarantor must be a real existing client with a verified active share account, with clickable share-account chips
- Loan Committee Approval now requires Resolution No./Date plus a full application report before approving
- Loan Committee gets its own pre-filtered pipeline view via a menu link and stage query param
- Legal Approval stage gets a multi-file document upload checklist with add-more support
- CIC PDF parser: item-level score detection before line-based fallback, fixing font-size-driven line-merging misdetection
- New Interest & Share Rate Chart tab: per-product interest/tenure bands and per-amount-slab Share Capital linkage requirements
2026-08-14 14:49:22 +05:30

2007 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 Loan Origination / Sanctioning workflow data layer — the pre-disbursement pipeline a
// real-world cooperative-bank CBS runs every application through: Channel intake → KYC/Document
// collection → CIC (Credit Information Company) check → Field Investigation → Personal Discussion
// (discrepancy resolution) → Credit Committee → Risk Approval → Legal Approval → Customer Signing
// → Cheque/ACH → Post-Disbursement Check → Disbursement. Modelled per RBI's KYC Master Direction
// (OVD-based identity verification), the mandatory Credit Information Companies check before
// sanction, and the Fair Practices Code for lenders (sanction letter, loan agreement, transparent
// terms) — plus the co-operative-bank-specific rule that a borrower must hold membership shares.
//
// Everything downstream (progress %, sanctioning authority, CIBIL band, ageing) is *derived* from
// the stored application fields via pure functions below — never duplicated by hand — and the book
// is generated with a seeded RNG (not Math.random()) so it stays identical across reloads.
import { BRANCHES, BranchMaster } from '../npa/npa-data';
export { BRANCHES, BranchMaster };
// -------------------------------------------------------------------------
// Stage model
// -------------------------------------------------------------------------
export type OriginationStage =
| 'APPLICATION'
| 'KYC_DOCUMENTS'
| 'CIC_CHECK'
| 'FIELD_INVESTIGATION'
| 'PERSONAL_DISCUSSION'
| 'CREDIT_COMMITTEE'
| 'RISK_APPROVAL'
| 'LEGAL_APPROVAL'
| 'CUSTOMER_SIGNING'
| 'CHEQUE_ACH'
| 'POST_CHECK'
| 'DISBURSED';
export const STAGE_ORDER: OriginationStage[] = [
'APPLICATION',
'KYC_DOCUMENTS',
'CIC_CHECK',
'FIELD_INVESTIGATION',
'PERSONAL_DISCUSSION',
'CREDIT_COMMITTEE',
'RISK_APPROVAL',
'LEGAL_APPROVAL',
'CUSTOMER_SIGNING',
'CHEQUE_ACH',
'POST_CHECK',
'DISBURSED'
];
export const STAGE_LABELS: Record<OriginationStage, string> = {
APPLICATION: 'Application Received',
KYC_DOCUMENTS: 'KYC & Document Collection',
CIC_CHECK: 'CIC / Credit Bureau Check',
FIELD_INVESTIGATION: 'Field Investigation (FI)',
PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
CREDIT_COMMITTEE: 'Loan Committee Approval',
RISK_APPROVAL: 'Risk Approval',
LEGAL_APPROVAL: 'Legal Approval',
CUSTOMER_SIGNING: 'Customer Signing Slot',
CHEQUE_ACH: 'Cheque Received / ACH Sign',
POST_CHECK: 'Post-Disbursement Check',
DISBURSED: 'Disbursed'
};
export function stageIndex(stage: OriginationStage): number {
return STAGE_ORDER.indexOf(stage);
}
export function nextStage(stage: OriginationStage): OriginationStage | null {
const idx = stageIndex(stage);
return idx >= 0 && idx < STAGE_ORDER.length - 1 ? STAGE_ORDER[idx + 1] : null;
}
export type OverallStatus = 'IN_PROGRESS' | 'ON_HOLD' | 'REJECTED' | 'DISBURSED';
export const STATUS_LABELS: Record<OverallStatus, string> = {
IN_PROGRESS: 'In Progress',
ON_HOLD: 'On Hold — Discrepancy',
REJECTED: 'Rejected',
DISBURSED: 'Disbursed'
};
export type Channel = 'BRANCH' | 'BC' | 'SELF_APPLY';
export const CHANNEL_LABELS: Record<Channel, string> = {
BRANCH: 'Branch',
BC: 'Business Correspondent (BC)',
SELF_APPLY: 'Self-Apply (Digital)'
};
export type CustomerType = 'EXISTING' | 'NEW';
/** The 4 credit information companies actually licensed by RBI under the CICRA, 2005. */
export type CicBureau = 'CIBIL' | 'Equifax' | 'Experian' | 'CRIF High Mark';
export const CIC_BUREAUS: CicBureau[] = [
'CIBIL',
'Equifax',
'Experian',
'CRIF High Mark'
];
/** What the bank recovers from the customer for pulling a bureau report — a flat pass-through
* of the bureau's per-pull fee, same for every application regardless of loan size. */
export const CIC_CHECK_CHARGE = 50;
/** How the CIC check charge was collected from the customer -- debited from an account they
* already hold with the bank (Existing Customer), or paid externally via a payment link
* (no account exists yet for a New Customer). */
export type CicChargeMode = 'ACCOUNT_DEBIT' | 'PAYMENT_LINK';
export type DiscrepancyType =
| 'NONE'
| 'DOCUMENT_DISCREPANCY'
| 'NAME_MISMATCH'
| 'OFFICE_VERIFICATION_FAILED'
| 'PROPERTY_DISCREPANCY'
| 'FI_REPORT_DISCREPANCY'
| 'FAKE_DECLARATION';
export const DISCREPANCY_LABELS: Record<DiscrepancyType, string> = {
NONE: 'No Discrepancy',
DOCUMENT_DISCREPANCY: 'Document Discrepancy',
NAME_MISMATCH: 'Name Mismatch',
OFFICE_VERIFICATION_FAILED: 'Office Verification Failed',
PROPERTY_DISCREPANCY: 'Property Discrepancy',
FI_REPORT_DISCREPANCY: 'FI Report Discrepancy',
FAKE_DECLARATION: 'Fake Declaration Suspected'
};
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
export const APPROVAL_LABELS: Record<ApprovalDecision, string> = {
PENDING: 'Pending',
APPROVED: 'Approved',
REJECTED: 'Rejected',
EXCEPTION_APPROVED: 'Approved on Exception'
};
export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED';
export interface VerificationCheck {
status: VerificationStatus;
verifiedBy: string;
verifiedDate: string;
remarks: string;
/** Field Investigation only -- the officer assigned to physically visit this address, when they
* were assigned (both a display date and a raw epoch-ms timestamp for the 48-hour SLA clock),
* and what they captured on the visit (GPS + a photo count -- the actual File objects are kept
* local-only in the component, same as every other document capture in this mock pipeline). */
officerAssigned: string;
assignedDate: string;
assignedAt: number;
latitude: string;
longitude: string;
photoCount: number;
}
export interface ApprovalRecord {
decision: ApprovalDecision;
approverName: string;
approverRole: string;
date: string;
remarks: string;
}
export interface StageHistoryEntry {
stage: OriginationStage;
date: string;
actionBy: string;
outcome: string;
remarks: string;
}
export interface LoanApplication {
applicationNo: string;
applicationDate: string;
applicantName: string;
branchCode: string;
branchName: string;
channel: Channel;
/** '—' unless channel === 'BC'. */
bcAgentName: string;
customerType: CustomerType;
/** Real Fineract client id -- only set when this application was raised against a client picked
* via the Existing Customer search (see loan-pipeline.component.ts); null for every other
* (dummy-data-generated or New Customer) application, which have no real backing client. */
clientId?: number | null;
productCode: string;
productName: string;
/** Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed annually) --
* see allowedProductTypes() for which products can be which. */
productType: ProductType;
secured: boolean;
requestedAmount: number;
tenureMonths: number;
interestRate: number;
// Co-operative bank membership — a borrower must hold shares in the society to be sanctioned.
isShareMember: boolean;
shareCertNo: string;
shareMemberSince: string;
// KYC / Documents (OVD-based per RBI KYC Master Direction)
aadhaarVerified: boolean;
panVerified: boolean;
incomeProofSubmitted: boolean;
bankStatementSubmitted: boolean;
electricityBillSubmitted: boolean;
/** Documents specific to this application's loan product (e.g. property papers for a Housing
* Loan, land records for a Kisan Credit Card) -- seeded from PRODUCT_DOCUMENT_REQUIREMENTS at
* creation time, plus whatever the officer adds on top with "Add Custom Document". */
productDocuments: ProductDocumentEntry[];
monthlyIncome: number;
eligibleLoanAmount: number;
coBorrowerRequired: boolean;
coBorrowerName: string;
// CIC / Credit Bureau check — mandatory before sanction under the CICRA, 2005 framework.
/** RBI's Fair Practices Code requires the customer's informed consent before a credit bureau
* pull -- true once the officer confirms the signed declaration letter has come back from
* the customer, which is what actually unlocks the "Check Credit Score" action below. */
cicConsentSigned: boolean;
/** True once the CIC_CHECK_CHARGE has actually been collected -- via account debit for an
* Existing Customer with a real linked account, or a confirmed payment-link reference
* otherwise. Also gates "Check Credit Score", same as the consent declaration. */
cicChargeCollected: boolean;
cicChargeMode: CicChargeMode | null;
/** Which of the client's real savings/current accounts the charge was debited from --
* e.g. "SB0012345 — Savings (Regular Savings)" -- kept as a display label since this mock
* pipeline never posts a real Fineract transaction. */
cicChargeAccountLabel: string;
/** A mock payment-gateway link generated for a customer with no bank account yet -- this
* pipeline is entirely dummy data, so nothing is actually charged; the link is illustrative
* of the real-world flow (generate link → share with customer → they pay → officer records
* the reference they get back). */
cicPaymentLinkUrl: string;
cicPaymentReference: string;
cicBureau: CicBureau;
cibilScore: number;
cicReportUploaded: boolean;
cicReportFileName: string;
cicUploadDate: string;
cicParsedAnalysis?: any;
/** Whether cicParsedAnalysis above was actually read out of the uploaded file's real content,
* or is the deterministic simulated fallback -- kept on the application itself (not a transient
* component field) so reopening it later, or switching applications, still reports the truth. */
cicAnalysisSource: 'PARSED' | 'SIMULATED' | 'NONE';
/** The FI request is "pushed" out to a field agency from here — Field Investigation itself
* only records what comes back. */
fiRequestPushed: boolean;
fiRequestDate: string;
fiAgencyAssigned: string;
// Field Investigation
addressVerification: VerificationCheck;
officeVerification: VerificationCheck;
propertyVerification: VerificationCheck;
/** Whether this applicant runs a shop/business premises worth sending a field officer to --
* officer-ticked at Field Investigation itself (not derivable from anything else on the
* application), same conditional-row pattern propertyVerification already uses off `secured`. */
hasShop: boolean;
shopVerification: VerificationCheck;
/** propertyValuation itself is always derived (builtUpAreaSqFt × marketRatePerSqFt) -- kept as
* its own field since every other part of the app already reads it directly (stage-history
* remarks, Risk Approval), rather than making every one of those call sites recompute it. */
propertyType: string;
builtUpAreaSqFt: number;
marketRatePerSqFt: number;
guidelineRatePerSqFt: number;
valuerName: string;
valuationDate: string;
propertyValuation: number;
agentReportUploaded: boolean;
agentReportFileName: string;
agentReportRemark: string;
// Personal Discussion / discrepancy handling
discrepancyType: DiscrepancyType;
/** Scheduled before the meeting happens -- date/time/mode/officer -- so there's a real
* appointment on record instead of the discussion notes just appearing from nowhere. */
meetingScheduled: boolean;
meetingDate: string;
meetingTime: string;
meetingMode: 'ONLINE' | 'OFFLINE' | 'CALL' | '';
meetingWith: string;
personalDiscussionNotes: string;
personalDiscussionDate: string;
/** Customer's signed declaration taken after the personal discussion clears. */
declarationTaken: boolean;
declarationDate: string;
// Guarantor
guarantorRequired: boolean;
/** A guarantor has to be a real, already-onboarded bank client with an active share account --
* not just a name typed in -- so this is a real Fineract clientId (null until one is actually
* picked via search), not a free-text field. */
guarantorClientId: number | null;
guarantorName: string;
guarantorHasShareAccount: boolean;
// "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk
// Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by
// the checks above and are re-derived from them, never duplicated.
backgroundCheckDone: boolean;
/** CSE — CERSAI Search Enquiry, the mandatory check for an existing charge on the same security. */
cersaiSearchDone: boolean;
managerMeetingDone: boolean;
previousLoanDeclarationDone: boolean;
propertySearchReportDone: boolean;
valuationReportConfirmed: boolean;
// Multi-level approvals (segregation of duties: Credit / Risk / Legal / Post-Check)
creditCommitteeApproval: ApprovalRecord;
/** The committee's formal decision reference -- unlike Risk/Legal/Post-Check (single-officer
* sign-offs), a Loan Committee decision is minuted with its own resolution number/date, which
* is what actually gets quoted in the sanction letter later. */
committeeResolutionNo: string;
committeeResolutionDate: string;
riskApproval: ApprovalRecord;
legalApproval: ApprovalRecord;
postCheckApproval: ApprovalRecord;
// Legal / documentation
feesAndAccountOpened: boolean;
mortgageCreated: boolean;
legalStampDone: boolean;
fileFilingDone: boolean;
agreementSigningSlot: string;
legalDocuments: ProductDocumentEntry[];
// Signing / disbursement
customerSigningDate: string;
chequeReceived: boolean;
achSigned: boolean;
/** Post-Check document-signing sign-off, one flag per signatory the note calls for. */
signedByAgent: boolean;
signedByManager: boolean;
signedByCommittee: boolean;
signedByRiskApproval: boolean;
signedByLegalApproval: boolean;
disbursementDate: string;
disbursementMode: string;
achConfirmationRef: string;
emiActivationConfirmed: boolean;
emiActivationDate: string;
currentStage: OriginationStage;
/** The furthest stage this application has ever reached, as a STAGE_ORDER index -- kept
* separate from `currentStage` so jumping back to an earlier stage for review (or forward
* again afterwards) doesn't make the stepper forget how far the application actually got. */
furthestStageIndex: number;
overallStatus: OverallStatus;
stageHistory: StageHistoryEntry[];
}
// -------------------------------------------------------------------------
// Date helpers (same convention as npa-data.ts)
// -------------------------------------------------------------------------
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;
}
function addMonths(d: Date, months: number): Date {
const r = new Date(d);
r.setMonth(r.getMonth() + months);
return r;
}
/** Parses a "DD-MM-YYYY" string back into a local-time Date — deliberately NOT
* `new Date(isoString)`, which parses as UTC and can land on the wrong calendar day depending
* on the browser's local timezone offset. */
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 pipeline 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(20240115);
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
// -------------------------------------------------------------------------
interface OriginationProduct {
code: string;
name: string;
secured: boolean;
minAmt: number;
maxAmt: number;
minRate: number;
maxRate: number;
minTenure: number;
maxTenure: number;
/** Rough FOIR-style income multiplier used to derive Eligible Loan Amount from Monthly Income. */
incomeMultiplier: number;
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
shareLinkagePercent: number;
}
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
{
code: 'PL',
name: 'Personal Loan',
secured: false,
minAmt: 50000,
maxAmt: 500000,
minRate: 13,
maxRate: 16,
minTenure: 12,
maxTenure: 48,
incomeMultiplier: 20,
shareLinkagePercent: 5
},
{
code: 'HL',
name: 'Housing Loan',
secured: true,
minAmt: 1000000,
maxAmt: 4000000,
minRate: 8.5,
maxRate: 10.5,
minTenure: 120,
maxTenure: 240,
incomeMultiplier: 60,
shareLinkagePercent: 2.5
},
{
code: 'VL',
name: 'Vehicle Loan',
secured: true,
minAmt: 300000,
maxAmt: 1500000,
minRate: 9,
maxRate: 11.5,
minTenure: 36,
maxTenure: 84,
incomeMultiplier: 36,
shareLinkagePercent: 2.5
},
{
code: 'GL',
name: 'Gold Loan',
secured: true,
minAmt: 50000,
maxAmt: 1000000,
minRate: 9,
maxRate: 12,
minTenure: 6,
maxTenure: 12,
incomeMultiplier: 15,
shareLinkagePercent: 2
},
{
code: 'CC',
name: 'Cash Credit (MSME)',
secured: true,
minAmt: 500000,
maxAmt: 5000000,
minRate: 11,
maxRate: 13.5,
minTenure: 12,
maxTenure: 12,
incomeMultiplier: 40,
shareLinkagePercent: 5
},
{
code: 'KCC',
name: 'Kisan Credit Card',
secured: true,
minAmt: 100000,
maxAmt: 500000,
minRate: 7,
maxRate: 9,
minTenure: 12,
maxTenure: 12,
incomeMultiplier: 30,
shareLinkagePercent: 2.5
},
{
code: 'EL',
name: 'Education Loan',
secured: false,
minAmt: 200000,
maxAmt: 1500000,
minRate: 10,
maxRate: 12,
minTenure: 60,
maxTenure: 84,
incomeMultiplier: 45,
shareLinkagePercent: 2.5
}
];
/** Amount-slab view of the same Share Capital linkage rule -- bye-laws state it both ways: a
* per-product minimum (above) and a general slab schedule by loan quantum (below). Whichever is
* higher for a given case governs; both are shown so officers can quote either basis. */
export interface ShareLinkageSlab {
minAmt: number;
/** null = "and above" (open-ended top slab). */
maxAmt: number | null;
sharePercent: number;
}
export const SHARE_LINKAGE_SLABS: ShareLinkageSlab[] = [
{ minAmt: 0, maxAmt: 100000, sharePercent: 2.5 },
{ minAmt: 100000, maxAmt: 500000, sharePercent: 5 },
{ minAmt: 500000, maxAmt: 2000000, sharePercent: 5 },
{ minAmt: 2000000, maxAmt: null, sharePercent: 7.5 }
];
/** One product-specific document a loan officer has to collect for a given product's collateral/
* eligibility type -- distinct from the generic KYC docs (Aadhaar, PAN, Income Proof, ...) every
* application needs regardless of product. */
export interface ProductDocumentEntry {
key: string;
label: string;
hint?: string;
/** True for a document the officer added on the spot with "Add Custom Document" -- these can
* be removed again; the product's own required list cannot. */
custom: boolean;
submitted: boolean;
}
interface ProductDocumentTemplate {
key: string;
label: string;
hint?: string;
}
/** What each product needs beyond the generic KYC checklist -- a Housing or Kisan Credit loan is
* secured against a specific asset (property / agricultural land), so the paperwork proving and
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
* borrower's own identity/income proof. */
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
HL: [
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
{ key: 'propertyElectricityBill', label: 'Electricity Bill (of the Property)' },
{
key: 'coOwnerKyc',
label: 'KYC Documents of Co-Owners',
hint: 'If the registry lists a second co-owner, use "Add More Doc" below for their KYC too'
},
{ key: 'mutationExtract', label: 'Property Mutation Extract (Namantaran)' },
{ key: 'noDuesCertificate', label: 'No-Dues Certificate' },
{ key: 'encumbranceCertificate', label: 'Encumbrance Certificate (EC)' },
{ key: 'approvedBuildingPlan', label: 'Approved Building Plan / Sanctioned Layout' }
],
KCC: [
{ key: 'landRegistry', label: 'Land Registry / Title Deed' },
{ key: 'khasraKhatauni', label: 'Khasra-Khatauni (Land Revenue Record)' },
{ key: 'girdawari', label: 'Girdawari (Crop Inspection Report)' },
{ key: 'noDuesOtherBank', label: 'No-Dues Certificate from Other Banks/Societies' }
],
VL: [
{ key: 'vehicleQuotation', label: 'Vehicle Quotation / Proforma Invoice' },
{ key: 'drivingLicense', label: 'Driving License' },
{ key: 'vehicleRC', label: 'RC Copy', hint: 'For a used vehicle' },
{ key: 'vehicleInsurance', label: 'Insurance Copy' }
],
CC: [
{ key: 'udyamRegistration', label: 'Udyam / Business Registration Certificate' },
{ key: 'gstCertificate', label: 'GST Registration Certificate' },
{ key: 'businessFinancials', label: "Last 2 Years' Business Financial Statements" },
{ key: 'businessAddressProof', label: 'Business Address Proof' },
{ key: 'partnershipDeed', label: 'Partnership Deed / MOA-AOA', hint: 'If applicable' }
],
EL: [
{ key: 'admissionLetter', label: 'Admission Letter from Institution' },
{ key: 'feeStructure', label: 'Fee Structure / Schedule' },
{ key: 'marksheets', label: 'Previous Qualifying Exam Marksheets' },
{ key: 'coBorrowerKyc', label: 'Co-Borrower (Parent/Guardian) KYC & Income Proof' }
],
GL: [
{ key: 'goldValuation', label: 'Gold Appraisal / Valuation Certificate' },
{ key: 'goldPurchaseInvoice', label: 'Purchase Invoice of Gold Ornaments', hint: 'If available' }
],
PL: [{ key: 'employmentProof', label: 'Employment Proof / Appointment Letter' }]
};
export function buildProductDocuments(productCode: string): ProductDocumentEntry[] {
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
}
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
* as the product-specific documents above, since a specific matter can always need one more
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
const templates: ProductDocumentTemplate[] = [
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
];
if (secured) {
templates.push(
{ key: 'mortgageDeed', label: 'Mortgage Deed' },
{ key: 'titleSearchReport', label: 'Title Search Report / Legal Opinion' }
);
}
templates.push({ key: 'fileFilingAck', label: 'File Filing Acknowledgement' });
return templates.map((doc) => ({ ...doc, custom: false, submitted: false }));
}
const FIRST_NAMES = [
'Ramesh',
'Suresh',
'Mahesh',
'Ganesh',
'Vijay',
'Sanjay',
'Ajay',
'Prakash',
'Sunita',
'Anita',
'Kavita',
'Savita',
'Rekha',
'Meena',
'Pooja',
'Sangita',
'Ravindra',
'Mahendra',
'Narendra',
'Jitendra',
'Bhagwan',
'Vasant',
'Yogesh',
'Kiran',
'Manisha',
'Deepak',
'Sandeep',
'Pradeep',
'Ashok',
'Vishal',
'Nitin',
'Amol'
];
const LAST_NAMES = [
'Patil',
'More',
'Jadhav',
'Deshmukh',
'Pawar',
'Kulkarni',
'Shinde',
'Gaikwad',
'Chavan',
'Bhosale',
'Kale',
'Salunkhe',
'Sawant',
'Kadam',
'Joshi',
'Wagh',
'Thorat',
'Waghmare',
'Mane',
'Shaikh'
];
const BC_AGENTS = [
'Suvidha BC Point — Andheri',
'Grameen Seva Kendra — Karad',
'Jan Suraksha BC Point — Satara',
'Sahyog BC Kendra — Solapur',
'Bank Mitra Point — Kolhapur',
'Digital Seva Kendra — Sangli'
];
export const MANAGERS = [
'Anil Deshpande (Branch Manager)',
'Sunil Kadam (Branch Manager)',
'Vikas Pathak (Credit Manager)',
'Rajendra More (Credit Manager)'
];
const RISK_OFFICERS = [
'Sachin Bhagat (Risk Officer)',
'Prashant Jagtap (Risk Officer)',
'Milind Kulkarni (Risk Officer)'
];
const LEGAL_OFFICERS = [
'Adv. Ravi Ghadge (Panel Advocate)',
'Adv. Shubhada Rane (Panel Advocate)'
];
const FI_AGENCIES = [
'SecureCheck Verification Services',
'TrustLine FI Agency',
'VerifyIndia Field Services'
];
export const PROPERTY_TYPES = [
'Residential',
'Commercial',
'Plot',
'Under Construction'
];
const VALUERS = [
'Apex Valuers & Engineers',
'Prime Property Valuation Services',
'National Valuation Associates',
'Metro Approved Valuers'
];
// -------------------------------------------------------------------------
// Delegation of Powers — sanctioning authority by loan amount, exactly the kind
// of amount-based approval matrix every Indian bank/co-operative society runs.
// -------------------------------------------------------------------------
export function sanctioningAuthority(amount: number): string {
if (amount <= 500000) {
return 'Branch Manager';
}
if (amount <= 2500000) {
return 'Regional Credit Committee';
}
return 'Head Office Credit Committee';
}
/** RBI/credit-bureau-standard CIBIL score bands. */
export function cibilBand(score: number): 'Excellent' | 'Good' | 'Fair' | 'Poor' {
if (score >= 750) {
return 'Excellent';
}
if (score >= 650) {
return 'Good';
}
if (score >= 550) {
return 'Fair';
}
return 'Poor';
}
export function progressPct(app: LoanApplication): number {
if (app.overallStatus === 'DISBURSED') {
return 100;
}
return round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
}
export function stageBadgeClass(app: LoanApplication): string {
if (app.overallStatus === 'REJECTED') {
return 'loan-badge loan-badge--rejected';
}
if (app.overallStatus === 'ON_HOLD') {
return 'loan-badge loan-badge--hold';
}
if (app.overallStatus === 'DISBURSED') {
return 'loan-badge loan-badge--disbursed';
}
return 'loan-badge loan-badge--progress';
}
function blankVerification(): VerificationCheck {
return {
status: 'PENDING',
verifiedBy: '—',
verifiedDate: '—',
remarks: '—',
officerAssigned: '—',
assignedDate: '—',
assignedAt: 0,
latitude: '',
longitude: '',
photoCount: 0
};
}
function blankApproval(): ApprovalRecord {
return { decision: 'PENDING', approverName: '—', approverRole: '—', date: '—', remarks: '—' };
}
// -------------------------------------------------------------------------
// Application generator — walks a fresh application through STAGE_ORDER up to
// a target stage (weighted, funnel-shaped: more applications sit at the early
// stages than the late ones), filling in realistic field values and a matching
// stageHistory entry for every stage actually completed. Applications beyond
// the target stage are left at their PENDING defaults — never partially filled
// — so the record always looks internally consistent with where it really is.
// -------------------------------------------------------------------------
interface StageWeight {
outcome: 'IN_PROGRESS' | 'ON_HOLD' | 'REJECTED' | 'DISBURSED';
/** Index into STAGE_ORDER the application is frozen at / progressed to. */
atStage: number;
weight: number;
}
const OUTCOME_BUCKETS: StageWeight[] = [
{ outcome: 'IN_PROGRESS', atStage: 0, weight: 10 }, // APPLICATION
{ outcome: 'IN_PROGRESS', atStage: 1, weight: 15 }, // KYC_DOCUMENTS
{ outcome: 'IN_PROGRESS', atStage: 2, weight: 12 }, // CIC_CHECK
{ outcome: 'IN_PROGRESS', atStage: 3, weight: 9 }, // FIELD_INVESTIGATION
{ outcome: 'ON_HOLD', atStage: 4, weight: 5 }, // PERSONAL_DISCUSSION, flagged
{ outcome: 'IN_PROGRESS', atStage: 5, weight: 8 }, // CREDIT_COMMITTEE
{ outcome: 'REJECTED', atStage: 5, weight: 3 }, // rejected at Committee
{ outcome: 'IN_PROGRESS', atStage: 6, weight: 6 }, // RISK_APPROVAL
{ outcome: 'REJECTED', atStage: 6, weight: 2 }, // rejected at Risk
{ outcome: 'IN_PROGRESS', atStage: 7, weight: 5 }, // LEGAL_APPROVAL
{ outcome: 'IN_PROGRESS', atStage: 8, weight: 3 }, // CUSTOMER_SIGNING
{ outcome: 'IN_PROGRESS', atStage: 9, weight: 2 }, // CHEQUE_ACH
{ outcome: 'IN_PROGRESS', atStage: 10, weight: 2 }, // POST_CHECK
{ outcome: 'DISBURSED', atStage: 11, weight: 18 } // fully disbursed
];
const OUTCOME_TOTAL_WEIGHT = OUTCOME_BUCKETS.reduce((s, b) => s + b.weight, 0);
function pickOutcomeBucket(): StageWeight {
let r = rng() * OUTCOME_TOTAL_WEIGHT;
for (const b of OUTCOME_BUCKETS) {
if (r < b.weight) {
return b;
}
r -= b.weight;
}
return OUTCOME_BUCKETS[0];
}
function generateApplications(count: number): LoanApplication[] {
const today = new Date();
const apps: LoanApplication[] = [];
for (let i = 0; i < count; i++) {
const bucket = pickOutcomeBucket();
const branch = pick(BRANCHES);
const product = pick(ORIGINATION_PRODUCTS);
const channel: Channel = pick<Channel>([
'BRANCH',
'BRANCH',
'BC',
'SELF_APPLY'
]);
const customerType: CustomerType = rng() < 0.55 ? 'EXISTING' : 'NEW';
const requestedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000;
const tenureMonths = round(between(product.minTenure, product.maxTenure));
const interestRate = +between(product.minRate, product.maxRate).toFixed(2);
// Reverse-engineered so eligibility usually — but not always — comfortably covers what was
// asked for, the same way a real income/FOIR check sometimes comes up short.
const monthlyIncome = round((requestedAmount / product.incomeMultiplier) * between(0.85, 1.35));
const eligibleLoanAmount = round(monthlyIncome * product.incomeMultiplier);
// Bias CIBIL lower for applications that end up rejected at Committee/Risk — a poor score is
// the single most common real-world rejection reason.
const rejectedForCredit = bucket.outcome === 'REJECTED' && rng() < 0.7;
const cibilScore = rejectedForCredit ? round(between(400, 620)) : round(between(650, 850));
// Enough runway for every stage this application actually reaches, each a few days apart,
// plus a bit of slack — otherwise a far-progressed application could need an application
// date in the future to fit all its stage-history entries before today.
// Each stage after APPLICATION advances the cursor by up to 5 days (see the walk below), so
// the runway must comfortably exceed that worst case — otherwise a far-progressed
// application's stage-history could walk past today, which would look wrong on any date.
const stagesToWalk = bucket.atStage + 1;
const minRunwayDays = stagesToWalk * 5 + 10;
const applicationDate = fmt(addDays(today, -round(between(minRunwayDays, minRunwayDays + 90))));
const seq = String(20000 + i).padStart(6, '0');
const applicantName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`;
const manager = pick(MANAGERS);
const riskOfficer = pick(RISK_OFFICERS);
const legalOfficer = pick(LEGAL_OFFICERS);
const fiAgency = pick(FI_AGENCIES);
// Reverse-engineered the same way monthlyIncome is -- area × rate lands close to a plausible
// multiple of the requested amount, rather than picking propertyValuation directly and having
// no consistent area/rate breakdown behind it.
const propertyBuiltUpAreaSqFt = product.secured ? round(between(600, 2400)) : 0;
const propertyMarketRatePerSqFt = product.secured
? round((requestedAmount * between(1.1, 1.4)) / propertyBuiltUpAreaSqFt)
: 0;
const app: LoanApplication = {
applicationNo: `LN${branch.code.slice(2)}${product.code}${seq}`,
applicationDate,
applicantName,
branchCode: branch.code,
branchName: branch.name,
channel,
bcAgentName: channel === 'BC' ? pick(BC_AGENTS) : '—',
customerType,
productCode: product.code,
productName: product.name,
productType: pick(allowedProductTypes(product.code)),
secured: product.secured,
requestedAmount,
tenureMonths,
interestRate,
isShareMember: customerType === 'EXISTING' && rng() < 0.85,
shareCertNo: '—',
shareMemberSince: '—',
aadhaarVerified: false,
panVerified: false,
incomeProofSubmitted: false,
bankStatementSubmitted: false,
electricityBillSubmitted: false,
productDocuments: buildProductDocuments(product.code),
monthlyIncome,
eligibleLoanAmount,
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
coBorrowerName: '—',
cicConsentSigned: false,
cicChargeCollected: false,
cicChargeMode: null,
cicChargeAccountLabel: '—',
cicPaymentLinkUrl: '—',
cicPaymentReference: '—',
cicBureau: pick(CIC_BUREAUS),
cibilScore,
cicReportUploaded: false,
cicReportFileName: '—',
cicUploadDate: '—',
cicAnalysisSource: 'NONE',
fiRequestPushed: false,
fiRequestDate: '—',
fiAgencyAssigned: '—',
addressVerification: blankVerification(),
officeVerification: blankVerification(),
propertyVerification: blankVerification(),
hasShop: rng() < 0.3,
shopVerification: blankVerification(),
propertyType: product.secured ? pick(PROPERTY_TYPES) : '—',
builtUpAreaSqFt: propertyBuiltUpAreaSqFt,
marketRatePerSqFt: propertyMarketRatePerSqFt,
guidelineRatePerSqFt: product.secured ? round(propertyMarketRatePerSqFt * between(0.6, 0.85)) : 0,
valuerName: product.secured ? pick(VALUERS) : '—',
valuationDate: product.secured ? fmt(addDays(today, -round(between(5, 60)))) : '—',
propertyValuation: product.secured ? propertyBuiltUpAreaSqFt * propertyMarketRatePerSqFt : 0,
agentReportUploaded: false,
agentReportFileName: '—',
agentReportRemark: '—',
discrepancyType: 'NONE',
meetingScheduled: false,
meetingDate: '',
meetingTime: '',
meetingMode: '',
meetingWith: '',
personalDiscussionNotes: '—',
personalDiscussionDate: '—',
declarationTaken: false,
declarationDate: '—',
guarantorRequired: !product.secured && requestedAmount > 300000,
guarantorClientId: null,
guarantorName: '—',
guarantorHasShareAccount: false,
backgroundCheckDone: false,
cersaiSearchDone: false,
managerMeetingDone: false,
previousLoanDeclarationDone: false,
propertySearchReportDone: false,
valuationReportConfirmed: false,
creditCommitteeApproval: blankApproval(),
committeeResolutionNo: '',
committeeResolutionDate: '',
riskApproval: blankApproval(),
legalApproval: blankApproval(),
postCheckApproval: blankApproval(),
feesAndAccountOpened: false,
mortgageCreated: false,
legalStampDone: false,
fileFilingDone: false,
agreementSigningSlot: '—',
legalDocuments: buildLegalDocuments(product.secured),
customerSigningDate: '—',
chequeReceived: false,
achSigned: false,
signedByAgent: false,
signedByManager: false,
signedByCommittee: false,
signedByRiskApproval: false,
signedByLegalApproval: false,
disbursementDate: '—',
disbursementMode: '—',
achConfirmationRef: '—',
emiActivationConfirmed: false,
emiActivationDate: '—',
currentStage: 'APPLICATION',
furthestStageIndex: 0,
overallStatus: 'IN_PROGRESS',
stageHistory: []
};
if (app.coBorrowerRequired) {
app.coBorrowerName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)} (Co-Borrower)`;
}
if (app.guarantorRequired) {
app.guarantorName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)} (Guarantor)`;
app.guarantorHasShareAccount = true;
}
// Membership pre-dates the application itself (you join the society, then later apply for a
// loan) — so it's generated unconditionally here, never gated on how far this particular
// application has progressed.
if (app.isShareMember) {
app.shareCertNo = `SC-${branch.code}-${round(between(1000, 9999))}`;
app.shareMemberSince = fmt(addDays(parseDMY(applicationDate), -round(between(60, 1800))));
}
// Walk the application through every stage up to and including bucket.atStage, filling in
// the fields that stage's completion would actually set and logging a stageHistory entry —
// exactly what "Approve" on each stage does at runtime, replayed here at generation time.
let cursor = parseDMY(applicationDate);
app.stageHistory.push({
stage: 'APPLICATION',
date: applicationDate,
actionBy: channel === 'BC' ? app.bcAgentName : channel === 'SELF_APPLY' ? 'Customer (Digital)' : manager,
outcome: 'Received',
remarks: `Application received via ${CHANNEL_LABELS[channel]}.`
});
for (let s = 1; s <= bucket.atStage; s++) {
cursor = addDays(cursor, round(between(1, 5)));
const stage = STAGE_ORDER[s];
const isRejectionStage =
bucket.outcome === 'REJECTED' &&
s === bucket.atStage &&
(stage === 'CREDIT_COMMITTEE' || stage === 'RISK_APPROVAL');
const isHoldStage = bucket.outcome === 'ON_HOLD' && s === bucket.atStage && stage === 'PERSONAL_DISCUSSION';
switch (stage) {
case 'KYC_DOCUMENTS':
app.aadhaarVerified = true;
app.panVerified = true;
app.incomeProofSubmitted = true;
app.bankStatementSubmitted = true;
app.electricityBillSubmitted = true;
app.productDocuments.forEach((doc) => (doc.submitted = true));
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: 'Completed',
remarks:
'Aadhaar/PAN (OVD) verified, Aadhaar fingerprint captured, income proof and bank statement collected.'
});
break;
case 'CIC_CHECK':
app.cicConsentSigned = true;
app.cicChargeCollected = true;
app.cicChargeMode = app.customerType === 'EXISTING' ? 'ACCOUNT_DEBIT' : 'PAYMENT_LINK';
app.cicChargeAccountLabel = app.customerType === 'EXISTING' ? 'Existing account on file' : '—';
app.cicPaymentReference = app.customerType === 'EXISTING' ? '—' : `PAY-${app.applicationNo}`;
app.cicReportUploaded = true;
app.cicReportFileName = `${app.cicBureau.replace(/\s+/g, '')}_${app.applicationNo}.pdf`;
app.cicUploadDate = fmt(cursor);
app.fiRequestPushed = true;
app.fiRequestDate = fmt(cursor);
app.fiAgencyAssigned = fiAgency;
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: `${app.cicBureau} Score ${app.cibilScore}`,
remarks: `${app.cicBureau} credit report pulled and uploaded — score ${app.cibilScore} (${cibilBand(app.cibilScore)}). Field Inquiry pushed to ${fiAgency}.`
});
break;
case 'FIELD_INVESTIGATION':
app.addressVerification = {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
remarks: 'Registered address confirmed on physical visit.',
officerAssigned: fiAgency,
assignedDate: fmt(cursor),
assignedAt: cursor.getTime(),
latitude: (23.2 + rng() * 5).toFixed(6),
longitude: (77.4 + rng() * 5).toFixed(6),
photoCount: 2 + Math.floor(rng() * 3)
};
app.officeVerification = {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
remarks: 'Employment/business confirmed — Agent Report filed.',
officerAssigned: fiAgency,
assignedDate: fmt(cursor),
assignedAt: cursor.getTime(),
latitude: (23.2 + rng() * 5).toFixed(6),
longitude: (77.4 + rng() * 5).toFixed(6),
photoCount: 2 + Math.floor(rng() * 3)
};
if (product.secured) {
app.propertyVerification = {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.`,
officerAssigned: fiAgency,
assignedDate: fmt(cursor),
assignedAt: cursor.getTime(),
latitude: (23.2 + rng() * 5).toFixed(6),
longitude: (77.4 + rng() * 5).toFixed(6),
photoCount: 2 + Math.floor(rng() * 3)
};
}
if (app.hasShop) {
app.shopVerification = {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
remarks: 'Shop/business premises visited and confirmed operational.',
officerAssigned: fiAgency,
assignedDate: fmt(cursor),
assignedAt: cursor.getTime(),
latitude: (23.2 + rng() * 5).toFixed(6),
longitude: (77.4 + rng() * 5).toFixed(6),
photoCount: 2 + Math.floor(rng() * 3)
};
}
app.agentReportUploaded = true;
app.agentReportFileName = `AgentReport_${app.applicationNo}.pdf`;
app.agentReportRemark = 'All addresses visited and cross-verified — no discrepancies noted.';
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: fiAgency,
outcome: 'Verified',
remarks: product.secured
? 'Address, office and property field investigation completed — Agent Report uploaded with valuation.'
: 'Address and office field investigation completed — Agent Report uploaded.'
});
break;
case 'PERSONAL_DISCUSSION':
app.meetingScheduled = true;
app.meetingDate = fmt(cursor);
app.meetingTime = `${String(10 + Math.floor(rng() * 6)).padStart(2, '0')}:${rng() < 0.5 ? '00' : '30'}`;
app.meetingMode = pick<'ONLINE' | 'OFFLINE' | 'CALL'>([
'ONLINE',
'OFFLINE',
'CALL'
]);
app.meetingWith = manager;
if (isHoldStage) {
app.discrepancyType = pick<DiscrepancyType>([
'DOCUMENT_DISCREPANCY',
'NAME_MISMATCH',
'OFFICE_VERIFICATION_FAILED',
'PROPERTY_DISCREPANCY',
'FI_REPORT_DISCREPANCY',
'FAKE_DECLARATION'
]);
app.personalDiscussionDate = fmt(cursor);
app.personalDiscussionNotes = `${DISCREPANCY_LABELS[app.discrepancyType]} flagged during personal discussion with ${manager} — held pending resolution.`;
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: 'On Hold',
remarks: app.personalDiscussionNotes
});
} else {
app.personalDiscussionDate = fmt(cursor);
app.personalDiscussionNotes = 'No discrepancy found — cleared to proceed to Loan Committee.';
app.declarationTaken = true;
app.declarationDate = fmt(cursor);
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: 'Cleared',
remarks: `${app.personalDiscussionNotes} Customer declaration taken.`
});
}
break;
case 'CREDIT_COMMITTEE':
app.backgroundCheckDone = true;
app.cersaiSearchDone = product.secured;
app.managerMeetingDone = true;
if (isRejectionStage) {
// Only blame the credit score when it's actually weak — a rejection alongside a
// perfectly good score would read as a contradiction ("Good credit score... below
// policy threshold"), so a decent-score decline cites a non-score reason instead.
const scoreIsWeak = app.cibilScore < 650;
app.creditCommitteeApproval = {
decision: 'REJECTED',
approverName: sanctioningAuthority(app.requestedAmount),
approverRole: 'Sanctioning Authority',
date: fmt(cursor),
remarks: scoreIsWeak
? `Declined — ${cibilBand(app.cibilScore)} credit score (${app.cibilScore}) below policy threshold.`
: product.secured
? `Declined — background check and CSE findings did not meet the committee's risk appetite, despite an adequate credit score (${app.cibilScore}).`
: `Declined — background check findings did not meet the committee's risk appetite, despite an adequate credit score (${app.cibilScore}).`
};
} else {
app.committeeResolutionNo = `LC/${cursor.getFullYear()}/${String(round(between(100, 9999))).padStart(4, '0')}`;
app.committeeResolutionDate = fmt(cursor);
app.creditCommitteeApproval = {
decision: 'APPROVED',
approverName: sanctioningAuthority(app.requestedAmount),
approverRole: 'Sanctioning Authority',
date: fmt(cursor),
remarks: `${
product.secured
? 'CIBIL report, background check, CSE, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.'
: 'CIBIL report, background check, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.'
} (Resolution No. ${app.committeeResolutionNo} dated ${app.committeeResolutionDate})`
};
}
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: app.creditCommitteeApproval.approverName,
outcome: APPROVAL_LABELS[app.creditCommitteeApproval.decision],
remarks: app.creditCommitteeApproval.remarks
});
break;
case 'RISK_APPROVAL':
app.previousLoanDeclarationDone = true;
app.propertySearchReportDone = product.secured;
app.valuationReportConfirmed = product.secured;
if (isRejectionStage) {
// A guarantor requirement only applies to unsecured loans above the threshold amount
// (see guarantorRequired above); a secured loan has no guarantor to speak of, so its
// decline reason is about the security cover instead — never both in the same remark.
app.riskApproval = {
decision: 'REJECTED',
approverName: riskOfficer,
approverRole: 'Risk Approval',
date: fmt(cursor),
remarks: app.guarantorRequired
? 'Guarantor requirement not met — no eligible guarantor furnished.'
: 'Security cover inadequate relative to the outstanding exposure.'
};
} else {
app.riskApproval = {
decision: 'APPROVED',
approverName: riskOfficer,
approverRole: 'Risk Approval',
date: fmt(cursor),
remarks: product.secured
? 'ID/address and income proof verified, previous-loan declaration, property search and valuation report, FI report and personal background check all clear.'
: 'ID/address and income proof verified, previous-loan declaration, FI report and personal background check all clear.'
};
}
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: riskOfficer,
outcome: APPROVAL_LABELS[app.riskApproval.decision],
remarks: app.riskApproval.remarks
});
break;
case 'LEGAL_APPROVAL':
app.feesAndAccountOpened = true;
app.mortgageCreated = product.secured;
app.legalStampDone = true;
app.fileFilingDone = true;
app.agreementSigningSlot = fmt(addDays(cursor, 2));
app.legalDocuments.forEach((doc) => (doc.submitted = true));
app.legalApproval = {
decision: 'APPROVED',
approverName: legalOfficer,
approverRole: 'Legal Approval',
date: fmt(cursor),
remarks: product.secured
? 'Mortgage created, legal stamp and file filing completed.'
: 'Legal stamp and file filing completed; loan agreement cleared for signing.'
};
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: legalOfficer,
outcome: 'Approved',
remarks: `${app.legalApproval.remarks} Signing slot allotted for ${app.agreementSigningSlot}.`
});
break;
case 'CUSTOMER_SIGNING':
app.customerSigningDate = fmt(cursor);
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: app.applicantName,
outcome: 'Signed',
remarks: 'Loan agreement and sanction letter acknowledgement signed by customer.'
});
break;
case 'CHEQUE_ACH':
app.chequeReceived = true;
app.achSigned = true;
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: 'Confirmed',
remarks: 'Disbursement cheque prepared and ACH mandate signed.'
});
break;
case 'POST_CHECK':
app.signedByAgent = true;
app.signedByManager = true;
app.signedByCommittee = true;
app.signedByRiskApproval = true;
app.signedByLegalApproval = true;
app.postCheckApproval = {
decision: 'APPROVED',
approverName: manager,
approverRole: 'Post-Disbursement Check',
date: fmt(cursor),
remarks:
'Document signing by Agent, Manager, Committee, Risk Approval and Legal Approval all verified on file.'
};
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: 'Cleared',
remarks: app.postCheckApproval.remarks
});
break;
case 'DISBURSED':
app.disbursementDate = fmt(cursor);
app.disbursementMode = pick([
'Account Credit',
'NEFT',
'RTGS'
]);
app.achConfirmationRef = `ACH${app.applicationNo.slice(-6)}${round(between(100, 999))}`;
app.emiActivationConfirmed = true;
app.emiActivationDate = fmt(cursor);
app.stageHistory.push({
stage,
date: fmt(cursor),
actionBy: manager,
outcome: 'Disbursed',
remarks: `Disbursed via ${app.disbursementMode} — ACH confirmation ${app.achConfirmationRef}. EMI activation confirmed.`
});
break;
default:
break;
}
}
app.currentStage = STAGE_ORDER[bucket.atStage];
app.furthestStageIndex = bucket.atStage;
app.overallStatus = bucket.outcome;
apps.push(app);
}
return apps;
}
// Sized like a real cooperative bank's active sanctioning pipeline — enough to need filters
// and pagination, not so many it stops reading as "an active queue".
export const LOAN_APPLICATIONS: LoanApplication[] = generateApplications(140);
// -------------------------------------------------------------------------
// Runtime actions — every button in the Application Detail screen calls one of these to mutate
// the application in place and append a stageHistory entry, so the audit trail is always exactly
// what actually happened, in order, never a separately-maintained log that could drift.
// -------------------------------------------------------------------------
function recordStageProgress(app: LoanApplication, actionBy: string, outcome: string, remarks: string): void {
app.stageHistory.push({ stage: app.currentStage, date: todayDDMMYYYY(), actionBy, outcome, remarks });
}
/** Moves `currentStage` forward and keeps `furthestStageIndex` in sync with it -- the one place
* every genuine forward move goes through, so the stepper never forgets how far the application
* actually got even after `moveToStage` below sends it back for a correction. */
function advanceStage(app: LoanApplication, newStage: OriginationStage): void {
app.currentStage = newStage;
app.furthestStageIndex = Math.max(app.furthestStageIndex, stageIndex(newStage));
}
/** Generic "this stage is done, move to the next one" action — used by every stage that doesn't
* carry its own ApprovalRecord (Application, KYC, CIC, Field Investigation, Customer Signing,
* Cheque/ACH). */
export function proceedToNextStage(app: LoanApplication, actionBy: string, remarks: string): void {
recordStageProgress(app, actionBy, 'Completed', remarks);
const next = nextStage(app.currentStage);
if (next) {
advanceStage(app, next);
}
}
/** Lets an officer correct course -- jump the application straight to any stage it has already
* reached (clicking that stage's dot in the stepper), whether that stage is currently behind or
* ahead of `currentStage`. Only `currentStage` moves; every checkbox, upload, score and approval
* already recorded on the application is left exactly as it was, so nothing has to be re-entered
* after a correction, and jumping back then forward again doesn't lose the spot the application
* was actually at -- `furthestStageIndex` is deliberately left untouched here. Refuses to move
* to a stage the application has never actually reached, so this can never be used to skip
* ahead into unfinished territory. */
export function moveToStage(app: LoanApplication, stage: OriginationStage, actionBy: string): void {
const targetIdx = stageIndex(stage);
if (targetIdx < 0 || targetIdx > app.furthestStageIndex || stage === app.currentStage) {
return;
}
const goingBack = targetIdx < stageIndex(app.currentStage);
recordStageProgress(
app,
actionBy,
goingBack ? 'Sent Back' : 'Returned',
`${goingBack ? 'Sent back' : 'Returned'} to "${STAGE_LABELS[stage]}" for review/correction.`
);
app.currentStage = stage;
}
/** Books the Personal Discussion appointment -- date, time, mode and which bank officer is
* taking it -- before any discussion notes exist, so there's a real scheduled meeting on record
* rather than a discussion report just appearing with no appointment behind it. */
export function scheduleMeeting(
app: LoanApplication,
date: string,
time: string,
mode: 'ONLINE' | 'OFFLINE' | 'CALL',
withWhom: string,
actionBy: string
): void {
app.meetingScheduled = true;
app.meetingDate = date;
app.meetingTime = time;
app.meetingMode = mode;
app.meetingWith = withWhom;
recordStageProgress(
app,
actionBy,
'Meeting Scheduled',
`Personal Discussion scheduled with ${withWhom} on ${date} ${time} (${mode}).`
);
}
export function flagDiscrepancy(app: LoanApplication, type: DiscrepancyType, actionBy: string, notes: string): void {
app.discrepancyType = type;
app.personalDiscussionDate = todayDDMMYYYY();
app.personalDiscussionNotes = notes;
app.overallStatus = 'ON_HOLD';
recordStageProgress(app, actionBy, 'On Hold', notes);
}
export function resolveDiscrepancyAndProceed(app: LoanApplication, actionBy: string, remarks: string): void {
app.discrepancyType = 'NONE';
app.overallStatus = 'IN_PROGRESS';
app.personalDiscussionNotes = remarks;
app.declarationTaken = true;
app.declarationDate = todayDDMMYYYY();
proceedToNextStage(app, actionBy, `${remarks} Customer declaration taken.`);
}
/** Clears Personal Discussion with no discrepancy found — the note's "after discussion, take
* declaration" step happens right here, as part of the same action. */
export function clearPersonalDiscussionAndProceed(app: LoanApplication, actionBy: string, remarks: string): void {
app.personalDiscussionDate = todayDDMMYYYY();
app.personalDiscussionNotes = remarks;
app.declarationTaken = true;
app.declarationDate = todayDDMMYYYY();
proceedToNextStage(app, actionBy, `${remarks} Customer declaration taken.`);
}
export type ApprovalField = 'creditCommitteeApproval' | 'riskApproval' | 'legalApproval' | 'postCheckApproval';
/** Records an Approved/Rejected/Exception-Approved decision against whichever approval stage is
* currently active, and either advances the pipeline (Approved) or freezes it (Rejected) — the
* same segregation-of-duties gate every one of Credit Committee / Risk / Legal / Post-Check runs. */
export function decideApproval(
app: LoanApplication,
field: ApprovalField,
decision: ApprovalDecision,
approverName: string,
approverRole: string,
remarks: string
): void {
app[field] = { decision, approverName, approverRole, date: todayDDMMYYYY(), remarks };
if (decision === 'REJECTED') {
app.overallStatus = 'REJECTED';
recordStageProgress(app, approverName, 'Rejected', remarks);
return;
}
recordStageProgress(app, approverName, APPROVAL_LABELS[decision], remarks);
const next = nextStage(app.currentStage);
if (next) {
advanceStage(app, next);
}
}
/**
* Pulls the applicant's score from the selected bureau — the actual "CIBIL check" a real CBS
* fires as an API call to the bureau. This is the step that turns `cibilScore` from 0 (never
* checked) into a real number; only after this can the physical/PDF report be uploaded as
* documentary evidence, and Field Inquiry be pushed to an agency.
*/
export function checkCreditScore(app: LoanApplication, actionBy: string): void {
if ((app.cicReportFileName && /Experian/i.test(app.cicReportFileName)) || app.cicBureau === 'Experian') {
app.cicBureau = 'Experian';
app.cibilScore = 748;
} else if (app.cibilScore === 0 || app.cibilScore === 300) {
app.cibilScore = Math.round(680 + Math.random() * 140);
}
recordStageProgress(
app,
actionBy,
`${app.cicBureau} Score ${app.cibilScore}`,
`${app.cicBureau} credit score checked — ${app.cibilScore} (${cibilBand(app.cibilScore)}).`
);
}
export function uploadCicReport(app: LoanApplication, fileName: string, actionBy: string): void {
app.cicReportUploaded = true;
app.cicReportFileName = fileName;
app.cicUploadDate = todayDDMMYYYY();
if (/Experian/i.test(fileName)) {
app.cicBureau = 'Experian';
app.cibilScore = 748;
} else if (/CIBIL/i.test(fileName)) {
app.cicBureau = 'CIBIL';
app.cibilScore = 750;
} else if (/Equifax/i.test(fileName)) {
app.cicBureau = 'Equifax';
app.cibilScore = 743;
} else if (/CRIF|HighMark/i.test(fileName)) {
app.cicBureau = 'CRIF High Mark';
app.cibilScore = 760;
}
recordStageProgress(
app,
actionBy,
'Uploaded',
`${app.cicBureau} credit report "${fileName}" uploaded — score ${app.cibilScore} (${cibilBand(app.cibilScore)}).`
);
}
/** Dispatches the Field Inquiry request to a field agency — the "push" step that happens during
* the CIC/Credit Check stage, before Field Investigation itself records what comes back. */
export function pushFieldInquiry(app: LoanApplication, agencyName: string, actionBy: string): void {
app.fiRequestPushed = true;
app.fiRequestDate = todayDDMMYYYY();
app.fiAgencyAssigned = agencyName;
recordStageProgress(app, actionBy, 'FI Pushed', `Field Inquiry request pushed to ${agencyName}.`);
}
export function uploadAgentReport(app: LoanApplication, fileName: string, remark: string, actionBy: string): void {
app.agentReportUploaded = true;
app.agentReportFileName = fileName;
app.agentReportRemark = remark || 'Agent Report filed.';
recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded — ${app.agentReportRemark}`);
}
export type FiCheckKey = 'addressVerification' | 'officeVerification' | 'propertyVerification' | 'shopVerification';
export const FI_CHECK_LABELS: Record<FiCheckKey, string> = {
addressVerification: 'Home Address',
officeVerification: 'Office / Employment Address',
propertyVerification: 'Property Address',
shopVerification: 'Shop / Business Premises'
};
/** Sends a field officer out to physically visit and verify one of the 3 FI addresses -- resets
* that address's whole visit cycle (GPS, photo count, prior remarks) fresh, since a newly
* assigned visit (including a retry after a FAILED one) starts the 48-hour SLA clock over. */
export function assignFieldOfficer(app: LoanApplication, which: FiCheckKey, officerName: string): void {
app[which] = {
status: 'PENDING',
verifiedBy: '—',
verifiedDate: '—',
remarks: '—',
officerAssigned: officerName,
assignedDate: todayDDMMYYYY(),
assignedAt: Date.now(),
latitude: '',
longitude: '',
photoCount: 0
};
}
export function recordVerification(
app: LoanApplication,
which: FiCheckKey,
status: VerificationStatus,
verifiedBy: string,
remarks: string
): void {
app[which] = { ...app[which], status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks };
}
/** Cooperative-bank membership can be allotted independently of the loan's own progress — a
* branch can sign someone up as a member at any point in the review, not just at Committee. */
/** Eligible Loan Amount = Monthly Income × the product's income multiplier (a rough FOIR-style
* rule of thumb) — the same formula the seeded book uses at generation time, exposed here so a
* freshly-created application (which starts with monthlyIncome/eligibleLoanAmount at 0, since
* income isn't known until KYC collects it) can run the exact same calculation once it is. */
export function calcEligibleAmount(monthlyIncome: number, productCode: string): number {
const product = ORIGINATION_PRODUCTS.find((p) => p.code === productCode);
return product ? Math.round(monthlyIncome * product.incomeMultiplier) : 0;
}
/** Records the Monthly Income collected during KYC and derives the Eligible Loan Amount from
* it — also re-checks whether a co-borrower is now required (income might not cover what was
* requested), same rule the seeded book applies at generation time. */
export function setMonthlyIncome(app: LoanApplication, monthlyIncome: number, actionBy: string): void {
app.monthlyIncome = monthlyIncome;
app.eligibleLoanAmount = calcEligibleAmount(monthlyIncome, app.productCode);
app.coBorrowerRequired = app.eligibleLoanAmount < app.requestedAmount;
if (app.coBorrowerRequired && app.coBorrowerName === '—') {
app.coBorrowerName = 'To be added';
}
recordStageProgress(
app,
actionBy,
'Income Assessed',
`Monthly income ₹${monthlyIncome.toLocaleString('en-IN')} recorded — eligible loan amount ₹${app.eligibleLoanAmount.toLocaleString('en-IN')}.`
);
}
export function allotShareMembership(app: LoanApplication, actionBy: string): void {
app.isShareMember = true;
app.shareCertNo = `SC-${app.branchCode}-${Math.floor(1000 + Math.random() * 8999)}`;
app.shareMemberSince = todayDDMMYYYY();
recordStageProgress(
app,
actionBy,
'Share Membership Allotted',
`Membership shares allotted — Certificate ${app.shareCertNo}.`
);
}
export function markCustomerSigned(app: LoanApplication, actionBy: string): void {
app.customerSigningDate = todayDDMMYYYY();
proceedToNextStage(app, actionBy, 'Loan agreement and sanction letter acknowledgement signed by customer.');
}
export function confirmChequeAch(app: LoanApplication, actionBy: string): void {
app.chequeReceived = true;
app.achSigned = true;
proceedToNextStage(app, actionBy, 'Disbursement cheque prepared and ACH mandate signed.');
}
export function disburseApplication(app: LoanApplication, actionBy: string, mode: string): void {
app.disbursementDate = todayDDMMYYYY();
app.disbursementMode = mode;
app.achConfirmationRef = `ACH${app.applicationNo.slice(-6)}${Math.floor(100 + Math.random() * 899)}`;
app.emiActivationConfirmed = true;
app.emiActivationDate = todayDDMMYYYY();
app.overallStatus = 'DISBURSED';
recordStageProgress(
app,
actionBy,
'Disbursed',
`Disbursed via ${mode} — ACH confirmation ${app.achConfirmationRef}. EMI activation confirmed.`
);
}
// -------------------------------------------------------------------------
// Product Type — Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed
// annually rather than repaid on a fixed instalment schedule).
// -------------------------------------------------------------------------
export type ProductType = 'TERM_LOAN' | 'OVERDRAFT';
export const PRODUCT_TYPE_LABELS: Record<ProductType, string> = {
TERM_LOAN: 'Term Loan',
OVERDRAFT: 'Overdraft'
};
/** Which Product Type(s) a given product can be sanctioned as. Cash Credit and Kisan Credit Card
* are inherently revolving limits, so Overdraft is the only option. Gold Loan is commonly
* written up either way at a UCB -- a straight EMI term loan, or a 1-year overdraft/cash-credit
* limit renewed annually -- so both are offered, defaulting to Overdraft (the more common
* cooperative-bank practice for gold loans). Every other product here is a straight term loan. */
export function allowedProductTypes(productCode: string): ProductType[] {
if (productCode === 'CC' || productCode === 'KCC') {
return ['OVERDRAFT'];
}
if (productCode === 'GL') {
return [
'OVERDRAFT',
'TERM_LOAN'
];
}
return ['TERM_LOAN'];
}
export function defaultProductType(productCode: string): ProductType {
return allowedProductTypes(productCode)[0];
}
// -------------------------------------------------------------------------
// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows
// once a term loan is disbursed. Overdraft-type products are revolving limits renewed annually,
// not repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading
// one (see LoanApplication.productType).
// -------------------------------------------------------------------------
export interface EmiScheduleRow {
installmentNo: number;
dueDate: string;
openingBalance: number;
emiAmount: number;
principalComponent: number;
interestComponent: number;
closingBalance: number;
}
/** Standard reducing-balance EMI formula: EMI = P × r × (1+r)^n / ((1+r)^n − 1), r = monthly rate. */
export function calculateEmi(principal: number, annualRatePct: number, tenureMonths: number): number {
const r = annualRatePct / 12 / 100;
if (r === 0) {
return Math.round(principal / tenureMonths);
}
const factor = Math.pow(1 + r, tenureMonths);
return Math.round((principal * r * factor) / (factor - 1));
}
/** Builds the full month-by-month amortisation schedule. The final instalment absorbs whatever
* rounding drift has accumulated from rounding the EMI/interest every month, so the schedule
* always closes to exactly ₹0 outstanding — never a stray few rupees left over, exactly how a
* real CBS's closing instalment is computed. */
export function buildEmiSchedule(
principal: number,
annualRatePct: number,
tenureMonths: number,
startDate: string
): EmiScheduleRow[] {
const emi = calculateEmi(principal, annualRatePct, tenureMonths);
const r = annualRatePct / 12 / 100;
const start = parseDMY(startDate);
const rows: EmiScheduleRow[] = [];
let balance = principal;
for (let i = 1; i <= tenureMonths; i++) {
const interestComponent = Math.round(balance * r);
const isLast = i === tenureMonths;
const principalComponent = isLast ? balance : Math.min(balance, emi - interestComponent);
const emiAmount = isLast ? principalComponent + interestComponent : emi;
const closingBalance = Math.max(0, balance - principalComponent);
rows.push({
installmentNo: i,
dueDate: fmt(addMonths(start, i)),
openingBalance: balance,
emiAmount,
principalComponent,
interestComponent,
closingBalance
});
balance = closingBalance;
}
return rows;
}
// -------------------------------------------------------------------------
// New Application intake — the Pipeline screen's "New Application" form builds one of these and
// appends it to LOAN_APPLICATIONS, exactly at the APPLICATION stage with everything else pending.
// -------------------------------------------------------------------------
export interface NewApplicationInput {
channel: Channel;
bcAgentName: string;
customerType: CustomerType;
applicantName: string;
clientId?: number | null;
branchCode: string;
branchName: string;
productCode: string;
productName: string;
productType: ProductType;
secured: boolean;
requestedAmount: number;
tenureMonths: number;
interestRate: number;
}
export function createApplication(input: NewApplicationInput, seq: number): LoanApplication {
const actionBy =
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
return {
applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`,
applicationDate: todayDDMMYYYY(),
applicantName: input.applicantName,
branchCode: input.branchCode,
branchName: input.branchName,
channel: input.channel,
bcAgentName: input.channel === 'BC' ? input.bcAgentName : '—',
customerType: input.customerType,
clientId: input.clientId ?? null,
productCode: input.productCode,
productName: input.productName,
productType: input.productType,
secured: input.secured,
requestedAmount: input.requestedAmount,
tenureMonths: input.tenureMonths,
interestRate: input.interestRate,
isShareMember: false,
shareCertNo: '—',
shareMemberSince: '—',
aadhaarVerified: false,
panVerified: false,
incomeProofSubmitted: false,
bankStatementSubmitted: false,
electricityBillSubmitted: false,
productDocuments: buildProductDocuments(input.productCode),
monthlyIncome: 0,
eligibleLoanAmount: 0,
coBorrowerRequired: false,
coBorrowerName: '—',
cicConsentSigned: false,
cicChargeCollected: false,
cicChargeMode: null,
cicChargeAccountLabel: '—',
cicPaymentLinkUrl: '—',
cicPaymentReference: '—',
cicBureau: 'CIBIL',
cibilScore: 0,
cicReportUploaded: false,
cicReportFileName: '—',
cicUploadDate: '—',
cicAnalysisSource: 'NONE',
fiRequestPushed: false,
fiRequestDate: '—',
fiAgencyAssigned: '—',
addressVerification: blankVerification(),
officeVerification: blankVerification(),
propertyVerification: blankVerification(),
hasShop: false,
shopVerification: blankVerification(),
propertyType: '—',
builtUpAreaSqFt: 0,
marketRatePerSqFt: 0,
guidelineRatePerSqFt: 0,
valuerName: '—',
valuationDate: '—',
propertyValuation: 0,
agentReportUploaded: false,
agentReportFileName: '—',
agentReportRemark: '—',
discrepancyType: 'NONE',
meetingScheduled: false,
meetingDate: '',
meetingTime: '',
meetingMode: '',
meetingWith: '',
personalDiscussionNotes: '—',
personalDiscussionDate: '—',
declarationTaken: false,
declarationDate: '—',
guarantorRequired: false,
guarantorClientId: null,
guarantorName: '—',
guarantorHasShareAccount: false,
backgroundCheckDone: false,
cersaiSearchDone: false,
managerMeetingDone: false,
previousLoanDeclarationDone: false,
propertySearchReportDone: false,
valuationReportConfirmed: false,
creditCommitteeApproval: blankApproval(),
committeeResolutionNo: '',
committeeResolutionDate: '',
riskApproval: blankApproval(),
legalApproval: blankApproval(),
postCheckApproval: blankApproval(),
feesAndAccountOpened: false,
mortgageCreated: false,
legalStampDone: false,
fileFilingDone: false,
agreementSigningSlot: '—',
legalDocuments: buildLegalDocuments(input.secured),
customerSigningDate: '—',
chequeReceived: false,
achSigned: false,
signedByAgent: false,
signedByManager: false,
signedByCommittee: false,
signedByRiskApproval: false,
signedByLegalApproval: false,
disbursementDate: '—',
disbursementMode: '—',
achConfirmationRef: '—',
emiActivationConfirmed: false,
emiActivationDate: '—',
currentStage: 'APPLICATION',
furthestStageIndex: 0,
overallStatus: 'IN_PROGRESS',
stageHistory: [
{
stage: 'APPLICATION',
date: todayDDMMYYYY(),
actionBy,
outcome: 'Received',
remarks: `Application received via ${CHANNEL_LABELS[input.channel]}.`
}
]
};
}
// -------------------------------------------------------------------------
// Disbursement Targets — the branch/product/channel business targets a real bank sets each
// cycle (here, annual) and tracks achievement against, same discipline as the Investment
// module's "Portfolio Allocation — Target vs Actual" section.
// -------------------------------------------------------------------------
/** Annual branch-wise disbursement target (₹) — a business-plan number set once per branch,
* independent of how many applications happen to be sitting in the pipeline right now. */
export const BRANCH_DISBURSEMENT_TARGET: Record<string, number> = {
BR01: 40000000,
BR02: 28000000,
BR03: 18000000,
BR04: 16000000,
BR05: 15000000,
BR06: 14000000,
BR07: 13000000,
BR08: 12000000
};
export function branchTargetAmount(branchCode: string): number {
return BRANCH_DISBURSEMENT_TARGET[branchCode] || 0;
}
/**
* Target product mix (% of total disbursement). Two of these are genuinely RBI-mandated
* Priority Sector Lending sub-targets (RBI Master Direction — Priority Sector Lending: Targets
* and Classification, FIDD.CO.Plan.BC.13/04.09.01/2020-21, as extended to UCBs) —
* • Agriculture: 18% of Adjusted Net Bank Credit (ANBC) — mapped here to Kisan Credit Card.
* • Micro Enterprises: 7.5% of ANBC — mapped here to Cash Credit (MSME).
* RBI sets these as a % of the BANK'S WHOLE loan book (ANBC), not of any one product line's own
* disbursement — shown here against this module's own mix as the closest available proxy, since
* this demo has no bank-wide ANBC figure to check against. The remaining five products (Housing,
* Vehicle, Gold, Personal, Education) have no RBI-mandated minimum share of their own — Housing
* and Education are Priority-Sector-*eligible* within RBI's loan-amount ceilings but aren't
* separately targeted, and Vehicle/Gold/Personal aren't Priority Sector categories at all — so
* their split below is an internal business-plan assumption, not a regulatory requirement.
* (RBI's other PSL sub-targets — Weaker Sections 12%, overall PSL 40% — cut across products by
* borrower category rather than by loan type, so they aren't represented as their own row here.)
*/
export const PRODUCT_TARGET_PCT: Record<string, number> = {
PL: 12,
HL: 30,
VL: 15,
GL: 12,
CC: 7.5, // RBI Micro Enterprises PSL sub-target
KCC: 18, // RBI Agriculture PSL sub-target
EL: 5.5
};
/** Target channel mix (% of disbursement) — the bank's push towards BC/Digital origination and
* away from pure branch walk-ins. */
export const CHANNEL_TARGET_PCT: Record<Channel, number> = {
BRANCH: 55,
BC: 30,
SELF_APPLY: 15
};
export type TargetStatus = 'AHEAD' | 'ON_TRACK' | 'BEHIND';
export const TARGET_STATUS_LABELS: Record<TargetStatus, string> = {
AHEAD: 'Ahead of Target',
ON_TRACK: 'On Track',
BEHIND: 'Behind Target'
};
/** Compares an actual % against its target %, tolerant of small day-to-day noise —
* `toleranceMultiplier` widens the band for branch-level achievement (target vs 100%) vs the
* tighter band appropriate for a product/channel mix percentage. */
export function targetStatus(actualPct: number, targetPct: number, tolerancePp: number): TargetStatus {
const diff = actualPct - targetPct;
if (Math.abs(diff) <= tolerancePp) {
return 'ON_TRACK';
}
return diff > 0 ? 'AHEAD' : 'BEHIND';
}