/** * 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 = { 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 = { 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 = { 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 = { 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 = { 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 = (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 = { 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([ '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([ '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 = { 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 = { 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 = { 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 = { 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 = { BRANCH: 55, BC: 30, SELF_APPLY: 15 }; export type TargetStatus = 'AHEAD' | 'ON_TRACK' | 'BEHIND'; export const TARGET_STATUS_LABELS: Record = { 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'; }