feat(loan-origination): persist the sanctioning pipeline to the backend
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

Replaces the in-memory mock (LOAN_APPLICATIONS array + seeded RNG, reset on
every refresh) with real Fineract-backed persistence: a new
LoanOriginationService for CRUD plus one API call per stage transition, a
loan-fineract-bridge that creates a real loan account (create + approve +
disburse) at disbursement for Existing Customers, and file uploads through
Fineract's generic document store keyed by a stable checklist slot so
"On file"/View controls survive a reload.

Also fixes several gaps found while verifying full-cycle persistence:
- CIC charge-account selection never saved (missing persist() call)
- Co-borrower relationship/monthly income lived in local-only component state
  instead of on the application, so they reset on refresh
- Field Investigation "View Full Details" showed local-session photos only;
  stored photos are now downloaded and shown as real thumbnails
- "Assign Field Officer" was free text; now a dropdown of real CBS loan
  officers pulled from /staff
- site photos and cheque/signature images now open in the same zoomable,
  pannable lightgallery viewer used elsewhere in the app

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:05:05 +05:30
co-authored by Claude Sonnet 5
parent 2d93f80d50
commit c18219fce5
10 changed files with 1333 additions and 301 deletions
@@ -11,13 +11,17 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { Subscription, forkJoin } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Observable, Subscription, forkJoin } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { Dates } from '../../core/utils/dates';
import { ClientsService } from '../../clients/clients.service';
import { LoansService } from '../../loans/loans.service';
import { ProductsService } from '../../products/products.service';
import { SettingsService } from '../../settings/settings.service';
import { SharesService } from '../../shares/shares.service';
import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service';
import { DocumentPreviewDialogComponent } from '../../clients/shared/document-preview-dialog/document-preview-dialog.component';
@@ -36,6 +40,8 @@ import {
} from './cic-report-analysis-dialog/cic-report-analysis.util';
import { parseCicPdfReport } from './cic-report-analysis-dialog/cic-pdf-parser.util';
import { FieldVisitDetailsDialogComponent } from './field-visit-details-dialog/field-visit-details-dialog.component';
import { createRealLoanAccount } from '../loan-fineract-bridge';
import { LoanOriginationService } from '../loan-origination.service';
import {
ApprovalField,
BRANCHES,
@@ -44,7 +50,6 @@ import {
DISCREPANCY_LABELS,
EmiScheduleRow,
LoanApplication,
LOAN_APPLICATIONS,
ORIGINATION_PRODUCTS,
OriginationStage,
STAGE_LABELS,
@@ -59,28 +64,20 @@ import {
cibilBand,
CIC_BUREAUS,
CIC_CHECK_CHARGE,
clearPersonalDiscussionAndProceed,
confirmChequeAch,
decideApproval,
disburseApplication,
FiCheckKey,
FI_CHECK_LABELS,
flagDiscrepancy,
MANAGERS,
markCustomerSigned,
moveToStage,
ProductDocumentEntry,
progressPct,
PROPERTY_TYPES,
pushFieldInquiry,
resolveDiscrepancyAndProceed,
sanctioningAuthority,
scheduleMeeting,
setMonthlyIncome,
stageBadgeClass,
stageIndex,
proceedToNextStage,
recordVerification,
todayDDMMYYYY,
uploadAgentReport,
uploadCicReport
@@ -163,6 +160,16 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
actionRemarks = '';
discrepancySelection: DiscrepancyType = 'NONE';
disbursementMode = 'Account Credit';
/** True while the real Fineract loan account (Existing Customer applications only) is being
* created/approved/disbursed via loan-fineract-bridge.ts. */
creatingRealLoan = false;
loadingApplication = false;
/** True while a save is in flight; drives the "Saving…"/"Saved" indicator so an officer can tell
* their work has actually reached the database rather than assuming it. */
saving = false;
saveFailed = false;
private inlineEditTimer: ReturnType<typeof setTimeout> | undefined;
/** KYC stage's Income & Affordability inputs -- always editable (not just before the first
* "Assess"), pre-filled from the application and re-committed on every click, so the officer
* can revisit and adjust income, requested amount or tenure as many times as needed. */
@@ -186,7 +193,12 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly sharesService = inject(SharesService);
private readonly loansService = inject(LoansService);
private readonly productsService = inject(ProductsService);
private readonly dateUtils = inject(Dates);
private readonly settingsService = inject(SettingsService);
private readonly kycUploadHelper = inject(KycUploadHelperService);
private readonly originationService = inject(LoanOriginationService);
private readonly dialog = inject(MatDialog);
private sub?: Subscription;
@@ -249,27 +261,20 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
};
/** Once `coBorrowerRequired` fires (either the eligible-amount shortfall in setMonthlyIncome()
* or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via this
* checkbox -- relationship, their own monthly income and last-3-months' salary slips, all
* local-only like the rest of this mock pipeline. Their income only counts toward affordability
* once all of that is in, see combinedEmiCapacity()/affordabilityOk() below. */
* or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via
* `a.coBorrowerWanted`. Their name, relationship and monthly income live directly on the
* application (so they survive a refresh); this holds only the in-flight local File objects for
* the last-3-months' salary slips, which are otherwise tracked server-side via `hasStoredDoc`.
* Their income only counts toward affordability once all of that is in, see
* combinedEmiCapacity()/affordabilityOk() below. */
coBorrowerCapture: {
wanted: boolean;
relationship: string;
monthlyIncome: number | null;
salarySlips: NewKycSingleDoc[];
} = this.emptyCoBorrowerCapture();
private emptyCoBorrowerCapture(): {
wanted: boolean;
relationship: string;
monthlyIncome: number | null;
salarySlips: NewKycSingleDoc[];
} {
return {
wanted: false,
relationship: '',
monthlyIncome: null,
salarySlips: [
{ file: null, previewUrl: null },
{ file: null, previewUrl: null },
@@ -295,6 +300,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
shopVerification: ''
};
/** Real CBS staff to pick a Field Officer from -- loaded once, not per-application, since it
* doesn't depend on which application is open. */
fiStaffOptions: { id: number; displayName: string }[] = [];
/** Ditto for the rejection remark textarea. */
fiRejectRemarkInput: Record<FiCheckKey, string> = {
addressVerification: '',
@@ -406,13 +415,42 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.fiTickTimer = setInterval(() => {
this.nowTick = Date.now();
}, 60000);
this.originationService.listStaff().subscribe({
next: (staff) => (this.fiStaffOptions = staff ?? []),
error: () => (this.fiStaffOptions = [])
});
this.sub = this.route.paramMap.subscribe((params) => {
const applicationNo = params.get('applicationNo');
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
// A branch-level login can only open applications belonging to its own branch — treated
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
// that a differently-branched application exists at all.
this.application = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
if (!applicationNo) {
this.application = undefined;
this.resetForApplication();
return;
}
this.loadingApplication = true;
this.originationService.get(applicationNo).subscribe({
next: (found: LoanApplication) => {
// A branch-level login can only open applications belonging to its own branch — treated
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
// that a differently-branched application exists at all.
this.application = !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
this.resetForApplication();
this.loadingApplication = false;
},
error: () => {
this.application = undefined;
this.resetForApplication();
this.loadingApplication = false;
}
});
});
}
/** Clears every piece of per-application transient state (locally-captured files, in-progress
* inputs, search results) and re-primes the per-document capture maps for whichever application
* is now on screen. Split out of the route subscription because the application itself now
* arrives asynchronously from the database. */
private resetForApplication(): void {
{
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
@@ -468,7 +506,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.loadRealClientAccounts();
this.loadRealKyc();
this.loadRealAddresses();
});
this.uploadingDocs = 0;
this.docUploadFailed = false;
this.refreshDocuments(this.application);
}
}
/** Only applications raised against a real Existing Customer (clientId set) get a real
@@ -689,6 +730,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
target[side] = cropped;
target[previewKey] = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
this.uploadCaptured(cropped, `KYC:${doc}:${side}`, `${doc === 'aadhaar' ? 'Aadhaar' : 'PAN'} — ${side}`);
}
sendNewAadhaarOtp(): void {
@@ -762,6 +804,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return;
}
this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
this.uploadCaptured(file, 'KYC:incomeProof:salaried', 'Form 16 / 3 Month Salary Slip');
this.syncIncomeProofSubmitted(a);
}
@@ -771,6 +814,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return;
}
this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file);
this.uploadCaptured(file, `KYC:incomeProof:itr:${yearIndex}`, `ITR — Year ${yearIndex + 1}`);
this.syncIncomeProofSubmitted(a);
}
@@ -804,6 +848,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
this.setSingleDoc(this.newKycCapture.bankStatement, file);
a.bankStatementSubmitted = true;
this.uploadCaptured(file, 'KYC:bankStatement', 'Bank Statement');
this.persist();
if (this.isCsvFile(file)) {
await this.analyzeBankStatement(file);
}
@@ -823,6 +869,37 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
void this.analyzeBankStatement(file);
}
/** True once a CSV bank statement is available to re-analyze, whether it's the file just picked
* in this browser tab or one already saved from an earlier session. */
canReopenBankStatementAnalysis(): boolean {
if (this.isCsvFile(this.newKycCapture.bankStatement.file)) {
return true;
}
const stored = this.storedDocsFor('KYC:bankStatement')[0];
return !!stored && (stored.fileName ?? '').toLowerCase().endsWith('.csv');
}
/** Same re-analysis as `reopenBankStatementAnalysis`, but also covers a statement that only
* exists in the document store -- pulled down from the server first since there's no local
* File object for it after a reload. */
reopenBankStatementAnalysisFor(a: LoanApplication): void {
if (this.newKycCapture.bankStatement.file) {
this.reopenBankStatementAnalysis(this.newKycCapture.bankStatement.file);
return;
}
const stored = this.storedDocsFor('KYC:bankStatement')[0];
if (!stored || !a.id) {
return;
}
this.originationService.downloadDocument(a.id, stored.id).subscribe({
next: (blob: Blob) => {
const file = new File([blob], stored.fileName ?? 'Bank Statement.csv', { type: 'text/csv' });
void this.analyzeBankStatement(file);
},
error: () => this.toast('Could not load the stored bank statement.')
});
}
/** A CSV bank statement can actually be read, unlike a PDF/photo -- so dropping one in here
* opens straight into the transaction analysis instead of just filing it away unread. */
private async analyzeBankStatement(file: File): Promise<void> {
@@ -861,6 +938,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return;
}
this.setSingleDoc(this.newKycCapture.electricityBill, file);
this.uploadCaptured(file, 'KYC:electricityBill', 'Electricity Bill');
this.syncElectricityBillSubmitted(a);
}
@@ -879,6 +957,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
this.setSingleDoc(this.productDocCapture[doc.key], file);
doc.submitted = true;
this.uploadCaptured(file, `PRODUCT_DOC:${doc.key}`, doc.label);
this.persist();
}
/** Not every product's required-document list anticipates everything a specific file might
@@ -893,6 +973,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
a.productDocuments.push({ key, label, custom: true, submitted: false });
this.productDocCapture[key] = { file: null, previewUrl: null };
this.newCustomDocLabel = '';
this.persist();
}
/** Only officer-added documents can be removed -- a product's own required list stays fixed,
@@ -910,6 +991,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
URL.revokeObjectURL(capture.previewUrl);
}
delete this.productDocCapture[doc.key];
this.persist();
}
// -------------------- Legal documents (Legal Approval) --------------------
@@ -928,12 +1010,26 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.legalDocCapture[doc.key] = [];
}
for (const file of files) {
this.legalDocCapture[doc.key].push({
const captured: NewKycSingleDoc = {
file,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
};
this.legalDocCapture[doc.key].push(captured);
// Once the document store has it, drop the local capture so the same file doesn't render
// twice -- once as this in-session thumb, once again from `storedDocsFor` after the reload.
this.uploadCaptured(file, `LEGAL_DOC:${doc.key}`, doc.label, () => {
const files = this.legalDocCapture[doc.key];
const index = files?.indexOf(captured) ?? -1;
if (index >= 0) {
if (captured.previewUrl) {
URL.revokeObjectURL(captured.previewUrl);
}
files.splice(index, 1);
}
});
}
doc.submitted = true;
this.persist();
}
removeLegalDocFile(doc: ProductDocumentEntry, index: number): void {
@@ -976,6 +1072,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
a.legalDocuments.push({ key, label, custom: true, submitted: false });
this.legalDocCapture[key] = [];
this.newLegalDocLabel = '';
this.persist();
}
removeCustomLegalDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
@@ -992,6 +1089,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
});
delete this.legalDocCapture[doc.key];
this.persist();
}
onCoOwnerToggle(a: LoanApplication): void {
@@ -1005,6 +1103,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return;
}
this.setSingleDoc(this.coBorrowerCapture.salarySlips[index], file);
this.uploadCaptured(file, `COBORROWER:salarySlip:${index}`, `Co-Borrower Salary Slip — Month ${index + 1}`);
}
/** Single source of truth for "is there actually a usable co-borrower on file" -- name,
@@ -1014,12 +1113,14 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
coBorrowerDetailsComplete(a: LoanApplication): boolean {
const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added';
return (
this.coBorrowerCapture.wanted &&
a.coBorrowerWanted &&
hasName &&
!!this.coBorrowerCapture.relationship &&
!!this.coBorrowerCapture.monthlyIncome &&
this.coBorrowerCapture.monthlyIncome > 0 &&
this.coBorrowerCapture.salarySlips.every((doc) => !!doc.file)
!!a.coBorrowerRelationship &&
!!a.coBorrowerMonthlyIncome &&
a.coBorrowerMonthlyIncome > 0 &&
this.coBorrowerCapture.salarySlips.every(
(doc, index) => !!doc.file || this.hasStoredDoc(`COBORROWER:salarySlip:${index}`)
)
);
}
@@ -1027,15 +1128,15 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return a.monthlyIncome * 0.4;
}
coBorrowerEmiCapacity(): number {
return (this.coBorrowerCapture.monthlyIncome || 0) * 0.4;
coBorrowerEmiCapacity(a: LoanApplication): number {
return (a.coBorrowerMonthlyIncome || 0) * 0.4;
}
/** The 40% affordability rule applied to the household rather than the applicant alone: 40% of
* the applicant's own income, plus 40% of the co-borrower's (once their details are actually
* complete -- income and salary slips, not just a number typed in), added together. */
combinedEmiCapacity(a: LoanApplication): number {
const coBorrowerCapacity = this.coBorrowerDetailsComplete(a) ? this.coBorrowerEmiCapacity() : 0;
const coBorrowerCapacity = this.coBorrowerDetailsComplete(a) ? this.coBorrowerEmiCapacity(a) : 0;
return this.applicantEmiCapacity(a) + coBorrowerCapacity;
}
@@ -1059,7 +1160,20 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
maxWidth: '95vw'
})
.afterClosed()
.subscribe(() => this.syncCoOwnerDocSubmitted(a));
.subscribe(() => {
this.syncCoOwnerDocSubmitted(a);
// Files are captured inside the dialog (state is passed by reference), so they're uploaded
// here once it closes rather than at pick time.
const state = this.coOwnerCapture[docType];
const label = docType === 'aadhaar' ? 'Aadhaar' : 'PAN';
if (state.front) {
this.uploadCaptured(state.front, `COOWNER:${docType}:front`, `Co-Owner ${label} — front`);
}
if (state.back) {
this.uploadCaptured(state.back, `COOWNER:${docType}:back`, `Co-Owner ${label} — back`);
}
this.persist();
});
}
/** No co-owner ticked means there's nothing to collect, so the requirement is satisfied by
@@ -1103,7 +1217,181 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
});
}
// -------------------- Uploaded documents --------------------
/** Document rows already stored against this application, so a captured file is still there after
* a reload instead of only living in this browser session. */
uploadedDocs: any[] = [];
/** Count of uploads currently in flight, so the UI can say so instead of looking idle. */
uploadingDocs = 0;
docUploadFailed = false;
/**
* Sends a captured file to the document store under a stable key.
*
* The key (stored as the document's `name`) is what lets the file be matched back to its checklist
* slot on the next page load -- without it a reloaded screen would know a file exists but not which
* checkbox it belongs to. `label` is the human-readable text the officer sees.
*/
private uploadCaptured(file: File, key: string, label: string, onUploaded?: () => void): void {
const a = this.application;
if (!a?.id) {
// Application not persisted yet -- nothing to attach to. The local preview still shows.
return;
}
this.uploadingDocs++;
this.docUploadFailed = false;
this.originationService.uploadDocument(a.id, file, key, label).subscribe({
next: () => {
this.uploadingDocs--;
this.refreshDocuments(a);
onUploaded?.();
},
error: () => {
this.uploadingDocs--;
this.docUploadFailed = true;
}
});
}
private refreshDocuments(a: LoanApplication | undefined): void {
if (!a?.id) {
this.uploadedDocs = [];
return;
}
this.originationService.listDocuments(a.id).subscribe({
next: (docs: any[]) => {
this.uploadedDocs = docs ?? [];
},
error: () => {
this.uploadedDocs = [];
}
});
}
/** Every stored file filed under one checklist slot (a slot can hold several, e.g. FI site photos). */
storedDocsFor(key: string): any[] {
return this.uploadedDocs.filter((doc) => doc.name === key);
}
hasStoredDoc(key: string): boolean {
return this.storedDocsFor(key).length > 0;
}
/** One "View" button for a KYC slot that works whether the file is still local (just picked in
* this browser tab) or only exists in the document store (picked in an earlier session) --
* so the slot doesn't need two separate view controls for the same document. */
viewKycSlot(a: LoanApplication, localFile: File | null, key: string): void {
if (localFile) {
this.viewNewSingleDoc(localFile);
return;
}
const stored = this.storedDocsFor(key)[0];
if (stored) {
this.viewStoredDoc(a, stored);
}
}
/** Opens a stored file in the same preview dialog used for client KYC documents. */
viewStoredDoc(a: LoanApplication, doc: any): void {
if (!a.id) {
return;
}
this.originationService.downloadDocument(a.id, doc.id).subscribe({
next: (blob: Blob) => {
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.description || doc.name, fileName: doc.fileName }
});
},
error: () => {
this.toast('Could not open the stored document.');
}
});
}
removeStoredDoc(a: LoanApplication, doc: any): void {
if (!a.id) {
return;
}
this.originationService.deleteDocument(a.id, doc.id).subscribe({
next: () => this.refreshDocuments(a),
error: () => this.toast('Could not remove the stored document.')
});
}
// -------------------- Stage transitions (server-validated) --------------------
/**
* Runs a stage action on the server and adopts whatever it returns.
*
* The transition itself is applied server-side, so the local mutators are deliberately NOT called
* for these: the screen shows the state the server actually accepted rather than a client guess
* that a rejected action would leave visibly wrong. A rejection surfaces the server's own reason
* (e.g. "The visit has no GPS coordinates recorded") instead of a generic failure.
*/
private runStageAction(action: Observable<LoanApplication>): void {
this.saving = true;
this.saveFailed = false;
action.subscribe({
next: (updated: LoanApplication) => {
this.application = updated;
this.actionRemarks = '';
this.saving = false;
},
error: (err: any) => {
this.saving = false;
this.saveFailed = true;
this.toast(
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'The server rejected this action — check the stage requirements.'
);
}
});
}
// -------------------- Persistence --------------------
/** Writes the whole application back to the database. Called after every stage action, and
* (debounced) after inline field edits -- the local mutators have already updated the object, so
* this is deliberately the entire object rather than a per-action patch: there is no field list
* here that could fall out of step with the model and silently drop an officer's entry. */
persist(): void {
if (!this.application) {
return;
}
this.saving = true;
this.saveFailed = false;
this.originationService.save(this.application).subscribe({
next: () => {
this.saving = false;
},
error: () => {
// Left on screen rather than reverting: the officer's typing is still in the object and
// still visible, and clearing it on a transient network failure would lose real work.
this.saving = false;
this.saveFailed = true;
}
});
}
/** Bound once on the stage-card container, so it catches every `[(ngModel)]` field on this screen
* -- including ones added later -- instead of needing a handler wired per input. Both `input` and
* `change` bubble, and Angular's own ngModel listener sits on the input itself, so by the time
* this runs the model already holds the new value. Debounced so a typed field saves once. */
onInlineEdit(): void {
clearTimeout(this.inlineEditTimer);
this.inlineEditTimer = setTimeout(() => this.persist(), 800);
}
/** Flushes a pending debounce so navigating away mid-edit can't drop the last keystrokes. */
private flushPendingInlineEdit(): void {
if (this.inlineEditTimer) {
clearTimeout(this.inlineEditTimer);
this.inlineEditTimer = undefined;
this.persist();
}
}
ngOnDestroy(): void {
this.flushPendingInlineEdit();
this.sub?.unsubscribe();
this.revokeNewKycPreviews(this.newKycCapture);
this.revokeProductDocPreviews();
@@ -1218,6 +1506,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return;
}
checkCreditScore(a, this.currentUserName);
this.persist();
}
/** Uploading the customer's signed copy of the consent declaration is what actually unlocks
@@ -1236,6 +1525,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
this.setSingleDoc(this.cicConsentCapture, cropped);
a.cicConsentSigned = true;
this.uploadCaptured(cropped, 'CIC:consent', 'Signed Consent Declaration');
this.persist();
}
// -------------------- CIC check charge --------------------
@@ -1251,6 +1542,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
a.cicChargeAccountLabel = `${account.accountNo} — ${account.kind} (${account.productName})`;
a.cicChargeCollected = true;
this.toast(`Rs. ${this.cicCheckCharge} CIC check charge marked for debit from ${account.accountNo}.`);
this.persist();
}
/** New Customer -- no account exists yet, so this mirrors a real payment-gateway collection
@@ -1258,6 +1550,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
generateCicPaymentLink(a: LoanApplication): void {
a.cicChargeMode = 'PAYMENT_LINK';
a.cicPaymentLinkUrl = `https://pay.cbsnexora.local/cic-charge/${a.applicationNo}?amount=${this.cicCheckCharge}`;
this.persist();
}
copyCicPaymentLink(a: LoanApplication): void {
@@ -1303,6 +1596,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
a.cicPaymentReference = this.cicPaymentRefInput.trim();
a.cicChargeCollected = true;
this.cicPaymentRefInput = '';
this.persist();
this.toast('Payment reference recorded — CIC check charge marked as collected.');
}
@@ -1480,8 +1774,37 @@ I confirm that:
}
// -------------------- Generic stage-advance actions --------------------
/** Advance for the stages that have no checklist of their own to enforce (currently only
* Application → KYC). Every stage that DOES gate on something goes through its own
* server-validated endpoint instead -- see completeKycStage/completeCicStage/completeFi and the
* approval methods. */
proceed(a: LoanApplication, remarks: string): void {
proceedToNextStage(a, this.currentUserName, remarks);
this.persist();
}
/** KYC → CIC. The server re-checks Aadhaar/PAN, income proof, bank statement, address proof,
* income assessment and every product-specific document before it will let the stage move. */
completeKycStage(a: LoanApplication): void {
this.runStageAction(
this.originationService.completeKyc(
a.applicationNo,
this.currentUserName,
'KYC/OVD documents verified and collected.'
)
);
}
/** CIC → Field Investigation. The server re-checks the signed bureau consent, the collected
* charge and the uploaded report. */
completeCicStage(a: LoanApplication): void {
this.runStageAction(
this.originationService.completeCicCheck(
a.applicationNo,
this.currentUserName,
`${a.cicBureau} report reviewed — score ${a.cibilScore} (${this.cibilBandOf(a.cibilScore)}).`
)
);
}
/** Any stage the application has ever reached is clickable -- whether it's currently behind or
@@ -1502,6 +1825,7 @@ I confirm that:
return;
}
moveToStage(a, stage, this.currentUserName);
this.persist();
}
// -------------------- KYC & Documents --------------------
@@ -1606,6 +1930,7 @@ I confirm that:
a.coBorrowerName = 'To be added';
}
}
this.persist();
}
allKycDone(a: LoanApplication): boolean {
@@ -1661,6 +1986,7 @@ I confirm that:
URL.revokeObjectURL(doc.previewUrl);
}
});
this.persist();
}
async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise<void> {
@@ -1672,8 +1998,10 @@ I confirm that:
file,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
});
this.uploadCaptured(file, `FI:${key}`, `${this.fiCheckLabels[key]} — site photo`);
}
a[key].photoCount = this.fiPhotoCapture[key].length;
this.persist();
}
removeFiPhoto(a: LoanApplication, key: FiCheckKey, index: number): void {
@@ -1682,6 +2010,7 @@ I confirm that:
URL.revokeObjectURL(removed.previewUrl);
}
a[key].photoCount = this.fiPhotoCapture[key].length;
this.persist();
}
/** A visit can only be marked Verified once the officer has actually captured something --
@@ -1696,7 +2025,16 @@ I confirm that:
if (!this.fiCanVerify(a, key)) {
return;
}
recordVerification(a, key, 'VERIFIED', a[key].officerAssigned, 'Address visited and confirmed correct.');
// The GPS fix and photo count are re-checked server-side against the stored record, so a visit
// can't be marked verified on the strength of the client alone.
this.runStageAction(
this.originationService.verifyFieldCheck(
a.applicationNo,
key,
a[key].officerAssigned,
'Address visited and confirmed correct.'
)
);
}
rejectFieldVisit(a: LoanApplication, key: FiCheckKey): void {
@@ -1704,8 +2042,8 @@ I confirm that:
if (!remark) {
return;
}
recordVerification(a, key, 'FAILED', a[key].officerAssigned, remark);
this.fiRejectRemarkInput[key] = '';
this.runStageAction(this.originationService.rejectFieldCheck(a.applicationNo, key, a[key].officerAssigned, remark));
}
/** Hours elapsed since the officer was assigned -- the raw number the 48-hour SLA is checked
@@ -1733,17 +2071,55 @@ I confirm that:
}
completeFi(a: LoanApplication): void {
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
this.runStageAction(
this.originationService.completeFieldInvestigation(
a.applicationNo,
this.currentUserName,
'Field Investigation completed — Agent Report filed.'
)
);
}
/** Once a visit is Verified or Failed, the inline card only shows a short summary -- this opens
* the full record (officer, GPS, every site photo, remarks) in a popup instead of cramming it
* all into the card and breaking the grid layout. */
openFiVisitDetails(a: LoanApplication, key: FiCheckKey): void {
this.dialog.open(FieldVisitDetailsDialogComponent, {
data: { label: this.fiCheckLabels[key], check: a[key], photos: this.fiPhotoCapture[key] },
width: '520px',
maxWidth: '95vw'
const stored = this.storedDocsFor(`FI:${key}`);
const applicationId = a.id;
// Thumbnails need the actual bytes, not just the document record -- so every stored photo is
// downloaded up front (in parallel) rather than lazily on click, the same as a local capture's
// preview is available immediately. A failed download still shows the file name instead of
// silently dropping the photo from the grid.
const withPreviews =
!applicationId || stored.length === 0
? of([])
: forkJoin(
stored.map((doc) =>
this.originationService.downloadDocument(applicationId, doc.id).pipe(
map((blob) => ({ ...doc, previewUrl: URL.createObjectURL(blob) })),
catchError(() => of({ ...doc, previewUrl: null }))
)
)
);
withPreviews.subscribe((storedPhotos) => {
const ref = this.dialog.open(FieldVisitDetailsDialogComponent, {
data: {
label: this.fiCheckLabels[key],
check: a[key],
photos: this.fiPhotoCapture[key],
storedPhotos,
onViewStored: (doc: { id: number; fileName?: string }) => this.viewStoredDoc(a, doc)
},
width: '520px',
maxWidth: '95vw'
});
ref.afterClosed().subscribe(() => {
storedPhotos.forEach((p: { previewUrl: string | null }) => {
if (p.previewUrl) {
URL.revokeObjectURL(p.previewUrl);
}
});
});
});
}
@@ -1806,6 +2182,7 @@ I confirm that:
a.meetingWith.trim(),
this.currentUserName
);
this.persist();
}
// -------------------- Application Summary Report (briefing doc for the meeting) --------------------
@@ -2060,35 +2437,38 @@ I confirm that:
}
clearPersonalDiscussion(a: LoanApplication): void {
clearPersonalDiscussionAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
this.runStageAction(
this.originationService.clearPersonalDiscussion(
a.applicationNo,
this.currentUserName,
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
)
);
this.actionRemarks = '';
}
flagPersonalDiscussion(a: LoanApplication): void {
const notes =
this.actionRemarks.trim() ||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
this.actionRemarks = '';
const type = this.discrepancySelection;
this.discrepancySelection = 'NONE';
this.runStageAction(this.originationService.flagDiscrepancy(a.applicationNo, this.currentUserName, type, notes));
}
resolveDiscrepancy(a: LoanApplication): void {
resolveDiscrepancyAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
this.runStageAction(
this.originationService.resolveDiscrepancy(
a.applicationNo,
this.currentUserName,
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
)
);
this.actionRemarks = '';
}
// -------------------- Share Membership --------------------
allotMembership(a: LoanApplication): void {
allotShareMembership(a, this.currentUserName);
this.persist();
}
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
@@ -2103,45 +2483,49 @@ I confirm that:
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 = '';
this.runStageAction(
this.originationService.decideApproval(a.applicationNo, 'creditCommitteeApproval', {
decision: 'APPROVED',
approverName: this.currentUserName,
approverRole: 'Sanctioning Authority',
remarks: this.actionRemarks.trim() || 'Reviewed and approved.',
resolutionNo: a.committeeResolutionNo.trim(),
resolutionDate: a.committeeResolutionDate.trim()
})
);
}
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'APPROVED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Reviewed and approved.'
this.runStageAction(
this.originationService.decideApproval(a.applicationNo, field, {
decision: 'APPROVED',
approverName: this.currentUserName,
approverRole,
remarks: this.actionRemarks.trim() || 'Reviewed and approved.'
})
);
this.actionRemarks = '';
}
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'REJECTED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Declined on review.'
this.runStageAction(
this.originationService.decideApproval(a.applicationNo, field, {
decision: 'REJECTED',
approverName: this.currentUserName,
approverRole,
remarks: this.actionRemarks.trim() || 'Declined on review.'
})
);
this.actionRemarks = '';
}
exceptionApprove(a: LoanApplication): void {
decideApproval(
a,
'postCheckApproval',
'EXCEPTION_APPROVED',
this.currentUserName,
'Post-Disbursement Check',
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
this.runStageAction(
this.originationService.decideApproval(a.applicationNo, 'postCheckApproval', {
decision: 'EXCEPTION_APPROVED',
approverName: this.currentUserName,
approverRole: 'Post-Disbursement Check',
remarks: this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
})
);
this.actionRemarks = '';
}
legalChecklistDone(a: LoanApplication): boolean {
@@ -2227,6 +2611,7 @@ I confirm that:
this.guarantorShareAccounts = [];
this.guarantorSearchText = '';
this.guarantorSearchResults = [];
this.persist();
}
/** Same `/clients/{id}/accounts` call the applicant's own Share Member check uses -- a
@@ -2244,6 +2629,9 @@ I confirm that:
this.guarantorShareAccounts = data?.shareAccounts ?? [];
a.guarantorHasShareAccount = this.guarantorShareAccounts.length > 0;
this.loadingGuarantorShareCheck = false;
// Saved here rather than in selectGuarantor(), so the stored record carries the verified
// share-membership result and not just the picked name.
this.persist();
});
}
@@ -2281,6 +2669,8 @@ I confirm that:
uploadCicReport(a, file.name, this.currentUserName);
input.value = '';
this.uploadCaptured(file, 'CIC:report', `${a.cicBureau} Credit Report`);
this.persist();
this.openCicReportAnalysis(a);
}
@@ -2306,6 +2696,7 @@ I confirm that:
pushFieldInquiryNow(a: LoanApplication): void {
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
this.persist();
}
/** Free-text reference/summary the officer types before attaching the Agent Report file --
@@ -2319,21 +2710,54 @@ I confirm that:
return;
}
uploadAgentReport(a, file.name, this.agentReportRemarkInput.trim(), this.currentUserName);
this.uploadCaptured(file, 'FI:agentReport', 'Field Investigation Agent Report');
this.agentReportRemarkInput = '';
input.value = '';
this.persist();
}
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
markSigned(a: LoanApplication): void {
markCustomerSigned(a, a.applicantName);
this.runStageAction(this.originationService.markSigned(a.applicationNo, a.applicantName));
}
confirmChequeAndProceed(a: LoanApplication): void {
confirmChequeAch(a, this.currentUserName);
this.runStageAction(this.originationService.confirmChequeAch(a.applicationNo, this.currentUserName));
}
disburseNow(a: LoanApplication): void {
disburseApplication(a, this.currentUserName, this.disbursementMode);
/** For an Existing-Customer application, this is the one point where the pipeline stops being a
* mock checklist and actually creates the real Fineract loan account (create + approve +
* disburse in one go, see loan-fineract-bridge.ts) -- New Customer applications keep disbursing
* mock-only since there's no real clientId to attach a loan account to. A failure here leaves
* the application on the Disbursement stage (nothing marked Disbursed) so the officer can fix
* the underlying issue and retry rather than silently losing the real-account step. */
async disburseNow(a: LoanApplication): Promise<void> {
let realLoan: { fineractLoanId: number; fineractLoanAccountNo: string } | undefined;
if (a.customerType === 'EXISTING' && a.clientId) {
this.creatingRealLoan = true;
a.fineractLoanError = null;
try {
const result = await createRealLoanAccount(
a,
this.loansService,
this.productsService,
this.dateUtils,
this.settingsService
);
realLoan = { fineractLoanId: result.loanId, fineractLoanAccountNo: result.loanAccountNo };
} catch (err: any) {
a.fineractLoanError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.message || 'Could not create the real loan account.';
this.creatingRealLoan = false;
return;
}
this.creatingRealLoan = false;
}
// The server re-checks that all four approvals actually stand as approved before it will record
// the disbursement, and stores the real loan account reference in the same call.
this.runStageAction(
this.originationService.disburse(a.applicationNo, this.currentUserName, this.disbursementMode, realLoan)
);
}
// -------------------- EMI Repayment Schedule (once disbursed) --------------------