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>
2784 lines
107 KiB
TypeScript
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);
|
|
}
|
|
}
|