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
@@ -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) {