feat(loan-origination): RBI compliance checklist, real client integration, and CIC consent/charge flows
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

- 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:
2026-08-13 10:15:28 +05:30
parent 5a6fb1f9b5
commit 75df8865dd
22 changed files with 3688 additions and 48 deletions
@@ -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". */