Files
cbsnexora/src/app/loan-origination/application-detail/application-detail.component.ts
T
nitesh 5cd9998cfc feat(loan-origination): add full Loan Origination Sanctioning workflow module
New standalone module modelling the pre-disbursement pipeline: Channel intake
(Branch/BC/Self-Apply) -> KYC/OVD & e-KYC fingerprint -> CIC/Credit Bureau
check -> Field Investigation -> Personal Discussion & declaration -> Loan
Committee -> Risk Approval -> Legal Approval -> Customer Signing -> Cheque/ACH
-> Post-Disbursement Check -> Disbursement, per RBI KYC Master Direction, the
mandatory CIC check, and co-operative-bank share-membership rules.

Includes a 12-stage stepper with real approve/reject actions and a date-wise
audit trail, CIC upload + credit-score check, Field Inquiry push + Agent
Report upload, Committee "Reports Required" / Risk "Verification Checklist",
Head-Office-vs-branch scoping, a Pipeline screen (filters, New Application
intake, Channel-wise/BC-wise/Share-Member reports, Disbursement Target vs
Actual, Export to Excel), and a full EMI repayment schedule once disbursed.
2026-08-02 14:22:32 +05:30

419 lines
13 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 { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
ApprovalField,
BRANCHES,
BranchMaster,
DiscrepancyType,
DISCREPANCY_LABELS,
EmiScheduleRow,
LoanApplication,
LOAN_APPLICATIONS,
OriginationStage,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
allotShareMembership,
buildEmiSchedule,
calcEligibleAmount,
calculateEmi,
checkCreditScore,
cibilBand,
CIC_BUREAUS,
clearPersonalDiscussionAndProceed,
confirmChequeAch,
decideApproval,
disburseApplication,
flagDiscrepancy,
isRevolvingProduct,
markCustomerSigned,
progressPct,
pushFieldInquiry,
resolveDiscrepancyAndProceed,
sanctioningAuthority,
setMonthlyIncome,
stageBadgeClass,
stageIndex,
proceedToNextStage,
recordVerification,
todayDDMMYYYY,
uploadAgentReport,
uploadCicReport
} from '../loan-origination-data';
/**
* 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';
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
monthlyIncomeInput: number | null = null;
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 sub?: Subscription;
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.sub = this.route.paramMap.subscribe((params) => {
const applicationNo = params.get('applicationNo');
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
// A branch-level login can only open applications belonging to its own branch — treated
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
// that a differently-branched application exists at all.
this.application = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
goBack(): void {
this.router.navigate(['/loan-origination/pipeline']);
}
// -------------------- Stepper --------------------
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 (idx < curIdx || (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);
const curIdx = stageIndex(a.currentStage);
if (idx < curIdx || (idx === curIdx && 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 {
checkCreditScore(a, this.currentUserName);
}
sanctioningAuthorityOf(a: LoanApplication): string {
return sanctioningAuthority(a.requestedAmount);
}
// -------------------- Generic stage-advance actions --------------------
proceed(a: LoanApplication, remarks: string): void {
proceedToNextStage(a, this.currentUserName, remarks);
}
// -------------------- 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);
}
setIncomeNow(a: LoanApplication): void {
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
return;
}
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
}
allKycDone(a: LoanApplication): boolean {
const base =
a.aadhaarVerified &&
a.panVerified &&
a.aadhaarFingerprintCaptured &&
a.incomeProofSubmitted &&
a.bankStatementSubmitted &&
a.electricityBillSubmitted &&
a.monthlyIncome > 0;
return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base;
}
// -------------------- Field Investigation --------------------
fiComplete(a: LoanApplication): boolean {
const addressOk = a.addressVerification.status === 'VERIFIED';
const officeOk = a.officeVerification.status === 'VERIFIED';
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
return addressOk && officeOk && propertyOk;
}
completeFi(a: LoanApplication): void {
const agent = 'Field Verification Team';
const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [
'addressVerification',
'officeVerification',
'propertyVerification'
];
for (const key of keys) {
if (a[key].status !== 'PENDING') {
recordVerification(
a,
key,
a[key].status,
agent,
a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks
);
}
}
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
}
// -------------------- Personal Discussion --------------------
clearPersonalDiscussion(a: LoanApplication): void {
clearPersonalDiscussionAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
);
this.actionRemarks = '';
}
flagPersonalDiscussion(a: LoanApplication): void {
const notes =
this.actionRemarks.trim() ||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
}
resolveDiscrepancy(a: LoanApplication): void {
resolveDiscrepancyAndProceed(
a,
this.currentUserName,
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
);
this.actionRemarks = '';
}
// -------------------- Share Membership --------------------
allotMembership(a: LoanApplication): void {
allotShareMembership(a, this.currentUserName);
}
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'APPROVED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Reviewed and approved.'
);
this.actionRemarks = '';
}
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
decideApproval(
a,
field,
'REJECTED',
this.currentUserName,
approverRole,
this.actionRemarks.trim() || 'Declined on review.'
);
this.actionRemarks = '';
}
exceptionApprove(a: LoanApplication): void {
decideApproval(
a,
'postCheckApproval',
'EXCEPTION_APPROVED',
this.currentUserName,
'Post-Disbursement Check',
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
);
this.actionRemarks = '';
}
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
);
}
postCheckSignaturesDone(a: LoanApplication): boolean {
return (
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
);
}
// -------------------- CIC Upload --------------------
onCicFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadCicReport(a, file.name, this.currentUserName);
input.value = '';
}
pushFieldInquiryNow(a: LoanApplication): void {
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
}
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.currentUserName);
input.value = '';
}
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
markSigned(a: LoanApplication): void {
markCustomerSigned(a, a.applicantName);
}
confirmChequeAndProceed(a: LoanApplication): void {
confirmChequeAch(a, this.currentUserName);
}
disburseNow(a: LoanApplication): void {
disburseApplication(a, this.currentUserName, this.disbursementMode);
}
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
isRevolving(a: LoanApplication): boolean {
return isRevolvingProduct(a.productCode);
}
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);
}
}