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
+8
View File
@@ -1386,6 +1386,14 @@
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
</button>
<button
mat-menu-item
tabindex="0"
routerLink="/loan-origination/pipeline"
[queryParams]="{ stage: 'CREDIT_COMMITTEE' }"
>
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Loan Committee
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/eligibility-checklist">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> RBI Eligibility Checklist
</button>
@@ -1879,6 +1879,22 @@
<div class="pg-card">
<div class="pos-section-label">Loan Committee Approval — {{ sanctioningAuthorityOf(a) }}</div>
<div class="loan-action-card">
<div class="loi-cic-subsection">
<div class="loi-cic-subsection-title">Full Application Report — For the Committee</div>
<p class="pg-sub npa-report-note">
Everything captured on this application — KYC, CIC score, Field Investigation, property valuation, agent
report, meeting and stage history — as one PDF for every committee member to review before deciding.
</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="printApplicationSummary(a)">
🖨️ Print Full Report
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="downloadApplicationSummary(a)">
⬇️ Download Full Report
</button>
</div>
</div>
<p class="pg-sub npa-report-note">Reports Required — present below reports, then complete.</p>
<div class="loan-checklist">
<label class="loan-checklist-item">
@@ -1917,12 +1933,27 @@
<span>Committee Remarks</span>
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
</label>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Resolution No.</span>
<input type="text" [(ngModel)]="a.committeeResolutionNo" placeholder="e.g. LC/2026/0042" />
</label>
<label class="inv-form-field">
<span>Resolution Date</span>
<input type="text" [(ngModel)]="a.committeeResolutionDate" placeholder="DD-MM-YYYY" />
</label>
</div>
@if (committeeReportsReady(a) && !committeeCanApprove(a)) {
<p class="loi-kyc-error">
Enter the Resolution No. and Resolution Date the committee passed this decision under before approving.
</p>
}
<div class="loan-action-row">
<button
type="button"
class="pg-btn pg-btn--success"
[disabled]="!committeeReportsReady(a)"
(click)="approveStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
[disabled]="!committeeCanApprove(a)"
(click)="approveCommitteeStage(a)"
>
Approve
</button>
@@ -1973,10 +2004,65 @@
</label>
</div>
@if (a.guarantorRequired) {
<label class="inv-form-field">
<span>Guarantor Name</span>
<input type="text" [(ngModel)]="a.guarantorName" />
</label>
<div class="loi-cic-subsection">
<div class="loi-cic-subsection-title">Guarantor — Must Be an Existing Share Member</div>
@if (!a.guarantorClientId) {
<label class="inv-form-field">
<span>Search Existing Client</span>
<input
type="text"
[(ngModel)]="guarantorSearchText"
(ngModelChange)="onGuarantorSearch($event)"
placeholder="Name, account no., mobile..."
/>
</label>
@if (searchingGuarantor) {
<p class="loi-search-hint">Searching…</p>
} @else if (guarantorSearchText.length >= 2 && guarantorSearchResults.length === 0) {
<p class="loi-search-hint">No clients found.</p>
}
@if (guarantorSearchResults.length > 0) {
<div class="loi-search-results">
@for (client of guarantorSearchResults; track client.id) {
<button type="button" class="loi-search-row" (click)="selectGuarantor(a, client)">
<span class="loi-search-name">{{ client.displayName }}</span>
<span class="loi-search-meta">{{ client.accountNo ?? client.officeName ?? '' }}</span>
</button>
}
</div>
}
<p class="loi-kyc-error">
The guarantor must already be a bank client with an active share account — search and select them
above, a name alone isn't enough.
</p>
} @else {
<label class="inv-form-field">
<span>Guarantor</span>
<div class="loi-selected-client">
<span>{{ a.guarantorName }}</span>
<button type="button" class="pg-btn" (click)="clearGuarantor(a)">Change</button>
</div>
</label>
@if (loadingGuarantorShareCheck) {
<p class="loi-search-hint">Checking share membership…</p>
} @else if (a.guarantorHasShareAccount) {
<span class="loan-badge loan-badge--verified">Share Member — Verified</span>
<div class="loi-share-account-list">
@for (sa of guarantorShareAccounts; track sa.id) {
<button type="button" class="loi-share-account-chip" (click)="viewShareAccountDetails(sa.id)">
{{ sa.accountNo }} — {{ sa.productName }}
</button>
}
</div>
} @else {
<span class="loan-badge loan-badge--missing">No Share Account Found</span>
<p class="loi-kyc-error">
This client has no active share account — a guarantor must already be a bank member. Pick a
different guarantor.
</p>
}
}
</div>
}
<label class="inv-form-field">
<span>Risk Remarks</span>
@@ -2028,6 +2114,98 @@
<input type="checkbox" [(ngModel)]="a.fileFilingDone" /> File Filing Done
</label>
</div>
@if (a.legalDocuments.length > 0) {
<div class="loi-cic-subsection">
<div class="loi-legal-doc-header">
<div class="loi-cic-subsection-title">Legal Documents</div>
<span class="loi-legal-doc-progress">
{{ legalDocsSubmittedCount(a) }} / {{ a.legalDocuments.length }} submitted
</span>
</div>
<div class="loi-legal-doc-grid">
@for (doc of a.legalDocuments; track doc.key) {
<div class="loi-legal-doc-card" [class.loi-legal-doc-card--submitted]="doc.submitted">
<div class="loi-legal-doc-title-row">
<span class="loi-legal-doc-icon">{{ legalDocIcon(doc.key) }}</span>
<span class="loi-legal-doc-title">{{ doc.label }}</span>
@if (doc.custom) {
<button
type="button"
class="loi-doc-remove"
title="Remove this document"
(click)="removeCustomLegalDoc(a, doc)"
>
✕
</button>
}
</div>
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost loi-legal-doc-upload-btn">
+ Add File(s)
<input
type="file"
accept="image/*,application/pdf"
multiple
hidden
(change)="onLegalDocFile(doc, $event)"
/>
</label>
@for (capture of legalDocCapture[doc.key]; track $index) {
<div class="loi-legal-doc-thumb-wrap">
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(capture.file)"
>
@if (capture.previewUrl) {
<img [src]="capture.previewUrl" alt="{{ doc.label }} {{ $index + 1 }}" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
<button
type="button"
class="loi-legal-doc-thumb-remove"
title="Remove this file"
(click)="removeLegalDocFile(doc, $index)"
>
✕
</button>
</div>
}
</div>
@if (doc.submitted) {
<span class="loan-badge loan-badge--verified">
✓ Submitted
@if (legalDocCapture[doc.key]?.length) {
· {{ legalDocCapture[doc.key].length }} file{{
legalDocCapture[doc.key].length > 1 ? 's' : ''
}}
this session
}
</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
</div>
}
</div>
<div class="loan-action-row">
<input
type="text"
[(ngModel)]="newLegalDocLabel"
placeholder="Add More Doc"
class="loi-custom-doc-input"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="addCustomLegalDoc(a)">
+ Add Custom Document
</button>
</div>
</div>
}
<label class="inv-form-field">
<span>Agreement Signing Slot</span>
<input type="text" [(ngModel)]="a.agreementSigningSlot" placeholder="DD-MM-YYYY" />
@@ -232,6 +232,12 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
productDocCapture: Record<string, NewKycSingleDoc> = {};
newCustomDocLabel = '';
/** Same local-only capture, "Add More Doc" included, for `a.legalDocuments` at Legal Approval --
* a separate record since it's a different array on the application, not the product's own
* document list. */
legalDocCapture: Record<string, NewKycSingleDoc[]> = {};
newLegalDocLabel = '';
/** The Housing Loan "KYC Documents of Co-Owners" requirement only actually applies when the
* registry lists more than one person -- this checkbox is how the officer says whether that's
* the case, before the Aadhaar/PAN capture (front/back + verification, in its own popup so the
@@ -417,12 +423,21 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.cicConsentCapture = { file: null, previewUrl: null };
this.cicPaymentRefInput = '';
this.agentReportRemarkInput = '';
this.guarantorSearchText = '';
this.guarantorSearchResults = [];
this.guarantorShareAccounts = [];
this.revokeProductDocPreviews();
this.productDocCapture = {};
(this.application?.productDocuments ?? []).forEach((doc) => {
this.productDocCapture[doc.key] = { file: null, previewUrl: null };
});
this.newCustomDocLabel = '';
this.revokeLegalDocPreviews();
this.legalDocCapture = {};
(this.application?.legalDocuments ?? []).forEach((doc) => {
this.legalDocCapture[doc.key] = [];
});
this.newLegalDocLabel = '';
this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
this.revokeDocSidePreviews(this.coOwnerCapture.pan);
this.coOwnerCapture = { hasCoOwner: false, aadhaar: this.emptyDocSide(), pan: this.emptyDocSide() };
@@ -897,6 +912,88 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
delete this.productDocCapture[doc.key];
}
// -------------------- Legal documents (Legal Approval) --------------------
/** Any one legal document can genuinely be several files -- a multi-page registered agreement
* scanned page by page, more than one mortgagor's stamp paper, etc. -- so each doc key holds an
* array here, the same shape the Field Investigation site-photo capture already uses, rather
* than a single file that a second upload would silently replace. */
async onLegalDocFile(doc: ProductDocumentEntry, event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (files.length === 0) {
return;
}
if (!this.legalDocCapture[doc.key]) {
this.legalDocCapture[doc.key] = [];
}
for (const file of files) {
this.legalDocCapture[doc.key].push({
file,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
});
}
doc.submitted = true;
}
removeLegalDocFile(doc: ProductDocumentEntry, index: number): void {
const files = this.legalDocCapture[doc.key];
if (!files) {
return;
}
const [removed] = files.splice(index, 1);
if (removed?.previewUrl) {
URL.revokeObjectURL(removed.previewUrl);
}
}
private readonly legalDocIcons: Record<string, string> = {
loanAgreement: '📜',
stampDutyReceipt: '🧾',
feeReceipt: '💳',
mortgageDeed: '🏠',
titleSearchReport: '🔎',
fileFilingAck: '🗂️'
};
legalDocIcon(key: string): string {
return this.legalDocIcons[key] ?? '📄';
}
legalDocsSubmittedCount(a: LoanApplication): number {
return a.legalDocuments.filter((doc) => doc.submitted).length;
}
/** Lets the officer add a document this matter specifically needed beyond the starter list --
* a second mortgagor's consent letter, a court NOC, etc. -- same "Add More Doc" pattern the
* product-specific documents already use. */
addCustomLegalDoc(a: LoanApplication): void {
const label = this.newLegalDocLabel.trim();
if (!label) {
return;
}
const key = `custom-${a.legalDocuments.length}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
a.legalDocuments.push({ key, label, custom: true, submitted: false });
this.legalDocCapture[key] = [];
this.newLegalDocLabel = '';
}
removeCustomLegalDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
if (!doc.custom) {
return;
}
const idx = a.legalDocuments.indexOf(doc);
if (idx >= 0) {
a.legalDocuments.splice(idx, 1);
}
(this.legalDocCapture[doc.key] ?? []).forEach((capture) => {
if (capture.previewUrl) {
URL.revokeObjectURL(capture.previewUrl);
}
});
delete this.legalDocCapture[doc.key];
}
onCoOwnerToggle(a: LoanApplication): void {
this.syncCoOwnerDocSubmitted(a);
}
@@ -1010,6 +1107,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.sub?.unsubscribe();
this.revokeNewKycPreviews(this.newKycCapture);
this.revokeProductDocPreviews();
this.revokeLegalDocPreviews();
this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
this.revokeDocSidePreviews(this.coOwnerCapture.pan);
this.revokeCoBorrowerPreviews();
@@ -1030,6 +1128,16 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
});
}
private revokeLegalDocPreviews(): void {
Object.values(this.legalDocCapture).forEach((docs) => {
docs.forEach((doc) => {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
});
});
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
@@ -1984,6 +2092,22 @@ I confirm that:
}
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
/** The Loan Committee's decision only counts once it's actually minuted -- a resolution number
* and date, the same thing that gets quoted on the sanction letter later -- not just a
* self-ticked Approve button with nothing behind it. */
committeeCanApprove(a: LoanApplication): boolean {
return this.committeeReportsReady(a) && !!a.committeeResolutionNo.trim() && !!a.committeeResolutionDate.trim();
}
approveCommitteeStage(a: LoanApplication): void {
if (!this.committeeCanApprove(a)) {
return;
}
const remarks = `${this.actionRemarks.trim() || 'Reviewed and approved.'} (Resolution No. ${a.committeeResolutionNo.trim()} dated ${a.committeeResolutionDate.trim()})`;
decideApproval(a, 'creditCommitteeApproval', 'APPROVED', this.currentUserName, 'Sanctioning Authority', remarks);
this.actionRemarks = '';
}
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
@@ -2044,10 +2168,85 @@ I confirm that:
a.previousLoanDeclarationDone &&
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
this.fiComplete(a) &&
a.backgroundCheckDone
a.backgroundCheckDone &&
(!a.guarantorRequired || (!!a.guarantorClientId && a.guarantorHasShareAccount))
);
}
// -------------------- Guarantor (Risk Approval) --------------------
/** A guarantor has to be a real, already-onboarded bank client -- same search-as-you-type
* against real Fineract clients the New Application form's "Existing Customer" picker uses --
* not a free-text name nobody has actually verified exists. */
guarantorSearchText = '';
guarantorSearchResults: any[] = [];
searchingGuarantor = false;
loadingGuarantorShareCheck = false;
/** The guarantor's own real share accounts -- shown as clickable chips (same
* viewShareAccountDetails popup the applicant's own Share Member section already uses) once
* membership is verified, not just a bare "Verified" badge with nothing behind it. */
guarantorShareAccounts: any[] = [];
private guarantorSearchTimer: ReturnType<typeof setTimeout> | undefined;
onGuarantorSearch(text: string): void {
this.guarantorSearchText = text;
clearTimeout(this.guarantorSearchTimer);
if (!text || text.length < 2) {
this.guarantorSearchResults = [];
return;
}
this.searchingGuarantor = true;
this.guarantorSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.guarantorSearchResults = data?.content || [];
this.searchingGuarantor = false;
},
error: () => {
this.guarantorSearchResults = [];
this.searchingGuarantor = false;
}
});
}, 400);
}
selectGuarantor(a: LoanApplication, client: any): void {
a.guarantorClientId = client.id;
a.guarantorName = client.displayName;
a.guarantorHasShareAccount = false;
this.guarantorSearchResults = [];
this.guarantorSearchText = '';
this.verifyGuarantorShareAccount(a);
}
/** Lets the officer pick a different guarantor -- clears the share-account result too, since
* it belonged to whichever client was selected before. */
clearGuarantor(a: LoanApplication): void {
a.guarantorClientId = null;
a.guarantorName = '—';
a.guarantorHasShareAccount = false;
this.guarantorShareAccounts = [];
this.guarantorSearchText = '';
this.guarantorSearchResults = [];
}
/** Same `/clients/{id}/accounts` call the applicant's own Share Member check uses -- a
* guarantor with no active share account isn't actually a member of this cooperative bank,
* regardless of what their name is. */
private verifyGuarantorShareAccount(a: LoanApplication): void {
if (!a.guarantorClientId) {
return;
}
this.loadingGuarantorShareCheck = true;
this.clientsService
.getClientAccountData(String(a.guarantorClientId))
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.guarantorShareAccounts = data?.shareAccounts ?? [];
a.guarantorHasShareAccount = this.guarantorShareAccounts.length > 0;
this.loadingGuarantorShareCheck = false;
});
}
postCheckSignaturesDone(a: LoanApplication): boolean {
return (
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
@@ -2064,7 +2263,7 @@ I confirm that:
let parsed: CicReportAnalysis | null = null;
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
parsed = await parseCicPdfReport(file);
parsed = await parseCicPdfReport(file, a.cibilScore);
}
if (parsed) {
@@ -77,15 +77,21 @@ const LOAN_TYPE_KEYWORDS = [
* hand-rolled decompressor). Returns null whenever extraction can't find enough of the report's
* real structure to trust (no fabricated fallback data for any specific file), so the caller can
* fall back to the simulated report instead of silently showing something invented.
*
* `existingScore` is used only if the score genuinely can't be read as text off the page (some
* report generators draw the gauge's number as part of a vector/raster graphic rather than real
* selectable text, which no text-extraction approach can recover) -- falling back to whatever
* score this application already has on file avoids showing a number that visibly contradicts
* the rest of the screen.
*/
export async function parseCicPdfReport(file: File): Promise<CicReportAnalysis | null> {
export async function parseCicPdfReport(file: File, existingScore = 0): Promise<CicReportAnalysis | null> {
try {
const items = await extractPositionedItems(file);
if (items.length === 0) {
return null;
}
const lines = reconstructLines(items);
return buildAnalysisFromLines(lines, items);
return buildAnalysisFromLines(lines, items, existingScore);
} catch (err) {
console.warn('CIC PDF parse failed, falling back to simulated report:', err);
return null;
@@ -188,16 +194,41 @@ function detectBureau(fullText: string): string {
return 'CIBIL';
}
function detectScore(lines: TextLine[]): number | null {
const factorsLine = lineMatching(lines, /score factors/i);
const searchFrom = factorsLine ? Math.max(0, lines.indexOf(factorsLine) - 6) : 0;
const searchTo = factorsLine ? lines.indexOf(factorsLine) : lines.length;
// Walk backwards from just above "Score Factors" -- the score itself sits on the line
// immediately before that heading, whereas the "...ranges from 300 - 900" sentence describing
// the scale sits further above it and would otherwise be matched first (and wrongly) by the
// same 3-digit pattern.
for (let i = searchTo - 1; i >= searchFrom; i--) {
/** Score digits are normally their own PDF text run (rendered in a much larger font than the
* surrounding heading/paragraph text, inside the gauge graphic) -- checking the raw, ungrouped
* items directly sidesteps any line-reconstruction ambiguity entirely: it doesn't matter whether
* pdf.js's y-tolerance happened to merge that run onto the same visual line as "Score Factors"
* or the intro paragraph, because the ITEM itself still carries just "748", not the whole line's
* text. This is checked before anything line-based, since it's the least ambiguous signal. */
function detectScoreFromItems(items: PositionedItem[]): number | null {
const isolated = items
.map((item) => item.text.trim())
.filter((text) => /^([3-8]\d{2}|900)$/.test(text))
.map((text) => parseInt(text, 10));
return isolated.length === 1 ? isolated[0] : null;
}
/** Fallback for when the score isn't its own isolated text item (some report generators do embed
* it into a longer text run) -- scans lines around the "Score Factors"/"Credit Score" heading,
* preferring an isolated number-only line and skipping the "...ranges from 300 - 900" sentence
* describing the scale, which matches the exact same 3-digit pattern. */
function detectScoreFromLines(lines: TextLine[]): number | null {
const anchorLine = lineMatching(lines, /score factors/i) || lineMatching(lines, /credit score/i);
const anchorIdx = anchorLine ? lines.indexOf(anchorLine) : Math.floor(lines.length / 2);
const from = Math.max(0, anchorIdx - 15);
const to = Math.min(lines.length, anchorIdx + 5);
const candidates: { value: number; isolated: boolean; distance: number }[] = [];
for (let i = from; i < to; i++) {
const text = lines[i].text;
if (/ranges?\s+from/i.test(text)) {
continue;
}
const trimmed = text.trim();
if (/^([3-8]\d{2}|900)$/.test(trimmed)) {
candidates.push({ value: parseInt(trimmed, 10), isolated: true, distance: Math.abs(i - anchorIdx) });
continue;
}
if (/\d{3}\s*-\s*\d{3}/.test(text)) {
continue;
}
@@ -205,11 +236,19 @@ function detectScore(lines: TextLine[]): number | null {
if (m) {
const val = parseInt(m[1], 10);
if (val >= 300 && val <= 900) {
return val;
candidates.push({ value: val, isolated: false, distance: Math.abs(i - anchorIdx) });
}
}
}
return null;
if (candidates.length === 0) {
return null;
}
candidates.sort((a, b) => (a.isolated === b.isolated ? a.distance - b.distance : a.isolated ? -1 : 1));
return candidates[0].value;
}
function detectScore(lines: TextLine[], items: PositionedItem[]): number | null {
return detectScoreFromItems(items) ?? detectScoreFromLines(lines);
}
function detectApplicantName(lines: TextLine[]): string {
@@ -433,17 +472,22 @@ function parseEnquiries(lines: TextLine[]): CicEnquiry[] {
return enquiries;
}
function buildAnalysisFromLines(lines: TextLine[], items: PositionedItem[]): CicReportAnalysis | null {
const score = detectScore(lines);
function buildAnalysisFromLines(
lines: TextLine[],
items: PositionedItem[],
existingScore: number
): CicReportAnalysis | null {
const detectedScore = detectScore(lines, items);
const accounts = parseAccounts(lines, items);
if (score === null && accounts.length === 0) {
if (detectedScore === null && accounts.length === 0) {
return null;
}
const fullText = lines.map((l) => l.text).join(' ');
const bureau = detectBureau(fullText);
const applicantName = detectApplicantName(lines) || 'Applicant';
const band = scoreBand(score ?? 650);
const score = detectedScore ?? (existingScore > 0 ? existingScore : 650);
const band = scoreBand(score);
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
@@ -480,7 +524,7 @@ function buildAnalysisFromLines(lines: TextLine[], items: PositionedItem[]): Cic
bureau,
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
applicantName,
score: score ?? 650,
score,
band,
scoreFactors: [
'Recency: Recent Credit Account Defaults',
@@ -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,
@@ -774,6 +774,97 @@
flex: 1;
}
// Legal Approval documents -- its own card style (icon + status accent stripe) rather than the
// plain .loi-kyc-card, since Legal Approval is a page most officers look at repeatedly right
// before disbursement and benefits from a clearer, less generic-looking checklist.
.loi-legal-doc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 4px;
}
.loi-legal-doc-progress {
font-size: 11.5px;
font-weight: 700;
color: #64748b;
background: #f1f5f9;
padding: 3px 10px;
border-radius: 999px;
}
.loi-legal-doc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 12px;
margin-bottom: 10px;
}
.loi-legal-doc-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 10px;
background: #fff;
transition:
box-shadow 0.15s ease,
border-color 0.15s ease;
&:hover {
box-shadow: 0 3px 12px rgb(15 23 42 / 6%);
}
&--submitted {
border-left-color: #10b981;
}
}
.loi-legal-doc-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.loi-legal-doc-icon {
font-size: 18px;
line-height: 1.3;
}
.loi-legal-doc-title {
flex: 1;
font-size: 12.5px;
font-weight: 700;
color: #1e293b;
line-height: 1.35;
}
.loi-legal-doc-upload-btn {
font-size: 12px;
}
.loi-legal-doc-thumb-wrap {
display: flex;
align-items: center;
gap: 2px;
}
.loi-legal-doc-thumb-remove {
border: none;
background: none;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
padding: 0 2px;
&:hover {
color: #dc2626;
}
}
// CIC Credit Bureau Hero Card in Stage detail
.loi-cic-hero-card {
display: flex;
@@ -116,6 +116,14 @@
>
Target vs Actual
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeView === 'rates'"
(click)="setView('rates')"
>
Interest &amp; Share Rate Chart
</button>
</div>
<!-- ===================== PIPELINE ===================== -->
@@ -614,6 +622,103 @@
</table>
</div>
}
<!-- ===================== INTEREST & SHARE RATE CHART ===================== -->
@if (activeView === 'rates') {
<div class="pg-card">
<div class="pos-section-label">Product-wise — Interest Rate &amp; Share Capital Required</div>
<p class="pg-sub npa-report-note">
Total Interest is an illustrative simple-interest figure (Amount x Rate x Tenure in years) at the product's own
min/max amount and tenure band — actual repayment is on a reducing balance and depends on the amount and tenure
chosen at sanction. Share Capital Required is the minimum a borrower must hold per bank bye-laws, proportional
to the loan amount.
</p>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Type</th>
<th class="num">Amount Range</th>
<th class="num">Interest Rate (p.a.)</th>
<th class="num">Tenure Range</th>
<th class="num">Illustrative Total Interest</th>
<th class="num">Share Capital Required</th>
<th class="num">Illustrative Share Amount</th>
</tr>
</thead>
<tbody>
@for (p of products; track p.code) {
<tr>
<td>{{ p.name }}</td>
<td>
<span
class="loan-badge"
[class.loan-badge--progress]="p.secured"
[class.loan-badge--hold]="!p.secured"
>
{{ p.secured ? 'Secured' : 'Unsecured' }}
</span>
</td>
<td class="num">₹{{ p.minAmt | number: '1.0-0' }} – ₹{{ p.maxAmt | number: '1.0-0' }}</td>
<td class="num">{{ p.minRate | number: '1.1-1' }}% – {{ p.maxRate | number: '1.1-1' }}%</td>
<td class="num">{{ p.minTenure }} – {{ p.maxTenure }} months</td>
<td class="num">
₹{{ illustrativeInterest(p.minAmt, p.minRate, p.minTenure) | number: '1.0-0' }} – ₹{{
illustrativeInterest(p.maxAmt, p.maxRate, p.maxTenure) | number: '1.0-0'
}}
</td>
<td class="num">{{ p.shareLinkagePercent | number: '1.1-1' }}%</td>
<td class="num">
₹{{ p.minAmt * (p.shareLinkagePercent / 100) | number: '1.0-0' }} – ₹{{
p.maxAmt * (p.shareLinkagePercent / 100) | number: '1.0-0'
}}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">Amount Slab-wise — Share Capital Required (All Products)</div>
<p class="pg-sub npa-report-note">
A general slab schedule by loan quantum, independent of product — where this differs from the product-wise
minimum above, whichever is higher governs the actual requirement.
</p>
<table class="pg-table">
<thead>
<tr>
<th class="num">Loan Amount Slab</th>
<th class="num">Share Capital Required</th>
<th class="num">Illustrative Share Amount</th>
</tr>
</thead>
<tbody>
@for (row of shareLinkageSlabRows; track row.minAmt) {
<tr>
<td class="num">
@if (row.maxAmt !== null) {
₹{{ row.minAmt | number: '1.0-0' }} – ₹{{ row.maxAmt | number: '1.0-0' }}
} @else {
Above ₹{{ row.minAmt | number: '1.0-0' }}
}
</td>
<td class="num">{{ row.sharePercent | number: '1.1-1' }}%</td>
<td class="num">
@if (row.shareAtMax !== null) {
₹{{ row.shareAtMin | number: '1.0-0' }} – ₹{{ row.shareAtMax | number: '1.0-0' }}
} @else {
From ₹{{ row.shareAtMin | number: '1.0-0' }}
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -29,6 +29,7 @@ import {
PRODUCT_TARGET_PCT,
PRODUCT_TYPE_LABELS,
ProductType,
SHARE_LINKAGE_SLABS,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
@@ -81,7 +82,17 @@ interface MixTargetRow {
isRbiMandated: boolean;
}
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
interface AmountSlabRow {
minAmt: number;
maxAmt: number | null;
sharePercent: number;
/** Share Capital required at the slab's lower and upper bound, so the rupee amount is visible
* without officers having to do the multiplication themselves. */
shareAtMin: number;
shareAtMax: number | null;
}
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rates';
/**
* Loan Origination pipeline — every application currently moving through the sanctioning
@@ -115,6 +126,7 @@ export class LoanPipelineComponent implements OnInit {
readonly channelLabels = CHANNEL_LABELS;
readonly branches = BRANCHES;
readonly products = ORIGINATION_PRODUCTS;
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
asOnDate = todayDDMMYYYY();
activeView: ViewId = 'pipeline';
@@ -185,11 +197,20 @@ export class LoanPipelineComponent implements OnInit {
'channel',
'bc',
'share',
'target'
'target',
'rates'
].includes(requestedView)) {
this.activeView = requestedView;
}
// Lets a menu link (e.g. "Loan Committee") land here pre-filtered to just the applications
// waiting on that one stage, reusing this same pipeline list/filter instead of a separate
// queue view -- same pattern as the `view` query param above.
const requestedStage = this.route.snapshot.queryParamMap.get('stage') as OriginationStage | null;
if (requestedStage && this.stageOrder.includes(requestedStage)) {
this.stageFilter = requestedStage;
}
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
@@ -427,6 +448,22 @@ export class LoanPipelineComponent implements OnInit {
});
}
// -------------------- Interest & Share Linkage Reference --------------------
/** Illustrative total interest over the product's own tenure band, simple-interest approximation
* (principal x rate x years) -- flagged as illustrative in the template since actual repayment
* is on a reducing balance and varies by amount/tenure chosen at sanction. */
illustrativeInterest(amount: number, ratePct: number, tenureMonths: number): number {
return amount * (ratePct / 100) * (tenureMonths / 12);
}
get shareLinkageSlabRows(): AmountSlabRow[] {
return this.shareLinkageSlabs.map((slab) => ({
...slab,
shareAtMin: slab.minAmt * (slab.sharePercent / 100),
shareAtMax: slab.maxAmt !== null ? slab.maxAmt * (slab.sharePercent / 100) : null
}));
}
get channelTargetRows(): MixTargetRow[] {
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
const channels: Channel[] = [