Files
cbsnexora/src/app/loan-origination/application-detail/application-detail.component.ts
T
niteshandClaude Sonnet 5 c18219fce5
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
feat(loan-origination): persist the sanctioning pipeline to the backend
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>
2026-08-17 17:05:05 +05:30

2784 lines
107 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
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';
import { ShareAccountDetailsDialogComponent } from './share-account-details-dialog/share-account-details-dialog.component';
import { BankStatementAnalysisDialogComponent } from './bank-statement-analysis-dialog/bank-statement-analysis-dialog.component';
import { CoOwnerKycVerifyDialogComponent } from './co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component';
import {
parseBankStatementCsv,
summarizeBankStatement
} from './bank-statement-analysis-dialog/bank-statement-analysis.util';
import { CicReportAnalysisDialogComponent } from './cic-report-analysis-dialog/cic-report-analysis-dialog.component';
import {
CicReportAnalysis,
CIC_KNOWN_BUREAUS,
generateCicReportAnalysis
} 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,
BranchMaster,
DiscrepancyType,
DISCREPANCY_LABELS,
EmiScheduleRow,
LoanApplication,
ORIGINATION_PRODUCTS,
OriginationStage,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
allotShareMembership,
assignFieldOfficer,
buildEmiSchedule,
calcEligibleAmount,
calculateEmi,
checkCreditScore,
cibilBand,
CIC_BUREAUS,
CIC_CHECK_CHARGE,
FiCheckKey,
FI_CHECK_LABELS,
MANAGERS,
moveToStage,
ProductDocumentEntry,
progressPct,
PROPERTY_TYPES,
pushFieldInquiry,
sanctioningAuthority,
scheduleMeeting,
setMonthlyIncome,
stageBadgeClass,
stageIndex,
proceedToNextStage,
todayDDMMYYYY,
uploadAgentReport,
uploadCicReport
} from '../loan-origination-data';
interface NewKycDocSide {
front: File | null;
back: File | null;
frontPreviewUrl: string | null;
backPreviewUrl: string | null;
number: string;
verified: boolean;
}
interface NewKycSingleDoc {
file: File | null;
previewUrl: string | null;
}
type EmploymentType = 'SALARIED' | 'BUSINESS';
/** An ITR year the customer genuinely doesn't have (e.g. business started after that year) can be
* waived with a remark instead of blocking the checklist forever on a document that will never
* turn up -- `exception` only ever applies when there's no file for that year (see the
* `!doc.file` guard in the template), so a file and a waiver can never both be "true" at once. */
interface ItrYearCapture extends NewKycSingleDoc {
exception: boolean;
exceptionRemark: string;
}
/** A salaried applicant proves income with one combined Form 16 / 3-month salary slip upload;
* a business/self-employed applicant needs the last 3 years' Income Tax Returns instead, each
* as its own separate upload. */
interface IncomeProofCapture {
employmentType: EmploymentType;
salaried: NewKycSingleDoc;
itr: ItrYearCapture[];
}
interface NewKycCaptureState {
aadhaar: NewKycDocSide & { otpSent: boolean; otpDigits: string[] };
pan: NewKycDocSide;
incomeProof: IncomeProofCapture;
bankStatement: NewKycSingleDoc;
electricityBill: NewKycSingleDoc & { billDate: string | null };
}
/**
* A single application's full sanctioning journey — the stage stepper, the current stage's
* action card (checklist / CIC upload / Field Investigation / Committee-Risk-Legal approval /
* signing / disbursement), and the complete date-wise audit trail. Every action here mutates the
* SAME LoanApplication object the Pipeline list reads, so the two screens can never disagree.
*/
@Component({
selector: 'mifosx-application-detail',
templateUrl: './application-detail.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../loan-origination-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class ApplicationDetailComponent implements OnInit, OnDestroy {
readonly stageOrder = STAGE_ORDER;
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly discrepancyLabels = DISCREPANCY_LABELS;
asOnDate = todayDDMMYYYY();
application: LoanApplication | undefined;
/** Shared remarks box for whichever action card is currently on screen. */
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. */
monthlyIncomeInput: number | null = null;
requestedAmountInput: number | null = null;
tenureMonthsInput: number | null = null;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
currentUserName = 'Branch User';
/** True for a Head Office login, which can open any branch's application — a branch-level
* login is locked to its own branch, the same split every other report in the app uses. */
isHeadOffice = false;
private ownBranchCode: string | null = null;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
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;
/** Real Fineract share accounts for this application's linked client (only populated when
* LoanApplication.clientId is set -- i.e. raised against a real Existing Customer pick, not
* the mock isShareMember/shareCertNo/shareMemberSince fields every other application uses). */
realShareAccounts: any[] = [];
loadingShareAccounts = false;
/** The linked client's own open savings/current accounts -- offered as the CIC check charge's
* debit source. Populated by the same call as realShareAccounts above. */
realDebitableAccounts: any[] = [];
/** Real KYC record + generic uploaded documents for this application's linked client --
* only populated when LoanApplication.clientId is set, same real-vs-mock split as Share
* Member above. */
realKyc: any = null;
realDocuments: any[] = [];
loadingKyc = false;
/** Every address Fineract has on file for this application's linked client (Residential,
* Permanent, Office/Business, etc.) -- shown in full at Field Investigation instead of the
* single free-text address every mock/New-Customer application uses, so the officer sending
* someone out to verify knows exactly which addresses actually exist for this person. */
realAddresses: any[] = [];
loadingAddresses = false;
/** New-Customer applications (no real client yet) get a client-creation-style capture UI
* instead -- front/back document images + an OTP box -- but since no real client/kycId
* exists yet, Aadhaar/PAN can't actually be verified against Fineract here (every one of
* those APIs requires a real clientId+kycId). This stays purely local/simulated, same as
* every other field on a New Customer application in this mock pipeline. */
newKycCapture: NewKycCaptureState = this.emptyNewKycCapture();
/** The customer's signed copy of the CIC consent declaration -- local-only, same as the KYC
* capture above. Uploading this is what actually flips `cicConsentSigned`, so "Check Credit
* Score" can't be reached on a self-ticked checkbox with nothing behind it. */
cicConsentCapture: NewKycSingleDoc = { file: null, previewUrl: null };
/** Local file capture for each of `a.productDocuments`, keyed by the entry's `key` -- the
* submitted/missing flag lives on the application itself (like every other KYC checklist
* item), but the actual File/preview is local-only, same as the rest of this mock pipeline. */
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
* document card grid doesn't balloon in height) even shows up. */
coOwnerCapture: { hasCoOwner: boolean; aadhaar: NewKycDocSide; pan: NewKycDocSide } = {
hasCoOwner: false,
aadhaar: this.emptyDocSide(),
pan: this.emptyDocSide()
};
/** 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
* `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: {
salarySlips: NewKycSingleDoc[];
} = this.emptyCoBorrowerCapture();
private emptyCoBorrowerCapture(): {
salarySlips: NewKycSingleDoc[];
} {
return {
salarySlips: [
{ file: null, previewUrl: null },
{ file: null, previewUrl: null },
{ file: null, previewUrl: null }
]
};
}
private revokeCoBorrowerPreviews(): void {
this.coBorrowerCapture.salarySlips.forEach((doc) => {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
});
}
/** The officer name typed before "Assign Officer" is clicked -- transient, cleared once the
* assignment actually lands on the application (see assignFieldOfficerNow()). */
fiOfficerNameInput: Record<FiCheckKey, string> = {
addressVerification: '',
officeVerification: '',
propertyVerification: '',
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: '',
officeVerification: '',
propertyVerification: '',
shopVerification: ''
};
/** The site photos captured for each FI address -- local-only, same as every other document
* capture in this mock pipeline; only the count is persisted on the application itself
* (VerificationCheck.photoCount), which is what actually gates "Mark Verified". */
fiPhotoCapture: Record<FiCheckKey, NewKycSingleDoc[]> = {
addressVerification: [],
officeVerification: [],
propertyVerification: [],
shopVerification: []
};
/** Ticks once a minute so the 48-hour SLA countdown/overdue alert stays live on screen without
* needing a manual refresh. */
nowTick = Date.now();
private fiTickTimer: ReturnType<typeof setInterval> | undefined;
private revokeFiPhotoPreviews(): void {
(Object.keys(this.fiPhotoCapture) as FiCheckKey[]).forEach((key) => {
this.fiPhotoCapture[key].forEach((doc) => {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
});
});
}
private emptyDocSide(): NewKycDocSide {
return { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false };
}
private revokeDocSidePreviews(side: NewKycDocSide): void {
if (side.frontPreviewUrl) {
URL.revokeObjectURL(side.frontPreviewUrl);
}
if (side.backPreviewUrl) {
URL.revokeObjectURL(side.backPreviewUrl);
}
}
private emptyNewKycCapture(): NewKycCaptureState {
return {
aadhaar: {
front: null,
back: null,
frontPreviewUrl: null,
backPreviewUrl: null,
number: '',
otpSent: false,
otpDigits: [
'',
'',
'',
'',
'',
''
],
verified: false
},
pan: { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false },
incomeProof: {
employmentType: 'SALARIED',
salaried: { file: null, previewUrl: null },
itr: [
{ file: null, previewUrl: null, exception: false, exceptionRemark: '' },
{ file: null, previewUrl: null, exception: false, exceptionRemark: '' },
{ file: null, previewUrl: null, exception: false, exceptionRemark: '' }
]
},
bankStatement: { file: null, previewUrl: null },
electricityBill: { file: null, previewUrl: null, billDate: null }
};
}
/** Every uploaded-file preview in this local-only capture UI is an object URL -- release them
* before rebuilding the state (a new application selected) or the component is torn down, so
* a long session doesn't leak blob URLs. */
private revokeNewKycPreviews(state: NewKycCaptureState): void {
[
state.aadhaar.frontPreviewUrl,
state.aadhaar.backPreviewUrl,
state.pan.frontPreviewUrl,
state.pan.backPreviewUrl,
state.incomeProof.salaried.previewUrl,
...state.incomeProof.itr.map((doc) => doc.previewUrl),
state.bankStatement.previewUrl,
state.electricityBill.previewUrl,
this.cicConsentCapture.previewUrl
]
.filter((url): url is string => !!url)
.forEach((url) => URL.revokeObjectURL(url));
}
ngOnInit(): void {
const creds = this.auth.getCredentials();
this.currentUserName = creds?.username || 'Branch User';
this.isHeadOffice = this.auth.isHeadOfficeUser();
if (!this.isHeadOffice) {
const officeName = creds?.officeName || '';
this.ownBranchCode =
BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
}
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');
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;
this.requestedAmountInput = this.application?.requestedAmount || null;
this.tenureMonthsInput = this.application?.tenureMonths || null;
this.revokeNewKycPreviews(this.newKycCapture);
this.newKycCapture = this.emptyNewKycCapture();
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() };
this.revokeCoBorrowerPreviews();
this.coBorrowerCapture = this.emptyCoBorrowerCapture();
if (this.application) {
this.syncCoOwnerDocSubmitted(this.application);
}
this.revokeFiPhotoPreviews();
this.fiPhotoCapture = {
addressVerification: [],
officeVerification: [],
propertyVerification: [],
shopVerification: []
};
this.fiOfficerNameInput = {
addressVerification: '',
officeVerification: '',
propertyVerification: '',
shopVerification: ''
};
this.fiRejectRemarkInput = {
addressVerification: '',
officeVerification: '',
propertyVerification: '',
shopVerification: ''
};
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
* membership check and a real account list to debit the CIC charge from -- every other
* application keeps using the mock isShareMember fields / the payment-link flow. One call
* to `/clients/{id}/accounts` covers both, since it returns shares, savings and current
* accounts together. */
private loadRealClientAccounts(): void {
this.realShareAccounts = [];
this.realDebitableAccounts = [];
const clientId = this.application?.clientId;
if (!clientId) {
return;
}
this.loadingShareAccounts = true;
this.clientsService
.getClientAccountData(String(clientId))
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.realShareAccounts = data?.shareAccounts ?? [];
const isClosed = (acc: any) => [
'savingsAccountStatusType.withdrawn.by.applicant',
'savingsAccountStatusType.closed',
'savingsAccountStatusType.pre.mature.closure',
'savingsAccountStatusType.rejected'
].includes(acc?.status?.code);
const savings = (data?.savingsAccounts ?? [])
.filter((acc: any) => acc?.depositType?.value === 'Savings' && !isClosed(acc))
.map((acc: any) => ({ ...acc, kind: 'Savings' }));
const current = (data?.currentAccounts ?? [])
.filter((acc: any) => !isClosed(acc))
.map((acc: any) => ({ ...acc, kind: 'Current' }));
this.realDebitableAccounts = [
...savings,
...current
];
this.loadingShareAccounts = false;
});
}
viewShareAccountDetails(shareAccountId: number): void {
this.sharesService
.getSharesAccountData(String(shareAccountId), true)
.pipe(catchError(() => of(null)))
.subscribe((account: any) => {
if (!account) {
return;
}
this.dialog.open(ShareAccountDetailsDialogComponent, {
data: { account },
width: '520px'
});
});
}
// -------------------- KYC & Documents — real client data --------------------
/** Pulls the linked client's real KYC record + generic uploaded documents, then syncs the
* mock checklist booleans that the rest of this screen's stage-gating logic (allKycDone,
* committeeReportsReady, etc.) already reads -- so a real, already-verified customer's
* application progresses exactly as if an officer had ticked the boxes, while the template
* shows the real verified/masked/viewable data instead of a plain checkbox. */
private loadRealKyc(): void {
this.realKyc = null;
this.realDocuments = [];
const a = this.application;
const clientId = a?.clientId;
if (!a || !clientId) {
return;
}
this.loadingKyc = true;
forkJoin({
kyc: this.clientsService.getClientKyc(String(clientId), true).pipe(catchError(() => of(null))),
documents: this.clientsService.getClientDocuments(String(clientId)).pipe(catchError(() => of([])))
}).subscribe(({ kyc, documents }: { kyc: any; documents: any[] }) => {
this.realKyc = kyc;
this.realDocuments = documents ?? [];
this.loadingKyc = false;
if (kyc) {
a.aadhaarVerified = !!kyc.aadhaarVerified;
a.panVerified = !!kyc.panVerified;
}
a.incomeProofSubmitted = !!this.findDocument('income') || a.incomeProofSubmitted;
a.bankStatementSubmitted = !!this.findDocument('bank statement') || a.bankStatementSubmitted;
a.electricityBillSubmitted =
!!this.findDocument('electricity') || !!this.findDocument('address proof') || a.electricityBillSubmitted;
});
}
/** The generic document (Income Proof / Bank Statement / Electricity Bill) whose name or
* description matches the given keyword, if the client has one on file in Fineract. */
findDocument(keyword: string): any {
const k = keyword.toLowerCase();
return this.realDocuments.find(
(d) => (d.name || '').toLowerCase().includes(k) || (d.description || '').toLowerCase().includes(k)
);
}
// -------------------- Field Investigation — real client addresses --------------------
/** Every address type Fineract has on file for the linked client -- only meaningful once
* a.clientId is set, same real-vs-mock split as loadRealKyc() above. */
private loadRealAddresses(): void {
this.realAddresses = [];
const clientId = this.application?.clientId;
if (!clientId) {
return;
}
this.loadingAddresses = true;
this.clientsService
.getClientAddressData(String(clientId))
.pipe(catchError(() => of([])))
.subscribe((addresses: any) => {
this.realAddresses = Array.isArray(addresses) ? addresses : [];
this.loadingAddresses = false;
});
}
formatRealAddress(addr: any): string {
return [
addr?.addressLine1,
addr?.addressLine2,
addr?.townVillage,
addr?.city,
addr?.stateName,
addr?.countryName,
addr?.postalCode
]
.filter((p) => !!p && String(p).trim())
.join(', ');
}
/** Masks a PAN (e.g. ABCDE1234F) down to just the last 4 characters, matching the masking
* convention Fineract's KYC API already applies to Aadhaar (aadhaarNumberMasked). */
maskPan(pan: string | undefined): string {
if (!pan || pan.length <= 4) {
return pan || '—';
}
return 'X'.repeat(pan.length - 4) + pan.slice(-4);
}
/** Resolves and shows the actual Aadhaar/PAN identification document image for the linked
* real client -- same client_identifiers lookup client-kyc-step.component.ts uses. */
viewKycIdentifierDoc(kind: 'aadhaar' | 'pan'): void {
const clientId = this.application?.clientId;
if (!clientId) {
return;
}
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
this.clientsService.getClientIdentifiers(String(clientId)).subscribe({
next: (identifiers: any[]) => {
if (!identifiers || identifiers.length === 0) {
return;
}
const sorted = [...identifiers].sort((x: any, y: any) => (y?.id ?? 0) - (x?.id ?? 0));
const match = sorted.find((i: any) => cleanStr(i?.documentType?.name).includes(kind));
if (!match?.id) {
return;
}
const identifierId = match.id.toString();
this.clientsService.getClientIdentificationDocuments(identifierId).subscribe({
next: (docs: any[]) => {
if (!docs || docs.length === 0) {
return;
}
const doc = [...docs].sort((x: any, y: any) => (y?.id ?? 0) - (x?.id ?? 0))[0];
this.clientsService.downloadClientIdentificationDocument(identifierId, doc.id.toString()).subscribe({
next: (blob: Blob) => {
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.name || doc.fileName, fileName: doc.fileName }
});
},
error: () => {}
});
},
error: () => {}
});
},
error: () => {}
});
}
/** Shows a generic uploaded document (Income Proof / Bank Statement / Electricity Bill). */
viewGenericDocument(doc: any): void {
const clientId = this.application?.clientId;
if (!clientId || !doc?.id) {
return;
}
this.clientsService.downloadClientDocument(String(clientId), doc.id.toString()).subscribe({
next: (blob: Blob) => {
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.name || doc.fileName, fileName: doc.fileName }
});
},
error: () => {}
});
}
// -------------------- KYC & Documents — new customer (local-only capture) --------------------
/** New-Customer applications have no real Fineract client yet, so this mirrors the
* client-creation KYC step's capture UX (front/back image + crop, OTP entry) without calling
* any of its real Aadhaar/PAN verification endpoints -- every one of those requires an
* already-created clientId + kycId. Purely local state, same as the rest of this mock
* pipeline's New Customer flow. */
async onNewKycFile(doc: 'aadhaar' | 'pan', side: 'front' | 'back', event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
const cropped = await this.kycUploadHelper.pickWithCrop(file, `${doc === 'aadhaar' ? 'Aadhaar' : 'PAN'} — ${side}`);
if (!cropped) {
return;
}
const target = this.newKycCapture[doc];
const previewKey = side === 'front' ? 'frontPreviewUrl' : 'backPreviewUrl';
if (target[previewKey]) {
URL.revokeObjectURL(target[previewKey]);
}
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 {
if (this.newKycCapture.aadhaar.number.trim().length !== 12) {
return;
}
this.newKycCapture.aadhaar.otpSent = true;
this.newKycCapture.aadhaar.otpDigits = [
'',
'',
'',
'',
'',
''
];
}
onNewAadhaarOtpDigit(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '');
this.newKycCapture.aadhaar.otpDigits[index] = val.charAt(0) || '';
input.value = this.newKycCapture.aadhaar.otpDigits[index];
if (this.newKycCapture.aadhaar.otpDigits[index] && index < 5) {
const next = input.parentElement?.querySelectorAll('input')[index + 1];
(next as HTMLInputElement)?.focus();
}
}
onNewAadhaarOtpKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.newKycCapture.aadhaar.otpDigits[index] && index > 0) {
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
if (inputs) {
this.newKycCapture.aadhaar.otpDigits[index - 1] = '';
(inputs[index - 1] as HTMLInputElement).value = '';
(inputs[index - 1] as HTMLInputElement).focus();
}
}
}
/** Local-only "verification" -- flips the badge once every OTP box is filled, and also flips
* the application's real `aadhaarVerified` flag, since that's what actually gates "Proceed to
* CIC / Credit Bureau Check" (allKycDone) -- the capture-UI badge alone never did. No real
* Aadhaar API is reachable here (no clientId exists yet for this application). */
verifyNewAadhaarOtp(a: LoanApplication): void {
if (this.newKycCapture.aadhaar.otpDigits.every((d) => d !== '')) {
this.newKycCapture.aadhaar.verified = true;
a.aadhaarVerified = true;
}
}
/** Local-only "verification" -- flips the badge once the PAN looks well-formed, and also flips
* the application's real `panVerified` flag for the same reason as Aadhaar above. No real PAN
* API is reachable here (no clientId exists yet for this application). */
verifyNewPan(a: LoanApplication): void {
if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.newKycCapture.pan.number.trim().toUpperCase())) {
this.newKycCapture.pan.verified = true;
a.panVerified = true;
}
}
/** Salaried customers prove income with one Form 16 / 3-month salary slip upload; business/
* self-employed customers need their last 3 years' Income Tax Returns instead, each as its
* own separate upload since a single year only proves one point in time. */
onIncomeProofEmploymentTypeChange(a: LoanApplication): void {
this.syncIncomeProofSubmitted(a);
}
async onNewSalariedProofFile(a: LoanApplication, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, 'Form 16 / 3 Month Salary Slip');
if (!file) {
return;
}
this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
this.uploadCaptured(file, 'KYC:incomeProof:salaried', 'Form 16 / 3 Month Salary Slip');
this.syncIncomeProofSubmitted(a);
}
async onNewItrFile(a: LoanApplication, yearIndex: number, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, `ITR — Year ${yearIndex + 1}`);
if (!file) {
return;
}
this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file);
this.uploadCaptured(file, `KYC:incomeProof:itr:${yearIndex}`, `ITR — Year ${yearIndex + 1}`);
this.syncIncomeProofSubmitted(a);
}
/** An officer waives a missing ITR year here instead of the checklist being permanently
* blocked on a return that will never turn up -- the remark records exactly which year and
* why, so anyone reviewing the file later knows it wasn't just forgotten. */
onItrExceptionChange(a: LoanApplication): void {
this.syncIncomeProofSubmitted(a);
}
private syncIncomeProofSubmitted(a: LoanApplication): void {
const ip = this.newKycCapture.incomeProof;
a.incomeProofSubmitted =
ip.employmentType === 'BUSINESS'
? ip.itr.every((doc) => !!doc.file || (doc.exception && doc.exceptionRemark.trim() !== ''))
: !!ip.salaried.file;
}
/** True once every ITR year is covered but at least one of them is a waiver rather than an
* actual return -- flagged separately in the UI so "Submitted" doesn't quietly hide the fact
* that a year's return was never actually produced. */
get itrHasException(): boolean {
const ip = this.newKycCapture.incomeProof;
return ip.employmentType === 'BUSINESS' && ip.itr.some((doc) => doc.exception);
}
async onNewBankStatementFile(a: LoanApplication, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, 'Bank Statement');
if (!file) {
return;
}
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);
}
}
isCsvFile(file: File | null): boolean {
return !!file && (file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv'));
}
/** Re-opens the analysis for a bank statement already on file -- the dialog only shows once,
* right after upload, so this is the only way back into it once an officer has closed it to
* double-check something. */
reopenBankStatementAnalysis(file: File | null): void {
if (!file) {
return;
}
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> {
const text = await file.text();
const transactions = parseBankStatementCsv(text);
if (!transactions.length) {
return;
}
this.dialog.open(BankStatementAnalysisDialogComponent, {
data: { fileName: file.name, summary: summarizeBankStatement(transactions) },
width: '900px',
maxWidth: '95vw'
});
}
/** Address-proof bills go stale -- RBI KYC practice treats a utility bill older than 3
* months as no longer good evidence of the customer's current address, so this blocks the
* checklist item until a recent-enough date is entered. */
get electricityBillTooOld(): boolean {
const billDate = this.newKycCapture.electricityBill.billDate;
if (!billDate) {
return false;
}
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 3);
return new Date(billDate) < cutoff;
}
onElectricityBillDateChange(a: LoanApplication): void {
this.syncElectricityBillSubmitted(a);
}
async onNewElectricityBillFile(a: LoanApplication, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, 'Electricity Bill');
if (!file) {
return;
}
this.setSingleDoc(this.newKycCapture.electricityBill, file);
this.uploadCaptured(file, 'KYC:electricityBill', 'Electricity Bill');
this.syncElectricityBillSubmitted(a);
}
private syncElectricityBillSubmitted(a: LoanApplication): void {
a.electricityBillSubmitted = !!this.newKycCapture.electricityBill.file && !this.electricityBillTooOld;
}
// -------------------- Product-specific documents (Housing/KCC/Vehicle/... papers) --------------------
async onProductDocFile(doc: ProductDocumentEntry, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, doc.label);
if (!file) {
return;
}
if (!this.productDocCapture[doc.key]) {
this.productDocCapture[doc.key] = { file: null, previewUrl: null };
}
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
* need (e.g. a second or third co-owner's KYC on a Housing Loan) -- this lets the officer add
* exactly the document they're holding, under whatever label describes it. */
addCustomProductDoc(a: LoanApplication): void {
const label = this.newCustomDocLabel.trim();
if (!label) {
return;
}
const key = `custom-${a.productDocuments.length}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
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,
* since that's what the loan is actually collateralised/eligible against. */
removeCustomProductDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
if (!doc.custom) {
return;
}
const idx = a.productDocuments.indexOf(doc);
if (idx >= 0) {
a.productDocuments.splice(idx, 1);
}
const capture = this.productDocCapture[doc.key];
if (capture?.previewUrl) {
URL.revokeObjectURL(capture.previewUrl);
}
delete this.productDocCapture[doc.key];
this.persist();
}
// -------------------- 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) {
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 {
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 = '';
this.persist();
}
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];
this.persist();
}
onCoOwnerToggle(a: LoanApplication): void {
this.syncCoOwnerDocSubmitted(a);
}
// -------------------- Co-Borrower income capture --------------------
async onCoBorrowerSalarySlipFile(index: number, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, `Co-Borrower Salary Slip — Month ${index + 1}`);
if (!file) {
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,
* relationship, income and all 3 salary slips. Both the eligibility badge and the blocking
* error message key off this SAME method (not separate ad-hoc checks), so the two can never
* disagree with each other on screen. */
coBorrowerDetailsComplete(a: LoanApplication): boolean {
const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added';
return (
a.coBorrowerWanted &&
hasName &&
!!a.coBorrowerRelationship &&
!!a.coBorrowerMonthlyIncome &&
a.coBorrowerMonthlyIncome > 0 &&
this.coBorrowerCapture.salarySlips.every(
(doc, index) => !!doc.file || this.hasStoredDoc(`COBORROWER:salarySlip:${index}`)
)
);
}
applicantEmiCapacity(a: LoanApplication): number {
return a.monthlyIncome * 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(a) : 0;
return this.applicantEmiCapacity(a) + coBorrowerCapacity;
}
combinedEligibilityOk(a: LoanApplication): boolean {
if (this.isRevolving(a)) {
return true;
}
return this.emiAmountFor(a) <= this.combinedEmiCapacity(a);
}
/** Front/back capture and OTP/format verification happen in their own popup rather than inline
* in the document card -- the exact same reason the applicant's own Aadhaar/PAN capture was
* moved out of a flat card grid: that much UI crammed into one card breaks the grid's layout.
* `state` is passed by reference, so anything the dialog does to it (new files, verified flip)
* is already reflected here the moment the dialog closes, regardless of how it was closed. */
openCoOwnerVerify(a: LoanApplication, docType: 'aadhaar' | 'pan'): void {
this.dialog
.open(CoOwnerKycVerifyDialogComponent, {
data: { docType, state: this.coOwnerCapture[docType] },
width: '480px',
maxWidth: '95vw'
})
.afterClosed()
.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
* default -- it only actually blocks the checklist once the officer says a co-owner exists
* and their Aadhaar/PAN haven't both been VERIFIED yet (not just uploaded). */
private syncCoOwnerDocSubmitted(a: LoanApplication): void {
const doc = a.productDocuments.find((d) => d.key === 'coOwnerKyc');
if (!doc) {
return;
}
doc.submitted =
!this.coOwnerCapture.hasCoOwner || (this.coOwnerCapture.aadhaar.verified && this.coOwnerCapture.pan.verified);
}
private setSingleDoc(doc: NewKycSingleDoc, file: File): void {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
doc.file = file;
doc.previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
}
private async pickSingleNewKycFile(event: Event, title: string): Promise<File | null> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return null;
}
return this.kycUploadHelper.pickWithCrop(file, title);
}
/** Opens whichever local, not-yet-uploaded-anywhere file the officer picked for one of the
* New Customer capture blocks -- straight from the in-memory File, no backend round trip. */
viewNewSingleDoc(file: File | null): void {
if (!file) {
return;
}
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob: file, title: file.name, fileName: file.name }
});
}
// -------------------- 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();
this.revokeLegalDocPreviews();
this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
this.revokeDocSidePreviews(this.coOwnerCapture.pan);
this.revokeCoBorrowerPreviews();
this.revokeFiPhotoPreviews();
if (this.fiTickTimer) {
clearInterval(this.fiTickTimer);
}
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
}
private revokeProductDocPreviews(): void {
Object.values(this.productDocCapture).forEach((doc) => {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
});
}
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;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
goBack(): void {
this.router.navigate(['/loan-origination/pipeline']);
}
// -------------------- Stepper --------------------
/** A stage reads as "done" (green check) once the application has ever reached past it,
* regardless of whether `currentStage` is now sitting before or after it -- jumping back for
* a correction (see moveToStage) doesn't make an already-completed stage look unfinished. */
private isStageDone(a: LoanApplication, stage: OriginationStage): boolean {
const idx = stageIndex(stage);
return idx !== stageIndex(a.currentStage) && idx <= a.furthestStageIndex;
}
stepStatusClass(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
if (stage === a.currentStage && a.overallStatus === 'REJECTED') {
return 'loan-step loan-step--rejected';
}
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
return 'loan-step loan-step--hold';
}
if (this.isStageDone(a, stage) || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
return 'loan-step loan-step--done';
}
if (idx === curIdx) {
return 'loan-step loan-step--current';
}
return 'loan-step';
}
stepMarker(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
if (this.isStageDone(a, stage) || (idx === stageIndex(a.currentStage) && a.overallStatus === 'DISBURSED')) {
return '✓';
}
return String(idx + 1);
}
progress(a: LoanApplication): number {
return progressPct(a);
}
/** Colours an audit-trail entry's icon badge by what actually happened — a rejection/hold
* reads as an alert, everything else (approvals, verifications, uploads, signings) reads
* as positive progress. */
historyToneClass(outcome: string): string {
if (/reject|hold/i.test(outcome)) {
return 'npa-timeline-item npa-timeline-item--alert';
}
return 'npa-timeline-item npa-timeline-item--positive';
}
badgeClass(a: LoanApplication): string {
return stageBadgeClass(a);
}
cibilBandOf(score: number): string {
return cibilBand(score);
}
readonly cicBureaus = CIC_BUREAUS;
checkCreditScoreNow(a: LoanApplication): void {
// RBI's Fair Practices Code requires the customer's consent before a bureau pull, and the
// bureau's per-pull fee has to actually be collected first -- the button is disabled in the
// template for the same reasons, this is just the backstop.
if (!a.cicConsentSigned || !a.cicChargeCollected) {
return;
}
checkCreditScore(a, this.currentUserName);
this.persist();
}
/** Uploading the customer's signed copy of the consent declaration is what actually unlocks
* "Check Credit Score" -- a self-ticked checkbox proved nothing, this at least proves the
* officer has a scan of a signed paper in hand. */
async onCicConsentFile(a: LoanApplication, event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
const cropped = await this.kycUploadHelper.pickWithCrop(file, 'Signed Consent Declaration');
if (!cropped) {
return;
}
this.setSingleDoc(this.cicConsentCapture, cropped);
a.cicConsentSigned = true;
this.uploadCaptured(cropped, 'CIC:consent', 'Signed Consent Declaration');
this.persist();
}
// -------------------- CIC check charge --------------------
readonly cicCheckCharge = CIC_CHECK_CHARGE;
/** Reference number keyed in against a payment-link collection -- cleared after each confirm
* so a stale reference can't accidentally get reused on a different application. */
cicPaymentRefInput = '';
/** Existing Customer -- the officer picks one of the client's own real accounts; this pipeline
* never posts an actual Fineract transaction, so the "debit" is recorded as a label only. */
selectCicChargeAccount(a: LoanApplication, account: any): void {
a.cicChargeMode = 'ACCOUNT_DEBIT';
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
* link (illustrative only -- nothing is actually charged in this mock pipeline). */
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 {
const fallback = (): void => {
const ta = document.createElement('textarea');
ta.value = a.cicPaymentLinkUrl;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {
/* ignore */
}
document.body.removeChild(ta);
};
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(a.cicPaymentLinkUrl).catch(fallback);
} else {
fallback();
}
this.toast('Payment link copied to clipboard.');
}
emailCicPaymentLink(a: LoanApplication): void {
const subject = `Credit Bureau Check Charge — ${a.applicationNo}`;
const body = `Dear ${a.applicantName},\n\nPlease pay the credit bureau check charge of Rs. ${this.cicCheckCharge} for your loan application ${a.applicationNo} using the link below:\n${a.cicPaymentLinkUrl}\n\nRegards,\n${this.currentUserName}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
whatsappCicPaymentLink(a: LoanApplication): void {
const msg = `Dear ${a.applicantName}, please pay the credit bureau check charge of Rs. ${this.cicCheckCharge} for your loan application ${a.applicationNo} using this link: ${a.cicPaymentLinkUrl}`;
window.open(`https://wa.me/?text=${encodeURIComponent(msg)}`, '_blank', 'noopener');
}
/** The reference the customer hands back after paying externally is the only proof this
* pipeline has that the payment actually happened -- that's what flips `cicChargeCollected`. */
confirmCicPaymentReceived(a: LoanApplication): void {
if (!this.cicPaymentRefInput.trim()) {
return;
}
a.cicPaymentReference = this.cicPaymentRefInput.trim();
a.cicChargeCollected = true;
this.cicPaymentRefInput = '';
this.persist();
this.toast('Payment reference recorded — CIC check charge marked as collected.');
}
// -------------------- CIC consent declaration letter --------------------
private buildCicConsentPdf(a: LoanApplication): jsPDF {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const contentWidth = pageWidth - margin * 2;
let y = 20;
const addLine = (label: string, value: string, yPos: number): number => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(label, margin, yPos);
doc.setFont('helvetica', 'normal');
doc.text(value || '—', margin + 55, yPos);
return yPos + 7;
};
const drawHr = (yPos: number): number => {
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.line(margin, yPos, pageWidth - margin, yPos);
return yPos + 5;
};
doc.setFillColor(15, 23, 42);
doc.rect(0, 0, pageWidth, 30, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text('Credit Information Report — Consent Declaration', pageWidth / 2, 13, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(
'As per the Credit Information Companies (Regulation) Act, 2005 and RBI Fair Practices Code',
pageWidth / 2,
21,
{
align: 'center'
}
);
doc.setTextColor(0, 0, 0);
y = 40;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Branch: ${a.branchName}`, margin, y);
doc.text(`Date: ${todayDDMMYYYY()}`, pageWidth - margin, y, { align: 'right' });
y += 8;
y = drawHr(y);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Application Details', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
y = addLine('Applicant Name:', a.applicantName, y);
y = addLine('Application No:', a.applicationNo, y);
y = addLine('Product:', a.productName, y);
y = addLine('Requested Amount:', `Rs. ${a.requestedAmount.toLocaleString('en-IN')}`, y);
y = addLine('Credit Bureau:', a.cicBureau, y);
y += 3;
y = drawHr(y);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Declaration', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9.5);
const declarationText = `I, ${a.applicantName}, having applied for ${a.productName} (Application No. ${a.applicationNo}) with this Bank, hereby give my free and informed consent to the Bank to access, obtain and verify my credit information from ${a.cicBureau} and/or any other Credit Information Company registered under the Credit Information Companies (Regulation) Act, 2005, for the purpose of assessing my creditworthiness in connection with this loan application.
I confirm that:
1. This consent is given voluntarily and without any coercion.
2. I understand my credit information will be used solely for evaluating this loan application.
3. I have been informed of my rights under the RBI Fair Practices Code, including the right to access my own credit information report.
4. This declaration does not obligate the Bank to sanction the loan applied for.`;
const lines = doc.splitTextToSize(declarationText, contentWidth);
doc.text(lines, margin, y);
y += lines.length * 4.5 + 12;
y = drawHr(y);
y += 5;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('________________________', margin, y + 15);
doc.text('Signature of Customer', margin, y + 21);
doc.text(`Name: ${a.applicantName}`, margin, y + 27);
doc.text(`Date: _______________`, margin, y + 33);
doc.text(`Place: _______________`, margin, y + 39);
doc.text('________________________', pageWidth - margin - 50, y + 15);
doc.text('Authorized Bank Official', pageWidth - margin - 50, y + 21);
doc.text('Name: _______________', pageWidth - margin - 50, y + 27);
doc.text('Designation: _______________', pageWidth - margin - 50, y + 33);
const footerY = doc.internal.pageSize.getHeight() - 10;
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text(
'This is a system-generated document. Generated from CBS Nexora — Loan Origination Module.',
pageWidth / 2,
footerY,
{
align: 'center'
}
);
return doc;
}
private cicConsentFileName(a: LoanApplication): string {
return `CIC_Consent_${a.applicationNo}.pdf`;
}
/** Branch channel -- the officer has the customer in front of them, so the declaration opens
* straight into the print dialog rather than needing an extra "download, then print" hop. */
printCicConsentLetter(a: LoanApplication): void {
const doc = this.buildCicConsentPdf(a);
doc.autoPrint();
window.open(doc.output('bloburl'), '_blank');
}
downloadCicConsentLetter(a: LoanApplication): void {
this.buildCicConsentPdf(a).save(this.cicConsentFileName(a));
}
/** BC / Self-Apply channel -- there's no customer standing at a branch printer, so this hands
* the declaration to the OS share sheet (Email, WhatsApp, Drive, etc.) when the browser
* supports sharing files; falls back to a plain download if it doesn't. */
async shareCicConsentLetter(a: LoanApplication): Promise<void> {
const blob = this.buildCicConsentPdf(a).output('blob');
const file = new File([blob], this.cicConsentFileName(a), { type: 'application/pdf' });
const nav = navigator as Navigator & { canShare?: (data: ShareData) => boolean };
if (nav.canShare?.({ files: [file] })) {
try {
await navigator.share({
title: 'Credit Bureau Check — Consent Declaration',
text: `Consent declaration for ${a.applicantName} (${a.applicationNo})`,
files: [file]
});
return;
} catch {
// User cancelled the share sheet, or the OS declined -- fall through to a plain download
// so the officer still has the file to send manually.
}
}
this.downloadCicConsentLetter(a);
}
/** Email/WhatsApp can't attach a file via a plain link -- these open a pre-filled compose
* window and rely on the officer attaching the already-downloaded PDF. */
emailCicConsentLetter(a: LoanApplication): void {
const subject = `Credit Bureau Check Consent — ${a.applicationNo}`;
const body = `Dear ${a.applicantName},\n\nPlease find attached the consent declaration for the credit bureau check on your loan application ${a.applicationNo}. Kindly sign and return it.\n\nRegards,\n${this.currentUserName}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
whatsappCicConsentLetter(a: LoanApplication): void {
const msg = `Dear ${a.applicantName}, please find the consent declaration for the credit bureau check on your loan application ${a.applicationNo}. Kindly sign and share it back with us.`;
window.open(`https://wa.me/?text=${encodeURIComponent(msg)}`, '_blank', 'noopener');
}
sanctioningAuthorityOf(a: LoanApplication): string {
return sanctioningAuthority(a.requestedAmount);
}
// -------------------- 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
* ahead of `currentStage` -- except the current stage itself. So going back for a correction
* and then wanting to jump forward again to where you actually were still works; only a stage
* that's never been reached (genuinely still ahead) is blocked, since skipping into it would
* bypass whatever it still gates. */
canJumpToStage(a: LoanApplication, stage: OriginationStage): boolean {
return a.overallStatus === 'IN_PROGRESS' && stage !== a.currentStage && stageIndex(stage) <= a.furthestStageIndex;
}
/** Jumps the application to any stage it has already reached, for review/correction. Only
* `currentStage` moves -- every checkbox, upload, score and approval already recorded stays
* exactly as it was, and `furthestStageIndex` (how far the application actually got) is left
* untouched either way. */
goToStage(a: LoanApplication, stage: OriginationStage): void {
if (!this.canJumpToStage(a, stage)) {
return;
}
moveToStage(a, stage, this.currentUserName);
this.persist();
}
// -------------------- KYC & Documents --------------------
/** Live preview of what Eligible Loan Amount would be for whatever's currently typed into the
* Monthly Income box — before the officer actually commits it via "Assess Income". */
eligiblePreview(a: LoanApplication): number {
return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode);
}
/** Live EMI preview off whatever's currently typed into Requested Amount / Tenure -- falls
* back to the application's own committed values for whichever input is blank, so editing
* just one field still previews correctly against the other two. */
emiPreview(a: LoanApplication): number {
const amount = this.requestedAmountInput || a.requestedAmount;
const tenure = this.tenureMonthsInput || a.tenureMonths;
if (amount <= 0 || tenure <= 0) {
return 0;
}
return calculateEmi(amount, a.interestRate, tenure);
}
/** What share of monthly income the previewed EMI would eat -- the number the 40%
* affordability rule below is actually checked against. */
emiToIncomePctPreview(a: LoanApplication): number {
const income = this.monthlyIncomeInput || 0;
if (income <= 0) {
return 0;
}
return (this.emiPreview(a) / income) * 100;
}
/** Same ratio, but off the application's actually-committed figures -- this is what
* `allKycDone` gates on, since a figure the officer merely typed but never saved shouldn't be
* able to unlock (or block) progress. */
emiToIncomePct(a: LoanApplication): number {
if (a.monthlyIncome <= 0) {
return 0;
}
return (this.emiAmountFor(a) / a.monthlyIncome) * 100;
}
/** RBI-style affordability guardrail: an EMI eating more than 40% of monthly income needs a
* co-borrower's income behind it (or the amount/tenure adjusted until it doesn't) before the
* application can move past KYC. The eligible-amount-shortfall rule in setMonthlyIncome() can
* also set `coBorrowerRequired` -- either way, this only clears once a real co-borrower (name +
* relationship + salary slips + monthly income) is on file AND the combined 40% household
* capacity actually covers the EMI -- adding a co-borrower on paper without enough income
* behind them doesn't just wave the requirement through. */
affordabilityOk(a: LoanApplication): boolean {
if (!a.coBorrowerRequired) {
return true;
}
if (!this.coBorrowerDetailsComplete(a)) {
return false;
}
return this.combinedEligibilityOk(a);
}
/** The product's own min/max amount and tenure -- a 40,000-month tenure or a ₹1 crore Housing
* Loan against a 10-40 lakh product both "compute" a technically-correct EMI, they just aren't
* a real sanction anyone would write up, so out-of-range values are rejected rather than
* silently accepted. */
productLimitsFor(a: LoanApplication) {
return ORIGINATION_PRODUCTS.find((p) => p.code === a.productCode);
}
amountOutOfRange(a: LoanApplication): boolean {
const product = this.productLimitsFor(a);
if (!product || !this.requestedAmountInput) {
return false;
}
return this.requestedAmountInput < product.minAmt || this.requestedAmountInput > product.maxAmt;
}
tenureOutOfRange(a: LoanApplication): boolean {
const product = this.productLimitsFor(a);
if (!product || !this.tenureMonthsInput) {
return false;
}
return this.tenureMonthsInput < product.minTenure || this.tenureMonthsInput > product.maxTenure;
}
/** Re-assessable at any time, not just once before the first click -- lets the officer revisit
* income, requested amount or tenure as many times as the conversation with the customer
* actually needs. Amount/tenure are only actually applied when they fall inside the product's
* own range -- an out-of-range value is left showing (with its error) but doesn't touch the
* application, same as leaving the field blank does. */
setIncomeNow(a: LoanApplication): void {
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
return;
}
if (this.requestedAmountInput && this.requestedAmountInput > 0 && !this.amountOutOfRange(a)) {
a.requestedAmount = this.requestedAmountInput;
}
if (this.tenureMonthsInput && this.tenureMonthsInput > 0 && !this.tenureOutOfRange(a)) {
a.tenureMonths = this.tenureMonthsInput;
}
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
if (!this.isRevolving(a) && this.emiToIncomePct(a) > 40) {
a.coBorrowerRequired = true;
if (a.coBorrowerName === '—') {
a.coBorrowerName = 'To be added';
}
}
this.persist();
}
allKycDone(a: LoanApplication): boolean {
return (
a.aadhaarVerified &&
a.panVerified &&
a.incomeProofSubmitted &&
a.bankStatementSubmitted &&
a.electricityBillSubmitted &&
a.monthlyIncome > 0 &&
a.productDocuments.every((doc) => doc.submitted) &&
this.affordabilityOk(a)
);
}
// -------------------- Field Investigation --------------------
readonly fiCheckLabels = FI_CHECK_LABELS;
fiComplete(a: LoanApplication): boolean {
const addressOk = a.addressVerification.status === 'VERIFIED';
const officeOk = a.officeVerification.status === 'VERIFIED';
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
const shopOk = !a.hasShop || a.shopVerification.status === 'VERIFIED';
return addressOk && officeOk && propertyOk && shopOk;
}
/** The FI checks to render -- Property only for a secured product, Shop only when the officer
* has flagged that this applicant runs one, same gates fiComplete() already uses. */
fiRows(a: LoanApplication): FiCheckKey[] {
const keys: FiCheckKey[] = [
'addressVerification',
'officeVerification'
];
if (a.secured) {
keys.push('propertyVerification');
}
if (a.hasShop) {
keys.push('shopVerification');
}
return keys;
}
assignFieldOfficerNow(a: LoanApplication, key: FiCheckKey): void {
const name = this.fiOfficerNameInput[key].trim();
if (!name) {
return;
}
assignFieldOfficer(a, key, name);
this.fiOfficerNameInput[key] = '';
const removed = this.fiPhotoCapture[key].splice(0, this.fiPhotoCapture[key].length);
removed.forEach((doc) => {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
});
this.persist();
}
async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
for (const file of files) {
this.fiPhotoCapture[key].push({
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 {
const [removed] = this.fiPhotoCapture[key].splice(index, 1);
if (removed?.previewUrl) {
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 --
* GPS coordinates and at least one site photo -- a bare status flip proves nothing, same
* reasoning as every other "self-ticked checkbox" this pipeline replaced this session. */
fiCanVerify(a: LoanApplication, key: FiCheckKey): boolean {
const v = a[key];
return !!v.latitude.trim() && !!v.longitude.trim() && this.fiPhotoCapture[key].length > 0;
}
verifyFieldVisit(a: LoanApplication, key: FiCheckKey): void {
if (!this.fiCanVerify(a, key)) {
return;
}
// 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 {
const remark = this.fiRejectRemarkInput[key].trim();
if (!remark) {
return;
}
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
* against, driven off `nowTick` so it stays live without a page refresh. */
fiElapsedHours(a: LoanApplication, key: FiCheckKey): number {
const assignedAt = a[key].assignedAt;
if (!assignedAt) {
return 0;
}
return (this.nowTick - assignedAt) / 3600000;
}
fiIsOverdue(a: LoanApplication, key: FiCheckKey): boolean {
return a[key].status === 'PENDING' && !!a[key].assignedAt && this.fiElapsedHours(a, key) > 48;
}
fiSlaLabel(a: LoanApplication, key: FiCheckKey): string {
if (a[key].status !== 'PENDING' || !a[key].assignedAt) {
return '';
}
const remaining = 48 - this.fiElapsedHours(a, key);
return remaining > 0
? `${remaining.toFixed(1)}h left of the 48h SLA`
: `${Math.abs(remaining).toFixed(1)}h past the 48h SLA`;
}
completeFi(a: LoanApplication): void {
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 {
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);
}
});
});
});
}
// -------------------- Property Valuation (Field Investigation, secured products) --------------------
readonly propertyTypes = PROPERTY_TYPES;
/** propertyValuation is a derived field, not directly editable -- re-synced every time the area
* or either rate changes, so every existing read of a.propertyValuation elsewhere in the app
* (stage-history remarks, Risk Approval) stays correct without needing to change those sites. */
updatePropertyValuation(a: LoanApplication): void {
a.propertyValuation = (a.builtUpAreaSqFt || 0) * (a.marketRatePerSqFt || 0);
}
guidelineValue(a: LoanApplication): number {
return (a.builtUpAreaSqFt || 0) * (a.guidelineRatePerSqFt || 0);
}
/** Banks don't lend against the full market value -- a distress/forced sale realises less than
* what the property would fetch with a patient seller and enough time on the market. 90%/75%
* are the general rule-of-thumb haircuts Indian lenders apply, not a product-specific RBI slab
* (those vary too much by product to hard-code one number here). */
realizableValue(a: LoanApplication): number {
return a.propertyValuation * 0.9;
}
distressValue(a: LoanApplication): number {
return a.propertyValuation * 0.75;
}
/** Loan-to-Value against the realizable (not market) value -- the conservative number a real
* credit committee actually underwrites against. */
ltvPercent(a: LoanApplication): number {
const realizable = this.realizableValue(a);
if (realizable <= 0) {
return 0;
}
return (a.requestedAmount / realizable) * 100;
}
ltvHigh(a: LoanApplication): boolean {
return this.ltvPercent(a) > 80;
}
// -------------------- Personal Discussion --------------------
readonly meetingManagers = MANAGERS;
/** Bound directly to a.meetingDate/meetingTime/meetingMode/meetingWith (plain text, same
* DD-MM-YYYY convention every other date on this screen uses) -- this just makes the
* appointment official (flips meetingScheduled, logs it to stage history) once all 4 are
* filled in; editable again afterwards the same way for a reschedule. */
scheduleMeetingNow(a: LoanApplication): void {
if (!a.meetingDate.trim() || !a.meetingTime.trim() || !a.meetingMode || !a.meetingWith.trim()) {
return;
}
scheduleMeeting(
a,
a.meetingDate.trim(),
a.meetingTime.trim(),
a.meetingMode,
a.meetingWith.trim(),
this.currentUserName
);
this.persist();
}
// -------------------- Application Summary Report (briefing doc for the meeting) --------------------
/** Everything captured on this application so far -- KYC, CIC, Field Investigation (every
* address, GPS, photos, remarks), property valuation, agent report and the full stage-history
* audit trail -- rolled into one PDF so whoever takes the Personal Discussion meeting has a
* complete briefing in hand instead of clicking through every stage of the screen themselves. */
private buildApplicationSummaryPdf(a: LoanApplication): jsPDF {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 14;
let y = 20;
const addLine = (label: string, value: string, yPos: number): number => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(9.5);
doc.text(label, margin, yPos);
doc.setFont('helvetica', 'normal');
doc.text(value || '—', margin + 60, yPos);
return yPos + 6;
};
const sectionTitle = (title: string, yPos: number): number => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(11.5);
doc.setTextColor(16, 116, 185);
doc.text(title, margin, yPos);
doc.setTextColor(0, 0, 0);
return yPos + 7;
};
const ensureSpace = (needed: number): void => {
if (y + needed > doc.internal.pageSize.getHeight() - 15) {
doc.addPage();
y = 20;
}
};
const finalYOf = (): number => (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY;
doc.setFillColor(15, 23, 42);
doc.rect(0, 0, pageWidth, 26, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(15);
doc.text('Loan Application Summary Report', pageWidth / 2, 12, { align: 'center' });
doc.setFontSize(8.5);
doc.setFont('helvetica', 'normal');
doc.text('Prepared for the Personal Discussion meeting', pageWidth / 2, 19, { align: 'center' });
doc.setTextColor(0, 0, 0);
y = 34;
doc.setFontSize(9);
doc.text(`Generated: ${todayDDMMYYYY()}`, margin, y);
doc.text(`Application No: ${a.applicationNo}`, pageWidth - margin, y, { align: 'right' });
y += 8;
y = sectionTitle('Application Details', y);
y = addLine('Applicant Name:', a.applicantName, y);
y = addLine('Branch:', `${a.branchName} (${a.branchCode})`, y);
y = addLine('Channel:', a.channel, y);
y = addLine('Customer Type:', a.customerType, y);
y = addLine('Product:', `${a.productName} (${a.productType === 'OVERDRAFT' ? 'Overdraft' : 'Term Loan'})`, y);
y = addLine('Requested Amount:', `Rs. ${a.requestedAmount.toLocaleString('en-IN')}`, y);
y = addLine('Tenure:', `${a.tenureMonths} months`, y);
y = addLine('Interest Rate:', `${a.interestRate}% p.a.`, y);
y += 2;
y = sectionTitle('Income & Eligibility', y);
y = addLine('Monthly Income:', `Rs. ${a.monthlyIncome.toLocaleString('en-IN')}`, y);
y = addLine('Eligible Loan Amount:', `Rs. ${a.eligibleLoanAmount.toLocaleString('en-IN')}`, y);
y = addLine(
'Estimated EMI:',
this.isRevolving(a) ? 'No fixed EMI (revolving)' : `Rs. ${this.emiAmountFor(a).toLocaleString('en-IN')}/month`,
y
);
if (a.coBorrowerRequired) {
y = addLine('Co-Borrower:', a.coBorrowerName, y);
}
y += 2;
ensureSpace(45);
y = sectionTitle('KYC & Documents', y);
const kycRows: string[][] = [
[
'Aadhaar Verified',
a.aadhaarVerified ? 'Yes' : 'No'
],
[
'PAN Verified',
a.panVerified ? 'Yes' : 'No'
],
[
'Income Proof',
a.incomeProofSubmitted ? 'Submitted' : 'Missing'
],
[
'Bank Statement',
a.bankStatementSubmitted ? 'Submitted' : 'Missing'
],
[
'Electricity Bill / Address Proof',
a.electricityBillSubmitted ? 'Submitted' : 'Missing'
],
...a.productDocuments.map((d) => [
d.label,
d.submitted ? 'Submitted' : 'Missing'
])
];
autoTable(doc, {
startY: y,
head: [[
'Document',
'Status'
]],
body: kycRows,
margin: { left: margin, right: margin },
styles: { fontSize: 8.5 },
headStyles: { fillColor: [
15,
23,
42
] }
});
y = finalYOf() + 8;
ensureSpace(30);
y = sectionTitle('CIC / Credit Bureau Check', y);
y = addLine('Bureau:', a.cicBureau, y);
y = addLine('Credit Score:', a.cibilScore > 0 ? `${a.cibilScore} (${cibilBand(a.cibilScore)})` : 'Not checked', y);
y = addLine('Report:', a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded', y);
y += 2;
ensureSpace(40);
y = sectionTitle('Field Investigation', y);
const fiRowsData = this.fiRows(a).map((key) => {
const v = a[key];
return [
this.fiCheckLabels[key],
v.status,
v.officerAssigned,
v.latitude && v.longitude ? `${v.latitude}, ${v.longitude}` : '—',
String(v.photoCount),
v.remarks
];
});
autoTable(doc, {
startY: y,
head: [
[
'Address',
'Status',
'Officer',
'GPS',
'Photos',
'Remarks'
]
],
body: fiRowsData,
margin: { left: margin, right: margin },
styles: { fontSize: 7.5 },
headStyles: { fillColor: [
15,
23,
42
] }
});
y = finalYOf() + 8;
if (a.secured) {
ensureSpace(50);
y = sectionTitle('Property Valuation', y);
y = addLine('Property Type:', a.propertyType, y);
y = addLine('Built-Up Area:', `${a.builtUpAreaSqFt} sq ft`, y);
y = addLine('Market Rate:', `Rs. ${a.marketRatePerSqFt.toLocaleString('en-IN')}/sq ft`, y);
y = addLine('Market Value:', `Rs. ${a.propertyValuation.toLocaleString('en-IN')}`, y);
y = addLine('Guideline Value:', `Rs. ${this.guidelineValue(a).toLocaleString('en-IN')}`, y);
y = addLine('Realizable Value (90%):', `Rs. ${this.realizableValue(a).toLocaleString('en-IN')}`, y);
y = addLine('Loan-to-Value:', `${this.ltvPercent(a).toFixed(1)}%`, y);
y = addLine('Valuer:', a.valuerName, y);
y += 2;
}
ensureSpace(25);
y = sectionTitle('Agent Report', y);
y = addLine('Report File:', a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded', y);
y = addLine('Remark:', a.agentReportRemark, y);
y += 2;
ensureSpace(30);
y = sectionTitle('Personal Discussion Meeting', y);
y = addLine('Scheduled With:', a.meetingWith, y);
y = addLine('Date & Time:', a.meetingScheduled ? `${a.meetingDate} ${a.meetingTime}` : 'Not scheduled', y);
y = addLine('Mode:', a.meetingMode || '—', y);
y += 2;
ensureSpace(30);
y = sectionTitle('Application Stage History', y);
const historyRows = a.stageHistory.map((h) => [
this.stageLabels[h.stage],
h.date,
h.actionBy,
h.outcome,
h.remarks
]);
autoTable(doc, {
startY: y,
head: [
[
'Stage',
'Date',
'By',
'Outcome',
'Remarks'
]
],
body: historyRows,
margin: { left: margin, right: margin },
styles: { fontSize: 7 },
headStyles: { fillColor: [
15,
23,
42
] }
});
const footerY = doc.internal.pageSize.getHeight() - 8;
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text(
'This is a system-generated document. Generated from CBS Nexora — Loan Origination Module.',
pageWidth / 2,
footerY,
{ align: 'center' }
);
return doc;
}
private applicationSummaryFileName(a: LoanApplication): string {
return `Application_Summary_${a.applicationNo}.pdf`;
}
printApplicationSummary(a: LoanApplication): void {
const doc = this.buildApplicationSummaryPdf(a);
doc.autoPrint();
window.open(doc.output('bloburl'), '_blank');
}
downloadApplicationSummary(a: LoanApplication): void {
this.buildApplicationSummaryPdf(a).save(this.applicationSummaryFileName(a));
}
clearPersonalDiscussion(a: LoanApplication): void {
this.runStageAction(
this.originationService.clearPersonalDiscussion(
a.applicationNo,
this.currentUserName,
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
)
);
}
flagPersonalDiscussion(a: LoanApplication): void {
const notes =
this.actionRemarks.trim() ||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
const type = this.discrepancySelection;
this.discrepancySelection = 'NONE';
this.runStageAction(this.originationService.flagDiscrepancy(a.applicationNo, this.currentUserName, type, notes));
}
resolveDiscrepancy(a: LoanApplication): void {
this.runStageAction(
this.originationService.resolveDiscrepancy(
a.applicationNo,
this.currentUserName,
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
)
);
}
// -------------------- Share Membership --------------------
allotMembership(a: LoanApplication): void {
allotShareMembership(a, this.currentUserName);
this.persist();
}
// -------------------- 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;
}
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 {
this.runStageAction(
this.originationService.decideApproval(a.applicationNo, field, {
decision: 'APPROVED',
approverName: this.currentUserName,
approverRole,
remarks: this.actionRemarks.trim() || 'Reviewed and approved.'
})
);
}
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
this.runStageAction(
this.originationService.decideApproval(a.applicationNo, field, {
decision: 'REJECTED',
approverName: this.currentUserName,
approverRole,
remarks: this.actionRemarks.trim() || 'Declined on review.'
})
);
}
exceptionApprove(a: LoanApplication): void {
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.'
})
);
}
legalChecklistDone(a: LoanApplication): boolean {
return a.feesAndAccountOpened && (!a.secured || a.mortgageCreated) && a.legalStampDone && a.fileFilingDone;
}
committeeReportsReady(a: LoanApplication): boolean {
return (
a.cicReportUploaded &&
a.backgroundCheckDone &&
(!a.secured || a.cersaiSearchDone) &&
a.aadhaarVerified &&
a.panVerified &&
a.incomeProofSubmitted &&
this.fiComplete(a) &&
a.managerMeetingDone
);
}
riskChecklistReady(a: LoanApplication): boolean {
return (
a.addressVerification.status === 'VERIFIED' &&
a.incomeProofSubmitted &&
a.previousLoanDeclarationDone &&
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
this.fiComplete(a) &&
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 = [];
this.persist();
}
/** 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;
// Saved here rather than in selectGuarantor(), so the stored record carries the verified
// share-membership result and not just the picked name.
this.persist();
});
}
postCheckSignaturesDone(a: LoanApplication): boolean {
return (
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
);
}
// -------------------- CIC Upload --------------------
async onCicFileSelected(event: Event, a: LoanApplication): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
let parsed: CicReportAnalysis | null = null;
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
parsed = await parseCicPdfReport(file, a.cibilScore);
}
if (parsed) {
const knownBureau = (CIC_KNOWN_BUREAUS as readonly string[]).includes(parsed.bureau)
? parsed.bureau
: a.cicBureau;
a.cicBureau = knownBureau as LoanApplication['cicBureau'];
a.cibilScore = parsed.score;
a.cicParsedAnalysis = parsed;
a.cicAnalysisSource = 'PARSED';
} else {
a.cicParsedAnalysis = generateCicReportAnalysis(a);
a.cicAnalysisSource = 'SIMULATED';
}
uploadCicReport(a, file.name, this.currentUserName);
input.value = '';
this.uploadCaptured(file, 'CIC:report', `${a.cicBureau} Credit Report`);
this.persist();
this.openCicReportAnalysis(a);
}
/** Reopens whatever analysis is already on the application -- the real parsed one if the last
* upload actually yielded one, otherwise the simulated fallback -- generating and caching the
* simulated version on first view rather than a fresh (and pointlessly different) one every
* time this is clicked. */
openCicReportAnalysis(a: LoanApplication): void {
if (!a.cicParsedAnalysis) {
a.cicParsedAnalysis = generateCicReportAnalysis(a);
a.cicAnalysisSource = 'SIMULATED';
}
this.dialog.open(CicReportAnalysisDialogComponent, {
data: {
fileName: a.cicReportFileName,
analysis: a.cicParsedAnalysis as CicReportAnalysis,
wasParsedFromFile: a.cicAnalysisSource === 'PARSED'
},
width: '960px',
maxWidth: '95vw'
});
}
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 --
* transient, cleared once the upload lands on the application (see onAgentReportFileSelected). */
agentReportRemarkInput = '';
onAgentReportFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
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 {
this.runStageAction(this.originationService.markSigned(a.applicationNo, a.applicantName));
}
confirmChequeAndProceed(a: LoanApplication): void {
this.runStageAction(this.originationService.confirmChequeAch(a.applicationNo, this.currentUserName));
}
/** 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) --------------------
isRevolving(a: LoanApplication): boolean {
return a.productType === 'OVERDRAFT';
}
emiAmountFor(a: LoanApplication): number {
return calculateEmi(a.requestedAmount, a.interestRate, a.tenureMonths);
}
emiScheduleFor(a: LoanApplication): EmiScheduleRow[] {
return buildEmiSchedule(a.requestedAmount, a.interestRate, a.tenureMonths, a.disbursementDate);
}
totalInterestFor(a: LoanApplication): number {
return this.emiScheduleFor(a).reduce((s, r) => s + r.interestComponent, 0);
}
totalPayableFor(a: LoanApplication): number {
return a.requestedAmount + this.totalInterestFor(a);
}
}