feat(loan-origination): guarantor share-account verification, committee resolution gating, legal doc uploads, CIC score-detection fixes, and interest/share rate chart
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

- 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:
2026-08-14 14:49:22 +05:30
parent 1d1e76497c
commit 2d93f80d50
8 changed files with 773 additions and 38 deletions
@@ -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,