feat(loan-origination): guarantor share-account verification, committee resolution gating, legal doc uploads, CIC score-detection fixes, and interest/share rate chart
- Guarantor must be a real existing client with a verified active share account, with clickable share-account chips - Loan Committee Approval now requires Resolution No./Date plus a full application report before approving - Loan Committee gets its own pre-filtered pipeline view via a menu link and stage query param - Legal Approval stage gets a multi-file document upload checklist with add-more support - CIC PDF parser: item-level score detection before line-based fallback, fixing font-size-driven line-merging misdetection - New Interest & Share Rate Chart tab: per-product interest/tenure bands and per-amount-slab Share Capital linkage requirements
This commit is contained in:
@@ -302,7 +302,12 @@ export interface LoanApplication {
|
||||
|
||||
// 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
|
||||
@@ -317,6 +322,11 @@ export interface LoanApplication {
|
||||
|
||||
// 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;
|
||||
@@ -327,6 +337,7 @@ export interface LoanApplication {
|
||||
legalStampDone: boolean;
|
||||
fileFilingDone: boolean;
|
||||
agreementSigningSlot: string;
|
||||
legalDocuments: ProductDocumentEntry[];
|
||||
|
||||
// Signing / disbursement
|
||||
customerSigningDate: string;
|
||||
@@ -420,6 +431,9 @@ interface OriginationProduct {
|
||||
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[] = [
|
||||
{
|
||||
@@ -432,7 +446,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 16,
|
||||
minTenure: 12,
|
||||
maxTenure: 48,
|
||||
incomeMultiplier: 20
|
||||
incomeMultiplier: 20,
|
||||
shareLinkagePercent: 5
|
||||
},
|
||||
{
|
||||
code: 'HL',
|
||||
@@ -444,7 +459,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 10.5,
|
||||
minTenure: 120,
|
||||
maxTenure: 240,
|
||||
incomeMultiplier: 60
|
||||
incomeMultiplier: 60,
|
||||
shareLinkagePercent: 2.5
|
||||
},
|
||||
{
|
||||
code: 'VL',
|
||||
@@ -456,7 +472,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 11.5,
|
||||
minTenure: 36,
|
||||
maxTenure: 84,
|
||||
incomeMultiplier: 36
|
||||
incomeMultiplier: 36,
|
||||
shareLinkagePercent: 2.5
|
||||
},
|
||||
{
|
||||
code: 'GL',
|
||||
@@ -468,7 +485,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 12,
|
||||
minTenure: 6,
|
||||
maxTenure: 12,
|
||||
incomeMultiplier: 15
|
||||
incomeMultiplier: 15,
|
||||
shareLinkagePercent: 2
|
||||
},
|
||||
{
|
||||
code: 'CC',
|
||||
@@ -480,7 +498,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 13.5,
|
||||
minTenure: 12,
|
||||
maxTenure: 12,
|
||||
incomeMultiplier: 40
|
||||
incomeMultiplier: 40,
|
||||
shareLinkagePercent: 5
|
||||
},
|
||||
{
|
||||
code: 'KCC',
|
||||
@@ -492,7 +511,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 9,
|
||||
minTenure: 12,
|
||||
maxTenure: 12,
|
||||
incomeMultiplier: 30
|
||||
incomeMultiplier: 30,
|
||||
shareLinkagePercent: 2.5
|
||||
},
|
||||
{
|
||||
code: 'EL',
|
||||
@@ -504,10 +524,27 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 12,
|
||||
minTenure: 60,
|
||||
maxTenure: 84,
|
||||
incomeMultiplier: 45
|
||||
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. */
|
||||
@@ -582,6 +619,26 @@ 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',
|
||||
@@ -920,7 +977,9 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
declarationTaken: false,
|
||||
declarationDate: '—',
|
||||
guarantorRequired: !product.secured && requestedAmount > 300000,
|
||||
guarantorClientId: null,
|
||||
guarantorName: '—',
|
||||
guarantorHasShareAccount: false,
|
||||
backgroundCheckDone: false,
|
||||
cersaiSearchDone: false,
|
||||
managerMeetingDone: false,
|
||||
@@ -928,6 +987,8 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
propertySearchReportDone: false,
|
||||
valuationReportConfirmed: false,
|
||||
creditCommitteeApproval: blankApproval(),
|
||||
committeeResolutionNo: '',
|
||||
committeeResolutionDate: '',
|
||||
riskApproval: blankApproval(),
|
||||
legalApproval: blankApproval(),
|
||||
postCheckApproval: blankApproval(),
|
||||
@@ -936,6 +997,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(product.secured),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
@@ -960,6 +1022,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
}
|
||||
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
|
||||
@@ -1161,14 +1224,18 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
: `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.'
|
||||
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({
|
||||
@@ -1223,6 +1290,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
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,
|
||||
@@ -1812,7 +1880,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
declarationTaken: false,
|
||||
declarationDate: '—',
|
||||
guarantorRequired: false,
|
||||
guarantorClientId: null,
|
||||
guarantorName: '—',
|
||||
guarantorHasShareAccount: false,
|
||||
backgroundCheckDone: false,
|
||||
cersaiSearchDone: false,
|
||||
managerMeetingDone: false,
|
||||
@@ -1820,6 +1890,8 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
propertySearchReportDone: false,
|
||||
valuationReportConfirmed: false,
|
||||
creditCommitteeApproval: blankApproval(),
|
||||
committeeResolutionNo: '',
|
||||
committeeResolutionDate: '',
|
||||
riskApproval: blankApproval(),
|
||||
legalApproval: blankApproval(),
|
||||
postCheckApproval: blankApproval(),
|
||||
@@ -1828,6 +1900,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(input.secured),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
|
||||
Reference in New Issue
Block a user