feat(loan-origination): RBI compliance checklist, real client integration, and CIC consent/charge flows
- Add standalone RBI Eligibility Checklist and RBI Rules Reference pages (11 real RBI-circular based rules: age-at-maturity, area of operation, PSL, director/related-party, wilful defaulter, exposure norms, LTV/margin, fair practices code, prepayment charges, own-shares security, insurance bundling), wired into the Loan Origination menu. - Bridge the mock Loan Origination Pipeline to real Fineract data wherever an application is raised against a real Existing Customer: client search in the pipeline intake form, real Share Member accounts (with a details popup), and real KYC (Aadhaar/PAN verified status + masked numbers + document image view, generic document lookup for Income Proof/Bank Statement/ Electricity Bill) on the Application Detail page. - Add local-only capture UI for New Customer / no-account applications: front/back document images with crop + simulated Aadhaar OTP / PAN verification, employment-type-aware income proof, and an expiry-validated electricity bill upload. - Add an EMI Calculation Basis breakdown (principal, rate, tenure, total interest/payable, and the substituted reducing-balance formula) to the KYC stage's income assessment. - Add a CIC/Credit Bureau Check consent flow: a generated declaration letter (print for Branch, share for BC/Self-Apply), gated on the signed copy being uploaded before the credit score can be checked, plus a CIC check charge collected either by debiting a real linked account or via a mock payment-link flow for customers with none. - Let the stage stepper jump directly to any already-reached stage (forward or backward) via a new furthestStageIndex, without losing any data already recorded on the application.
This commit is contained in:
@@ -10,8 +10,17 @@ 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 { Subscription } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Subscription, forkJoin } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ClientsService } from '../../clients/clients.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 {
|
||||
ApprovalField,
|
||||
BRANCHES,
|
||||
@@ -32,6 +41,7 @@ import {
|
||||
checkCreditScore,
|
||||
cibilBand,
|
||||
CIC_BUREAUS,
|
||||
CIC_CHECK_CHARGE,
|
||||
clearPersonalDiscussionAndProceed,
|
||||
confirmChequeAch,
|
||||
decideApproval,
|
||||
@@ -39,6 +49,7 @@ import {
|
||||
flagDiscrepancy,
|
||||
isRevolvingProduct,
|
||||
markCustomerSigned,
|
||||
moveToStage,
|
||||
progressPct,
|
||||
pushFieldInquiry,
|
||||
resolveDiscrepancyAndProceed,
|
||||
@@ -53,6 +64,30 @@ import {
|
||||
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';
|
||||
|
||||
interface NewKycCaptureState {
|
||||
aadhaar: NewKycDocSide & { otpSent: boolean; otpDigits: string[] };
|
||||
pan: NewKycDocSide;
|
||||
incomeProof: NewKycSingleDoc & { employmentType: EmploymentType };
|
||||
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 /
|
||||
@@ -91,6 +126,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
|
||||
monthlyIncomeInput: 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
|
||||
@@ -101,8 +140,85 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
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 kycUploadHelper = inject(KycUploadHelperService);
|
||||
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;
|
||||
|
||||
/** 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 };
|
||||
|
||||
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: { file: null, previewUrl: null, employmentType: 'SALARIED' },
|
||||
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.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';
|
||||
@@ -122,11 +238,372 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
this.actionRemarks = '';
|
||||
this.discrepancySelection = 'NONE';
|
||||
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
|
||||
this.revokeNewKycPreviews(this.newKycCapture);
|
||||
this.newKycCapture = this.emptyNewKycCapture();
|
||||
this.cicConsentCapture = { file: null, previewUrl: null };
|
||||
this.cicPaymentRefInput = '';
|
||||
this.loadRealClientAccounts();
|
||||
this.loadRealKyc();
|
||||
});
|
||||
}
|
||||
|
||||
/** 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)
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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 Form 16 / salary slips; business/self-employed
|
||||
* customers prove it with their Income Tax Return -- so the required document changes with
|
||||
* the employment type the officer selects, rather than asking for a generic "income proof". */
|
||||
get incomeProofDocLabel(): string {
|
||||
return this.newKycCapture.incomeProof.employmentType === 'BUSINESS'
|
||||
? 'ITR (Income Tax Return)'
|
||||
: 'Form 16 / Salary Slip';
|
||||
}
|
||||
|
||||
onIncomeProofEmploymentTypeChange(a: LoanApplication): void {
|
||||
// Switching employment type invalidates whatever was already picked under the other type's
|
||||
// label -- clear it so the officer re-uploads the document that actually matches.
|
||||
if (this.newKycCapture.incomeProof.previewUrl) {
|
||||
URL.revokeObjectURL(this.newKycCapture.incomeProof.previewUrl);
|
||||
}
|
||||
this.newKycCapture.incomeProof.file = null;
|
||||
this.newKycCapture.incomeProof.previewUrl = null;
|
||||
a.incomeProofSubmitted = false;
|
||||
}
|
||||
|
||||
async onNewIncomeProofFile(a: LoanApplication, event: Event): Promise<void> {
|
||||
const file = await this.pickSingleNewKycFile(event, this.incomeProofDocLabel);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.setSingleDoc(this.newKycCapture.incomeProof, file);
|
||||
a.incomeProofSubmitted = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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.syncElectricityBillSubmitted(a);
|
||||
}
|
||||
|
||||
private syncElectricityBillSubmitted(a: LoanApplication): void {
|
||||
a.electricityBillSubmitted = !!this.newKycCapture.electricityBill.file && !this.electricityBillTooOld;
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sub?.unsubscribe();
|
||||
this.revokeNewKycPreviews(this.newKycCapture);
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -134,6 +611,14 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// -------------------- 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);
|
||||
@@ -143,7 +628,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
|
||||
return 'loan-step loan-step--hold';
|
||||
}
|
||||
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||
if (this.isStageDone(a, stage) || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||
return 'loan-step loan-step--done';
|
||||
}
|
||||
if (idx === curIdx) {
|
||||
@@ -154,8 +639,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
stepMarker(a: LoanApplication, stage: OriginationStage): string {
|
||||
const idx = stageIndex(stage);
|
||||
const curIdx = stageIndex(a.currentStage);
|
||||
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||
if (this.isStageDone(a, stage) || (idx === stageIndex(a.currentStage) && a.overallStatus === 'DISBURSED')) {
|
||||
return '✓';
|
||||
}
|
||||
return String(idx + 1);
|
||||
@@ -186,9 +670,270 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// -------------------- 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}.`);
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
|
||||
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.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);
|
||||
}
|
||||
@@ -198,6 +943,26 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
proceedToNextStage(a, this.currentUserName, remarks);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
// -------------------- 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". */
|
||||
|
||||
Reference in New Issue
Block a user