- 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
2007 lines
76 KiB
TypeScript
2007 lines
76 KiB
TypeScript
/**
|
||
* Copyright since 2025 Mifos Initiative
|
||
*
|
||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
*/
|
||
|
||
// Dummy 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';
|
||
}
|