Ready for Disbursement
+
- Disbursing also confirms EMI Activation for this account in the same step.
+ @if (!isDepositLoan(a)) {
+ Disbursing also confirms EMI Activation for this account in the same step.
+ }
@if (a.customerType === 'EXISTING' && a.clientId) {
A real Fineract loan account will be created, approved and disbursed in Core Banking at the same time.
} @else {
@@ -2412,19 +2806,30 @@
@if (a.fineractLoanError) {
{{ a.fineractLoanError }}
}
+ @if (isDepositLoan(a)) {
+ @if (disbursementLienIssue(a); as issue) {
+
{{ issue }}
+ } @else {
+
+ ✓ On disbursement the lien is marked on {{ a.depositSelection?.accountNos?.join(', ') }} for ₹{{
+ a.requestedAmount | number: '1.0-0'
+ }}.
+
+ }
+ }
@@ -2433,62 +2838,328 @@
}
}
+ @if (a.currentStage === 'DEPOSIT_SELECTION') {
+
+
Deposit Selection & Eligibility
+
+ @if (loadingDeposits) {
+
Loading the customer's deposits…
+ } @else if (realDeposits.length === 0) {
+
+ No active {{ a.productCode === 'RD' ? 'Recurring' : 'Fixed' }} Deposit found for this customer.
+
+ } @else {
+
+ @if (depositRules(a).allowThirdParty) {
+
+ }
+ @if (!depositRules(a).allowMultiple) {
+
This product allows one deposit per loan.
+ }
+
+ @if (depositCalc(a); as calc) {
+
+
+ Security value (balance + accrued interest){{ calc.rows.length ? '₹' + (calc.value | number: '1.0-0') : '—' }}
+
+
+ Normal limit — {{ calc.limitPct }}% {{ calc.rows.length ? '₹' + (calc.normalLimit | number: '1.0-0') : '—' }}
+
+
+ {{ calc.rateLabel }}{{ calc.rows.length ? calc.rateText : '—' }}
+
+
+ {{ calc.tenureLabel }}{{ calc.rows.length ? calc.maxTenure + ' months' : '—' }}
+
+
+ Eligible loan amount{{
+ calc.rows.length
+ ? '₹' + (calc.eligible | number: '1.0-0') + ' (' + (depositOver85 ? '100' : calc.limitPct) + '%)'
+ : '—'
+ }}
+
+
+
+
+
+
+
+
+ @if (depositOver85) {
+
+ }
+ @for (err of calc.errors; track err) {
+
{{ err }}
+ }
+ @if (calc.ok) {
+
+ ✓ ₹{{ a.requestedAmount | number: '1.0-0' }} for {{ a.tenureMonths }} months at
+ {{ calc.rate | number: '1.2-2' }}% is within the limit.
+
+ }
+
+
+
+ }
+ }
+
+
+ }
+
+ @if (showLienCard(a)) {
+
+
Lien Marking
+
+
+ A lien blocks withdrawal, premature closure and renewal in another name until the loan is repaid.
+
+
+ Deposits under lien{{ a.depositSelection?.accountNos?.join(', ') || 'Select and save a deposit above' }}
+
+
+ Lien amount₹{{ a.requestedAmount | number: '1.0-0' }}
+
+
+
Letter of lien and set-off
+
+
+
+
+ @if (!canMakeLienLetter(a)) {
+
Select a deposit above to create the letter.
+ }
+
Signed by the depositor (and all joint holders)
+ @if (lienLetterStored) {
+
Received
+ @for (stored of storedDocsFor('LIEN:letter'); track stored.id) {
+
+
+
+
+ }
+ } @else {
+
Pending
+
+ }
+
+ @if (a.lien?.marked && depositRules(a).certificateRequired) {
+
+ LIEN CERTIFICATE
+ No. {{ a.lien?.certificateNo }} · Date {{ a.lien?.markedOn }}
+ A lien of ₹{{ a.lien?.amount | number: '1.0-0' }} is noted on deposit
+ {{ a.lien?.accountNos?.join(', ') }} in favour of the bank against loan application {{ a.applicationNo }}.
+
+
Lien marked
+ } @else if (lienAtDisbursement(a)) {
+
+ This product marks the lien at disbursement. The lien is placed on the deposit automatically when
+ the loan is disbursed, so there is nothing to mark now. Before that: save the deposit selection and upload
+ the signed letter of lien and set-off.
+
+
Lien not marked yet — at disbursement
+ } @else {
+
+
+
+ @if (!lienReady(a)) {
+
+ Save the deposit selection and upload the letter of lien and set-off to enable Mark Lien.
+
+ }
+ }
+
+
+ }
+
+ @if (a.currentStage === 'DEPOSIT_SELECTION') {
+
+
Complete Deposit Selection
+
+
+ Order: choose the deposit and save it, then download the letter of lien and set-off, get it signed, and
+ upload it. Then proceed.
+
+ @if (depositStageIssue(a); as issue) {
+
{{ issue }}
+ } @else {
+
✓ Deposit saved and the signed letter is on file. Ready to proceed.
+ }
+
+
+
+
+
+ }
+
-
Approval Summary
+
Approval Tracking
-
- Loan Committee
- {{ a.creditCommitteeApproval.decision }}
- {{ a.creditCommitteeApproval.approverName }} · {{ a.creditCommitteeApproval.date }}
- {{ a.creditCommitteeApproval.remarks }}
-
-
- Risk Approval
- {{ a.riskApproval.decision }}
- {{ a.riskApproval.approverName }} · {{ a.riskApproval.date }}
- {{ a.riskApproval.remarks }}
-
-
- Legal Approval
- {{ a.legalApproval.decision }}
- {{ a.legalApproval.approverName }} · {{ a.legalApproval.date }}
- {{ a.legalApproval.remarks }}
-
-
- Post-Disbursement Check
- {{ a.postCheckApproval.decision }}
- {{ a.postCheckApproval.approverName }} · {{ a.postCheckApproval.date }}
- {{ a.postCheckApproval.remarks }}
-
+ @for (tile of approvalTiles; track tile.field) {
+ @if (approvalOf(a, tile.field); as ap) {
+
+ {{ tile.label }}
+ @if (approvalRequestAny(a, tile.field); as req) {
+ @if (req.status === 'PENDING') {
+ ⏳ AWAITING APPROVAL
+ Sent by {{ req.submittedBy }} · {{ req.submittedOn }}
+ @if (req.requestedDecision === 'EXCEPTION_APPROVED') {
+ On exception
+ }
+ @if (req.remarks) {
+ “{{ req.remarks }}”
+ }
+ } @else if (req.status === 'WITHDRAWN') {
+ WITHDRAWN
+ Sent by {{ req.submittedBy }} · {{ req.submittedOn }}, then withdrawn
+ } @else {
+ {{ ap.decision }}
+ Sent by {{ req.submittedBy }} · {{ req.submittedOn }}
+ {{ ap.decision === 'REJECTED' ? 'Rejected' : 'Approved' }} by {{ ap.approverName }} ·
+ {{ ap.date }}
+ @if (req.checkerChecklist?.length) {
+ Checker verified {{ checkerSummary(req) }}
+ }
+ {{ ap.remarks }}
+ }
+ } @else {
+ {{ ap.decision }}
+ @if (ap.decision === 'PENDING') {
+ Not sent for approval yet
+ } @else {
+ {{ ap.approverName }} · {{ ap.date }}
+ {{ ap.remarks }}
+ }
+ }
+
+ }
+ }
diff --git a/src/app/loan-origination/application-detail/application-detail.component.ts b/src/app/loan-origination/application-detail/application-detail.component.ts
index 9a75f67e0..7c32aa540 100644
--- a/src/app/loan-origination/application-detail/application-detail.component.ts
+++ b/src/app/loan-origination/application-detail/application-detail.component.ts
@@ -11,7 +11,8 @@ 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 { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
+import { Observable, Subscription, firstValueFrom, forkJoin } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { jsPDF } from 'jspdf';
@@ -41,19 +42,22 @@ import {
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 { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
import {
ApprovalField,
- BRANCHES,
- BranchMaster,
DiscrepancyType,
DISCREPANCY_LABELS,
EmiScheduleRow,
LoanApplication,
ORIGINATION_PRODUCTS,
+ isDepositLoanCode,
+ registerOriginationProduct,
+ DepositSelection,
+ ApprovalRequest,
OriginationStage,
STAGE_LABELS,
STAGE_ORDER,
+ stagesFor,
STATUS_LABELS,
allotShareMembership,
assignFieldOfficer,
@@ -108,15 +112,18 @@ interface ItrYearCapture extends NewKycSingleDoc {
exceptionRemark: string;
}
-/** A salaried applicant proves income with one combined Form 16 / 3-month salary slip upload;
+/** A salaried applicant proves income with Form 16 and the 3-month salary slips (several files);
* 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;
+ salaried: NewKycSingleDoc[];
itr: ItrYearCapture[];
}
+/** Document-store key every salaried income proof file is filed under. */
+const SALARY_PROOF_KEY = 'KYC:incomeProof:salaried';
+
interface NewKycCaptureState {
aadhaar: NewKycDocSide & { otpSent: boolean; otpDigits: string[] };
pan: NewKycDocSide;
@@ -199,6 +206,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
private readonly settingsService = inject(SettingsService);
private readonly kycUploadHelper = inject(KycUploadHelperService);
private readonly originationService = inject(LoanOriginationService);
+ private readonly productCatalog = inject(LoanProductCatalogService);
private readonly dialog = inject(MatDialog);
private sub?: Subscription;
@@ -372,7 +380,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
pan: { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false },
incomeProof: {
employmentType: 'SALARIED',
- salaried: { file: null, previewUrl: null },
+ salaried: [],
itr: [
{ file: null, previewUrl: null, exception: false, exceptionRemark: '' },
{ file: null, previewUrl: null, exception: false, exceptionRemark: '' },
@@ -393,7 +401,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
state.aadhaar.backPreviewUrl,
state.pan.frontPreviewUrl,
state.pan.backPreviewUrl,
- state.incomeProof.salaried.previewUrl,
+ ...state.incomeProof.salaried.map((doc) => doc.previewUrl),
...state.incomeProof.itr.map((doc) => doc.previewUrl),
state.bankStatement.previewUrl,
state.electricityBill.previewUrl,
@@ -408,9 +416,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
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.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
}
this.fiTickTimer = setInterval(() => {
this.nowTick = Date.now();
@@ -504,6 +510,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
shopVerification: ''
};
this.loadRealClientAccounts();
+ this.loadRealDeposits();
this.loadRealKyc();
this.loadRealAddresses();
this.uploadingDocs = 0;
@@ -798,14 +805,61 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.syncIncomeProofSubmitted(a);
}
- async onNewSalariedProofFile(a: LoanApplication, event: Event): Promise
{
- const file = await this.pickSingleNewKycFile(event, 'Form 16 / 3 Month Salary Slip');
- if (!file) {
+ /** Form 16 and each month's salary slip are separate files, so several can be picked at once. A file
+ * is shown locally until the store has it, then only the stored copy is listed. */
+ async onNewSalariedProofFiles(a: LoanApplication, event: Event): Promise {
+ const input = event.target as HTMLInputElement;
+ const files = Array.from(input.files ?? []);
+ input.value = '';
+ for (const picked of files) {
+ const file = await this.kycUploadHelper.pickWithCrop(picked, 'Form 16 / Salary Slip');
+ if (!file) {
+ continue;
+ }
+ const doc: NewKycSingleDoc = { file: null, previewUrl: null };
+ this.setSingleDoc(doc, file);
+ this.newKycCapture.incomeProof.salaried.push(doc);
+ this.uploadCaptured(file, SALARY_PROOF_KEY, 'Form 16 / Salary Slip', () => {
+ this.dropSalariedLocal(doc);
+ this.syncIncomeProofSubmitted(a);
+ });
+ }
+ this.syncIncomeProofSubmitted(a);
+ }
+
+ removeSalariedFile(a: LoanApplication, index: number): void {
+ this.dropSalariedLocal(this.newKycCapture.incomeProof.salaried[index]);
+ this.syncIncomeProofSubmitted(a);
+ }
+
+ removeSalariedStored(a: LoanApplication, stored: any): void {
+ if (!a.id) {
return;
}
- this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
- this.uploadCaptured(file, 'KYC:incomeProof:salaried', 'Form 16 / 3 Month Salary Slip');
- this.syncIncomeProofSubmitted(a);
+ this.originationService.deleteDocument(a.id, stored.id).subscribe({
+ next: () => {
+ this.uploadedDocs = this.uploadedDocs.filter((doc) => doc.id !== stored.id);
+ this.syncIncomeProofSubmitted(a);
+ },
+ error: () => this.toast('Could not remove the stored document.')
+ });
+ }
+
+ /** Files on the salaried income proof: picked in this session plus already stored. */
+ get salariedFileCount(): number {
+ return this.newKycCapture.incomeProof.salaried.length + this.storedDocsFor(SALARY_PROOF_KEY).length;
+ }
+
+ private dropSalariedLocal(doc: NewKycSingleDoc | undefined): void {
+ const list = this.newKycCapture.incomeProof.salaried;
+ const at = doc ? list.indexOf(doc) : -1;
+ if (at < 0) {
+ return;
+ }
+ if (doc!.previewUrl) {
+ URL.revokeObjectURL(doc!.previewUrl);
+ }
+ list.splice(at, 1);
}
async onNewItrFile(a: LoanApplication, yearIndex: number, event: Event): Promise {
@@ -827,10 +881,14 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
private syncIncomeProofSubmitted(a: LoanApplication): void {
const ip = this.newKycCapture.incomeProof;
- a.incomeProofSubmitted =
+ const submitted =
ip.employmentType === 'BUSINESS'
? ip.itr.every((doc) => !!doc.file || (doc.exception && doc.exceptionRemark.trim() !== ''))
- : !!ip.salaried.file;
+ : this.salariedFileCount > 0;
+ if (a.incomeProofSubmitted !== submitted) {
+ a.incomeProofSubmitted = submitted;
+ this.persist();
+ }
}
/** True once every ITR year is covered but at least one of them is a waiver rather than an
@@ -855,6 +913,36 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
}
+ /** Takes the bank statement off the application: the file picked here and every stored copy, so a
+ * wrong statement can be replaced instead of staying on file as "Submitted". */
+ removeBankStatement(a: LoanApplication): void {
+ const doc = this.newKycCapture.bankStatement;
+ if (doc.previewUrl) {
+ URL.revokeObjectURL(doc.previewUrl);
+ }
+ doc.file = null;
+ doc.previewUrl = null;
+ const stored = this.storedDocsFor('KYC:bankStatement');
+ const finish = () => {
+ a.bankStatementSubmitted = false;
+ this.persist();
+ };
+ if (!a.id || stored.length === 0) {
+ finish();
+ return;
+ }
+ forkJoin(stored.map((d) => this.originationService.deleteDocument(a.id!, d.id))).subscribe({
+ next: () => {
+ this.uploadedDocs = this.uploadedDocs.filter((d) => d.name !== 'KYC:bankStatement');
+ finish();
+ },
+ error: () => {
+ this.toast('Could not remove the stored bank statement.');
+ this.refreshDocuments(a);
+ }
+ });
+ }
+
isCsvFile(file: File | null): boolean {
return !!file && (file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv'));
}
@@ -1041,6 +1129,17 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
if (removed?.previewUrl) {
URL.revokeObjectURL(removed.previewUrl);
}
+ this.syncLegalDocSubmitted(doc);
+ }
+
+ /** A legal document counts as submitted only while at least one file (this session or stored) is attached. */
+ private syncLegalDocSubmitted(doc: ProductDocumentEntry, ignoreStoredId?: number): void {
+ const stored = this.storedDocsFor('LEGAL_DOC:' + doc.key).filter((d) => d.id !== ignoreStoredId);
+ const submitted = (this.legalDocCapture[doc.key]?.length ?? 0) + stored.length > 0;
+ if (doc.submitted !== submitted) {
+ doc.submitted = submitted;
+ this.persist();
+ }
}
private readonly legalDocIcons: Record = {
@@ -1261,6 +1360,15 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.originationService.listDocuments(a.id).subscribe({
next: (docs: any[]) => {
this.uploadedDocs = docs ?? [];
+ // A salary proof already in the store counts even though the flag is only set when a file is picked.
+ if (
+ !a.incomeProofSubmitted &&
+ this.newKycCapture.incomeProof.employmentType === 'SALARIED' &&
+ this.salariedFileCount > 0
+ ) {
+ a.incomeProofSubmitted = true;
+ this.persist();
+ }
},
error: () => {
this.uploadedDocs = [];
@@ -1313,7 +1421,13 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return;
}
this.originationService.deleteDocument(a.id, doc.id).subscribe({
- next: () => this.refreshDocuments(a),
+ next: () => {
+ this.refreshDocuments(a);
+ const legal = (a.legalDocuments ?? []).find((d) => 'LEGAL_DOC:' + d.key === doc.name);
+ if (legal) {
+ this.syncLegalDocSubmitted(legal, doc.id);
+ }
+ },
error: () => this.toast('Could not remove the stored document.')
});
}
@@ -1466,12 +1580,26 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
return 'loan-step';
}
+ /** The lien card shows from the deposit-selection stage onwards, for a loan against a deposit. */
+ showLienCard(a: LoanApplication): boolean {
+ return (
+ this.isDepositLoan(a) &&
+ this.depositRules(a).lienRequired &&
+ stageIndex(a.currentStage) >= stageIndex('DEPOSIT_SELECTION')
+ );
+ }
+
+ /** The stages this application's product goes through (see stagesFor). */
+ visibleStages(a: LoanApplication): OriginationStage[] {
+ return stagesFor(a.productCode);
+ }
+
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);
+ return String(this.visibleStages(a).indexOf(stage) + 1);
}
progress(a: LoanApplication): number {
@@ -1783,6 +1911,18 @@ I confirm that:
this.persist();
}
+ /** Deposit Selection → Field Investigation (CIC is skipped for a loan against a deposit). The server re-checks that
+ * a deposit is selected, saved and covers the requested amount. */
+ completeDepositStage(a: LoanApplication): void {
+ this.runStageAction(
+ this.originationService.completeDepositSelection(
+ a.applicationNo,
+ this.currentUserName,
+ 'Deposit selected and eligibility confirmed.'
+ )
+ );
+ }
+
/** 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 {
@@ -1874,8 +2014,846 @@ I confirm that:
* 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. */
+ /** Deposit account id -> the other application that already pledges it (still open). */
+ otherPledges: Record = {};
+
+ private loadOtherPledges(a: LoanApplication): void {
+ this.otherPledges = {};
+ this.originationService.list().subscribe({
+ next: (apps) => {
+ const found: Record = {};
+ apps
+ .filter((x) => x.applicationNo !== a.applicationNo && x.overallStatus !== 'REJECTED')
+ .forEach((x) =>
+ (x.depositSelection?.saved ? x.depositSelection.accountIds : []).forEach(
+ (id) => (found[id] = x.applicationNo)
+ )
+ );
+ this.otherPledges = found;
+ },
+ error: () => {
+ this.otherPledges = {};
+ }
+ });
+ }
+
+ // -------------------- Deposit selection & eligibility (loan against FD / RD) --------------------
+ realDeposits: any[] = [];
+ loadingDeposits = false;
+ selectedDepositIds = new Set();
+ depositOver85 = false;
+ depositOver85Reason = '';
+ depositThirdParty = false;
+
+ private toDate(value: any): Date | null {
+ if (Array.isArray(value) && value.length >= 3) {
+ return new Date(value[0], value[1] - 1, value[2]);
+ }
+ if (typeof value === 'string' && value) {
+ const parsed = new Date(value);
+ return isNaN(parsed.getTime()) ? null : parsed;
+ }
+ return null;
+ }
+
+ monthsUntil(date: Date | null): number {
+ if (!date) {
+ return 0;
+ }
+ const now = new Date();
+ let months = (date.getFullYear() - now.getFullYear()) * 12 + (date.getMonth() - now.getMonth());
+ if (date.getDate() < now.getDate()) {
+ months -= 1;
+ }
+ return months;
+ }
+
+ /**
+ * A loan against a deposit has no mortgage, title search or separate loan agreement at Legal Approval (the deposit is the
+ * security, and the DP Note was collected earlier). An application created before that was decided still carries them, so
+ * they are taken off here; files already uploaded against the other documents stay as they are.
+ */
+ private normalizeDepositLegalDocs(a: LoanApplication): void {
+ const drop = [
+ 'mortgageDeed',
+ 'titleSearchReport',
+ 'loanAgreement',
+ 'feeReceipt',
+ 'fileFilingAck'
+ ];
+ const before = (a.legalDocuments ?? []).length;
+ a.legalDocuments = (a.legalDocuments ?? []).filter((doc) => doc.custom || !drop.includes(doc.key));
+ const stamp = a.legalDocuments.find((doc) => doc.key === 'stampDutyReceipt');
+ if (stamp) {
+ stamp.label = 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)';
+ }
+ if (a.legalDocuments.length !== before) {
+ this.persist();
+ }
+ }
+
+ /** The customer's live deposits of the kind this product lends against, with the products' own rate rules. */
+ private loadRealDeposits(): void {
+ if (this.application && this.isDepositLoan(this.application)) {
+ this.normalizeDepositLegalDocs(this.application);
+ }
+ this.realDeposits = [];
+ this.selectedDepositIds = new Set();
+ const a = this.application;
+ if (!a || !a.clientId || !this.isDepositLoan(a)) {
+ return;
+ }
+ this.loadOtherPledges(a);
+ const saved = a.depositSelection;
+ if (saved) {
+ this.selectedDepositIds = new Set(saved.accountIds);
+ this.depositOver85 = saved.over85;
+ this.depositOver85Reason = saved.over85Reason;
+ this.depositThirdParty = saved.thirdParty;
+ }
+ // the detail screen can be opened directly, so make sure this product's limit % and spread are known
+ this.productCatalog
+ .activeProducts()
+ .subscribe((products) =>
+ products
+ .filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup)
+ .forEach((p) => registerOriginationProduct(p))
+ );
+ const wantKind = a.productCode === 'RD' ? 'Recurring Deposit' : 'Fixed Deposit';
+ this.loadingDeposits = true;
+ this.clientsService
+ .getClientAccountData(String(a.clientId))
+ .pipe(catchError(() => of(null)))
+ .subscribe((data: any) => {
+ const open = (acc: any) => acc?.status?.active === true;
+ const accounts = (data?.savingsAccounts ?? []).filter(
+ (acc: any) => acc?.depositType?.value === wantKind && open(acc)
+ );
+ if (accounts.length === 0) {
+ this.loadingDeposits = false;
+ return;
+ }
+ const kind = wantKind === 'Fixed Deposit' ? 'FD' : 'RD';
+ forkJoin(
+ accounts.map((acc: any) =>
+ this.originationService.getDepositAccount(kind, acc.id).pipe(catchError(() => of(null)))
+ )
+ ).subscribe((details: any) => {
+ this.realDeposits = (details as any[])
+ .filter((d) => !!d)
+ .map((d) => {
+ const maturity = this.toDate(d.maturityDate);
+ const balance = Number(d.summary?.accountBalance ?? 0);
+ const accrued = Number(d.summary?.interestNotPosted ?? 0);
+ return {
+ id: d.id,
+ accountNo: d.accountNo,
+ kind,
+ openedOn: this.toDate(d.timeline?.activatedOnDate),
+ maturityDate: maturity,
+ principal: Number(kind === 'FD' ? d.depositAmount : (d.recurringDepositAmount ?? 0)),
+ balance,
+ value: balance + accrued,
+ rate: Number(d.nominalAnnualInterestRate ?? 0),
+ underLien: Number(d.lienPercentage ?? 0) > 0,
+ monthsLeft: this.monthsUntil(maturity)
+ };
+ });
+ this.loadingDeposits = false;
+ });
+ });
+ }
+
+ /** True when the deposit cannot be picked: under lien, or pledged in another open application. */
+ depositBlocked(row: any): boolean {
+ return !!row.underLien || !!this.otherPledges[row.id];
+ }
+
+ toggleDeposit(row: any): void {
+ if (this.depositBlocked(row)) {
+ return;
+ }
+ if (this.selectedDepositIds.has(row.id)) {
+ this.selectedDepositIds.delete(row.id);
+ } else if (this.application && !this.depositRules(this.application).allowMultiple) {
+ // This product allows one deposit per loan: picking another replaces the first.
+ this.selectedDepositIds = new Set([row.id]);
+ } else {
+ this.selectedDepositIds.add(row.id);
+ }
+ this.markDepositUnsaved();
+ }
+
+ markDepositUnsaved(): void {
+ if (this.application?.depositSelection) {
+ this.application.depositSelection.saved = false;
+ }
+ }
+
+ get selectedDeposits(): any[] {
+ return this.realDeposits.filter((row) => this.selectedDepositIds.has(row.id));
+ }
+
+ /** The product's own rules for a loan against a deposit (set in Product Creation, Security & Lien). */
+ depositRules(a: LoanApplication) {
+ const product = this.productLimitsFor(a);
+ return {
+ limitPct: product?.ltvPct ?? 85,
+ basis: product?.eligibleBasis ?? 'balance_accrued',
+ rateFromDeposit: (product?.interestBasis ?? 'deposit_spread') === 'deposit_spread',
+ baseRate: product?.baseRate ?? 0,
+ spread: product?.spread ?? 0,
+ allowMultiple: product?.allowMultipleDeposits ?? false,
+ allowThirdParty: product?.allowThirdParty ?? false,
+ tenureWithinMaturity: product?.tenureWithinMaturity ?? true,
+ lienRequired: product?.lienRequired ?? true,
+ certificateRequired: product?.certificateRequired ?? true,
+ certificatePrefix: product?.certificatePrefix
+ };
+ }
+
+ /** What the selected deposits allow: limit, rate, maximum tenure and whatever is wrong with the request. */
+ depositCalc(a: LoanApplication) {
+ const product = this.productLimitsFor(a);
+ const rules = this.depositRules(a);
+ const rows = this.selectedDeposits;
+ const value = rows.reduce((sum, row) => sum + (rules.basis === 'balance' ? row.balance : row.value), 0);
+ const limitPct = rules.limitPct;
+ const normalLimit = (value * limitPct) / 100;
+ const eligible = this.depositOver85 ? value : normalLimit;
+ const depositRate = rows.length ? Math.max(...rows.map((row) => row.rate)) : 0;
+ const spread = rules.rateFromDeposit ? rules.spread : 0;
+ const rate = rules.rateFromDeposit ? depositRate + spread : rules.baseRate;
+ const minMonthsLeft = rows.length ? Math.min(...rows.map((row) => row.monthsLeft)) : 0;
+ const productMaxTenure = product?.maxTenure ?? 15;
+ const maxTenure = rows.length
+ ? rules.tenureWithinMaturity
+ ? Math.min(productMaxTenure, minMonthsLeft)
+ : productMaxTenure
+ : 0;
+ const minTenure = product?.minTenure ?? 12;
+ const errors: string[] = [];
+ if (!rows.length) {
+ errors.push('Select at least one deposit.');
+ } else {
+ if (a.requestedAmount > eligible) {
+ errors.push(
+ 'Requested ₹' +
+ Math.round(a.requestedAmount).toLocaleString('en-IN') +
+ ' is more than the eligible ₹' +
+ Math.round(eligible).toLocaleString('en-IN') +
+ '.'
+ );
+ }
+ if (maxTenure < minTenure) {
+ errors.push(
+ 'A selected deposit matures in ' +
+ minMonthsLeft +
+ ' months — less than the ' +
+ minTenure +
+ '-month minimum tenure.'
+ );
+ } else if (a.tenureMonths > maxTenure) {
+ errors.push(
+ 'Tenure ' + a.tenureMonths + ' months runs past the deposit maturity — maximum ' + maxTenure + ' months.'
+ );
+ }
+ if (this.depositOver85 && !this.depositOver85Reason.trim()) {
+ errors.push('Give a reason for sanctioning above ' + limitPct + '%.');
+ }
+ }
+ return {
+ rows,
+ value,
+ limitPct,
+ normalLimit,
+ eligible,
+ depositRate,
+ spread,
+ rate,
+ rules,
+ rateLabel: rules.rateFromDeposit ? 'Interest rate (deposit rate + spread)' : 'Interest rate (product rate)',
+ rateText: rules.rateFromDeposit
+ ? depositRate + '% + ' + spread + '% = ' + rate.toFixed(2) + '%'
+ : rate.toFixed(2) + '%',
+ tenureLabel: rules.tenureWithinMaturity
+ ? 'Max tenure (' + minTenure + '–' + productMaxTenure + ' m, not beyond maturity)'
+ : 'Max tenure (' + minTenure + '–' + productMaxTenure + ' m)',
+ maxTenure,
+ minTenure,
+ errors,
+ ok: errors.length === 0
+ };
+ }
+
+ saveDepositSelection(a: LoanApplication): void {
+ const calc = this.depositCalc(a);
+ if (!calc.ok) {
+ this.toast(calc.errors[0]);
+ return;
+ }
+ const selection: DepositSelection = {
+ accountIds: calc.rows.map((row) => row.id),
+ accountNos: calc.rows.map((row) => row.accountNo),
+ securityValue: Math.round(calc.value),
+ limitPct: calc.limitPct,
+ eligibleAmount: Math.round(calc.eligible),
+ depositRate: calc.depositRate,
+ spread: calc.spread,
+ maxTenure: calc.maxTenure,
+ over85: this.depositOver85,
+ over85Reason: this.depositOver85Reason,
+ thirdParty: this.depositThirdParty,
+ lienRequired: calc.rules.lienRequired,
+ saved: true
+ };
+ a.depositSelection = selection;
+ a.interestRate = +calc.rate.toFixed(2);
+ a.eligibleLoanAmount = selection.eligibleAmount;
+ this.persist();
+ this.toast('Deposit selection saved.');
+ }
+
+ // -------------------- Lien marking --------------------
+ /** The "Letter of lien and set-off" the depositor signs: lien on the deposit(s), the bank's right to set the loan off
+ * against them, and the undertaking not to withdraw, close or renew them meanwhile. */
+ private buildLienLetterPdf(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;
+ const money = (n: number) => 'Rs. ' + Math.round(n).toLocaleString('en-IN');
+ const fmtDate = (d: Date | null | undefined) => (d ? d.toLocaleDateString('en-GB').replace(/\//g, '-') : '—');
+ const deposits = this.lienLetterDeposits(a).map((row) => ({
+ no: row.accountNo,
+ kind: row.kind,
+ value: row.value,
+ maturity: fmtDate(row.maturityDate)
+ }));
+ const depositKind =
+ a.productCode === 'RD' ? 'recurring deposit' : a.productCode === 'DS' ? 'daily deposit' : 'fixed deposit';
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(15);
+ doc.text('LETTER OF LIEN AND SET-OFF', pageWidth / 2, 22, { align: 'center' });
+ doc.setLineWidth(0.4);
+ doc.line(margin, 26, pageWidth - margin, 26);
+
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ doc.text('Branch: ' + a.branchName, margin, 35);
+ doc.text('Date: ' + todayDDMMYYYY(), pageWidth - margin, 35, { align: 'right' });
+ doc.text('Loan application no.: ' + a.applicationNo, margin, 42);
+
+ doc.text('To,', margin, 54);
+ doc.text('The Branch Manager,', margin, 60);
+ doc.text(a.branchName + ' Branch', margin, 66);
+
+ doc.setFont('helvetica', 'bold');
+ doc.text('Subject: Lien on my ' + depositKind + ' and right of set-off against the loan', margin, 78);
+ doc.setFont('helvetica', 'normal');
+
+ autoTable(doc, {
+ startY: 84,
+ head: [[
+ 'Deposit account no.',
+ 'Type',
+ 'Matures on',
+ 'Value (balance + interest)'
+ ]],
+ body: deposits.length
+ ? deposits.map((d) => [
+ d.no,
+ d.kind,
+ d.maturity,
+ money(d.value)
+ ])
+ : [[
+ '—',
+ '—',
+ '—',
+ '—'
+ ]],
+ styles: { fontSize: 9 },
+ headStyles: { fillColor: [
+ 15,
+ 23,
+ 42
+ ] },
+ margin: { left: margin, right: margin }
+ });
+ let y = ((doc as any).lastAutoTable?.finalY ?? 100) + 8;
+
+ const intro =
+ 'I / We, ' +
+ a.applicantName +
+ ', hold the above deposit(s) with the bank and have applied for a loan of ' +
+ money(a.requestedAmount) +
+ ' for ' +
+ a.tenureMonths +
+ ' months. In consideration of the bank granting this loan, I / We state and agree as follows:';
+ const clauses = [
+ 'The bank has a lien on the above deposit(s), including interest and any renewal, as security for the loan, interest, penal interest and all charges, until the loan is repaid in full.',
+ 'The bank may set off (adjust) the loan, interest and charges against the deposit(s) at any time on default, and on the maturity of the deposit(s) if the loan is still outstanding, without further notice to me / us. Any balance will be paid to me / us.',
+ 'I / We will not withdraw, close, pledge, transfer or renew the deposit(s) in any other name until the bank cancels the lien in writing after the loan is fully repaid.',
+ 'This letter continues to apply for as long as any amount is due under the loan, even if the loan is renewed, reduced or drawn again.',
+ 'Where the deposit is in joint names, every holder signs this letter and each of us is bound by it.'
+ ];
+ const write = (text: string, indent = 0) => {
+ const lines = doc.splitTextToSize(text, contentWidth - indent) as string[];
+ lines.forEach((line) => {
+ if (y > 270) {
+ doc.addPage();
+ y = 20;
+ }
+ doc.text(line, margin + indent, y);
+ y += 5.2;
+ });
+ };
+ write(intro);
+ y += 2;
+ clauses.forEach((clause, i) => {
+ write(i + 1 + '. ' + clause, 0);
+ y += 1.5;
+ });
+
+ y += 10;
+ if (y > 235) {
+ doc.addPage();
+ y = 25;
+ }
+ doc.setFont('helvetica', 'bold');
+ doc.text('Depositor(s)', margin, y);
+ doc.text('For the bank', pageWidth - margin - 55, y);
+ doc.setFont('helvetica', 'normal');
+ y += 22;
+ doc.line(margin, y, margin + 70, y);
+ doc.line(pageWidth - margin - 55, y, pageWidth - margin, y);
+ y += 5;
+ doc.setFontSize(9);
+ doc.text('Signature · Name: ' + a.applicantName, margin, y);
+ doc.text('Branch Manager / Authorised officer', pageWidth - margin - 55, y);
+ y += 14;
+ doc.line(margin, y, margin + 70, y);
+ y += 5;
+ doc.text('Second / joint holder (if any) · Name and signature', margin, y);
+
+ return doc;
+ }
+
+ /** The deposits the letter is written for: the saved selection, or what is selected right now. */
+ private lienLetterDeposits(a: LoanApplication): any[] {
+ const saved = a.depositSelection?.saved ? a.depositSelection.accountIds : [];
+ const ids = saved.length ? saved : [...this.selectedDepositIds];
+ return this.realDeposits.filter((row) => ids.includes(row.id));
+ }
+
+ /** What still blocks completing the Deposit Selection stage, or null: deposit saved, then (if the product requires a
+ * lien) the signed letter of lien and set-off uploaded. */
+ depositStageIssue(a: LoanApplication): string | null {
+ if (!a.depositSelection?.saved) {
+ return 'Select the deposit and press Save Deposit & Terms.';
+ }
+ if (this.depositRules(a).lienRequired && !this.lienLetterStored) {
+ return 'Upload the signed letter of lien and set-off.';
+ }
+ return null;
+ }
+
+ /** The letter can be made as soon as a deposit is chosen -- no need to save the selection first. */
+ canMakeLienLetter(a: LoanApplication): boolean {
+ return this.lienLetterDeposits(a).length > 0;
+ }
+
+ downloadLienLetter(a: LoanApplication): void {
+ if (!this.canMakeLienLetter(a)) {
+ this.toast('Select a deposit first to create the letter.');
+ return;
+ }
+ this.buildLienLetterPdf(a).save('Lien-Letter-' + a.applicationNo + '.pdf');
+ }
+
+ printLienLetter(a: LoanApplication): void {
+ if (!this.canMakeLienLetter(a)) {
+ this.toast('Select a deposit first to create the letter.');
+ return;
+ }
+ const doc = this.buildLienLetterPdf(a);
+ doc.autoPrint();
+ window.open(doc.output('bloburl'), '_blank');
+ }
+
+ get lienLetterStored(): boolean {
+ return this.hasStoredDoc('LIEN:letter');
+ }
+
+ async onLienLetterFile(a: LoanApplication, event: Event): Promise {
+ const file = await this.pickSingleNewKycFile(event, 'Letter of lien and set-off');
+ if (!file) {
+ return;
+ }
+ this.uploadCaptured(file, 'LIEN:letter', 'Letter of lien and set-off');
+ }
+
+ lienReady(a: LoanApplication): boolean {
+ return !!a.depositSelection?.saved && this.lienLetterStored && !a.lien?.marked;
+ }
+
+ /** True when this product marks the lien at disbursement (the product's own setting; the default). */
+ lienAtDisbursement(a: LoanApplication): boolean {
+ const product = this.productLimitsFor(a);
+ return (product?.lienStage ?? 'disbursement') !== 'sanction';
+ }
+
+ /** Writes the lien record on the application (the deposit-side block follows with the lien API). */
+ private applyLien(a: LoanApplication): boolean {
+ const selection = a.depositSelection;
+ if (!selection || !this.lienReady(a)) {
+ return false;
+ }
+ const year = new Date().getFullYear();
+ a.lien = {
+ marked: true,
+ certificateNo:
+ (this.depositRules(a).certificatePrefix ?? 'LC/' + a.productCode + '/') +
+ year +
+ '/' +
+ a.applicationNo.slice(-6),
+ markedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
+ amount: a.requestedAmount,
+ accountNos: selection.accountNos
+ };
+ return true;
+ }
+
+ /** Manual lien marking: only for a product that marks the lien at sanction. */
+ markLien(a: LoanApplication): void {
+ if (this.lienAtDisbursement(a) || !this.applyLien(a)) {
+ return;
+ }
+ this.persist();
+ this.toast('Lien marked.');
+ }
+
+ /** What still blocks the disbursement of a loan against a deposit, or null when nothing does. */
+ disbursementLienIssue(a: LoanApplication): string | null {
+ if (!this.isDepositLoan(a) || a.lien?.marked) {
+ return null;
+ }
+ if (!a.depositSelection?.saved) {
+ return 'Select and save the deposit before disbursing.';
+ }
+ if (!this.depositRules(a).lienRequired) {
+ return null;
+ }
+ if (!this.lienLetterStored) {
+ return 'Upload the signed letter of lien and set-off before disbursing.';
+ }
+ return null;
+ }
+
+ // -------------------- Loan plan (Personal Discussion stage of a loan against a deposit) --------------------
+ /** The plan the customer is offered: amount, rate, interest, security and the product's other terms. */
+ loanPlan(a: LoanApplication) {
+ const product = this.productLimitsFor(a);
+ const selection = a.depositSelection;
+ const amount = a.requestedAmount;
+ const rate = a.interestRate;
+ const tenure = a.tenureMonths;
+ const monthlyInterest = (amount * rate) / 1200;
+ const totalInterest = monthlyInterest * tenure;
+ const word = (v: string | undefined) => (v ?? '').replace(/_/g, ' ');
+ return {
+ amount,
+ rate,
+ tenure,
+ monthlyInterest,
+ totalInterest,
+ totalPayable: amount + totalInterest,
+ depositRate: selection?.depositRate ?? 0,
+ spread: selection?.spread ?? 0,
+ rateFromDeposit: (product?.interestBasis ?? 'deposit_spread') === 'deposit_spread',
+ securityValue: selection?.securityValue ?? 0,
+ limitPct: selection?.limitPct ?? product?.ltvPct ?? 0,
+ eligible: selection?.eligibleAmount ?? 0,
+ accounts: selection?.accountNos?.join(', ') ?? '—',
+ facility: word(product?.facilityType) || 'demand',
+ repayment:
+ (product?.repaymentType ?? 'bullet') === 'bullet'
+ ? 'Principal in one payment at the end · interest ' +
+ (product?.interestPostingFrequency ?? 'monthly').toLowerCase()
+ : word(product?.repaymentType),
+ penal: product?.penalApplicable ? product.penalRate + '% p.a. on overdue amounts' : 'Not applicable',
+ preclosure: word(product?.preclosure) || 'anytime',
+ renewal: product?.renewalAllowed ? 'Allowed' : 'Not allowed',
+ lienStage: this.lienAtDisbursement(a) ? 'at disbursement' : 'at sanction',
+ lienRequired: this.depositRules(a).lienRequired
+ };
+ }
+
+ private buildLoanPlanPdf(a: LoanApplication): jsPDF {
+ const plan = this.loanPlan(a);
+ const money = (n: number) => 'Rs. ' + Math.round(n).toLocaleString('en-IN');
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const margin = 20;
+
+ doc.setFillColor(15, 23, 42);
+ doc.rect(0, 0, pageWidth, 28, 'F');
+ doc.setTextColor(255, 255, 255);
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(16);
+ doc.text('LOAN PLAN', pageWidth / 2, 12, { align: 'center' });
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ doc.text(a.productName, pageWidth / 2, 20, { align: 'center' });
+ doc.setTextColor(0, 0, 0);
+
+ doc.setFontSize(10);
+ doc.text('Branch: ' + a.branchName, margin, 38);
+ doc.text('Date: ' + todayDDMMYYYY(), pageWidth - margin, 38, { align: 'right' });
+
+ const rows: [string, string][][] = [
+ [
+ [
+ 'Applicant',
+ a.applicantName
+ ],
+ [
+ 'Application no.',
+ a.applicationNo
+ ]
+ ],
+ [
+ [
+ 'Loan amount',
+ money(plan.amount)
+ ],
+ [
+ 'Tenure',
+ plan.tenure + ' months'
+ ],
+ [
+ 'Facility',
+ plan.facility
+ ],
+ [
+ 'Interest rate',
+ plan.rate.toFixed(2) +
+ '% p.a.' +
+ (plan.rateFromDeposit ? ' (deposit rate ' + plan.depositRate + '% + ' + plan.spread + '%)' : '')
+ ],
+ [
+ 'Interest per month (estimate)',
+ money(plan.monthlyInterest)
+ ],
+ [
+ 'Total interest over the tenure',
+ money(plan.totalInterest)
+ ],
+ [
+ 'Total payable at the end',
+ money(plan.totalPayable)
+ ],
+ [
+ 'Repayment',
+ plan.repayment
+ ]
+ ],
+ [
+ [
+ 'Deposit pledged',
+ plan.accounts
+ ],
+ [
+ 'Security value (balance + interest)',
+ money(plan.securityValue)
+ ],
+ [
+ 'Loan limit on the deposit',
+ plan.limitPct + '% = ' + money(plan.eligible)
+ ],
+ [
+ 'Lien',
+ plan.lienRequired ? 'Marked ' + plan.lienStage + '; cancelled after full repayment' : 'Not required'
+ ]
+ ],
+ [
+ [
+ 'Penal interest',
+ plan.penal
+ ],
+ [
+ 'Pre-closure',
+ plan.preclosure
+ ],
+ [
+ 'Renewal',
+ plan.renewal
+ ]
+ ]
+ ];
+ const titles = [
+ 'Applicant',
+ 'Loan and interest',
+ 'Security',
+ 'Other terms'
+ ];
+ let y = 46;
+ rows.forEach((group, i) => {
+ autoTable(doc, {
+ startY: y,
+ head: [[
+ titles[i],
+ ''
+ ]],
+ body: group,
+ theme: 'grid',
+ styles: { fontSize: 9.5, cellPadding: 2.5 },
+ headStyles: { fillColor: [
+ 30,
+ 64,
+ 175
+ ] },
+ columnStyles: { 0: { cellWidth: 70, fontStyle: 'bold' } },
+ margin: { left: margin, right: margin }
+ });
+ y = ((doc as any).lastAutoTable?.finalY ?? y) + 6;
+ });
+
+ doc.setFontSize(8.5);
+ doc.setTextColor(100, 100, 100);
+ const note = doc.splitTextToSize(
+ 'Interest is an estimate on the full amount for the whole tenure. The actual interest is charged on the amount in use, and the rate may change as the deposit rate changes.',
+ pageWidth - margin * 2
+ ) as string[];
+ doc.text(note, margin, y + 2);
+ return doc;
+ }
+
+ downloadLoanPlan(a: LoanApplication): void {
+ this.buildLoanPlanPdf(a).save('Loan-Plan-' + a.applicationNo + '.pdf');
+ }
+
+ printLoanPlan(a: LoanApplication): void {
+ const doc = this.buildLoanPlanPdf(a);
+ doc.autoPrint();
+ window.open(doc.output('bloburl'), '_blank');
+ }
+
+ /** Sanction letter for a loan against a deposit, with the customer's acknowledgement block, shown at Customer Signing. */
+ private buildSanctionLetterPdf(a: LoanApplication): jsPDF {
+ const plan = this.loanPlan(a);
+ const money = (n: number) => 'Rs. ' + Math.round(n).toLocaleString('en-IN');
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const margin = 20;
+ const width = pageWidth - margin * 2;
+ let y = 22;
+ const para = (text: string, bold = false, gap = 5) => {
+ doc.setFont('helvetica', bold ? 'bold' : 'normal');
+ doc.setFontSize(10.5);
+ const lines = doc.splitTextToSize(text, width) as string[];
+ doc.text(lines, margin, y);
+ y += lines.length * 5 + gap;
+ };
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(15);
+ doc.text('SANCTION LETTER', pageWidth / 2, y, { align: 'center' });
+ y += 6;
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ doc.text(a.productName + ' - ' + a.branchName, pageWidth / 2, y, { align: 'center' });
+ y += 12;
+ doc.text('Application no.: ' + a.applicationNo, margin, y);
+ doc.text('Date: ' + todayDDMMYYYY(), pageWidth - margin, y, { align: 'right' });
+ y += 10;
+ para('To,\n' + a.applicantName, false, 4);
+ para('Subject: Sanction of loan against deposit', true);
+ para(
+ 'We are pleased to inform you that the bank has sanctioned a loan to you against the deposit pledged with us, on the terms below' +
+ (a.committeeResolutionNo
+ ? ' (Loan Committee resolution no. ' + a.committeeResolutionNo + ' dated ' + a.committeeResolutionDate + ')'
+ : '') +
+ '.'
+ );
+ autoTable(doc, {
+ startY: y,
+ body: [
+ [
+ 'Loan amount',
+ money(plan.amount)
+ ],
+ [
+ 'Tenure',
+ plan.tenure + ' months'
+ ],
+ [
+ 'Interest rate',
+ plan.rate.toFixed(2) + '% p.a.'
+ ],
+ [
+ 'Repayment',
+ plan.repayment
+ ],
+ [
+ 'Deposit pledged',
+ plan.accounts
+ ],
+ [
+ 'Penal interest',
+ plan.penal
+ ],
+ [
+ 'Pre-closure',
+ plan.preclosure
+ ]
+ ],
+ theme: 'grid',
+ styles: { fontSize: 10, cellPadding: 2.5 },
+ columnStyles: { 0: { cellWidth: 60, fontStyle: 'bold' } },
+ margin: { left: margin, right: margin }
+ });
+ y = ((doc as any).lastAutoTable?.finalY ?? y) + 8;
+ para(
+ "The deposit stays under the bank's lien until the loan is fully repaid. If the loan is not repaid in time, the bank may set off the dues against the deposit."
+ );
+ para('Acknowledgement', true, 3);
+ para('I / We have read and understood the terms above, and accept the loan on these terms.', false, 16);
+ doc.text('Signature of borrower: ____________________', margin, y);
+ doc.text('Date: ____________', pageWidth - margin, y, { align: 'right' });
+ return doc;
+ }
+
+ downloadSanctionLetter(a: LoanApplication): void {
+ this.buildSanctionLetterPdf(a).save('Sanction-Letter-' + a.applicationNo + '.pdf');
+ }
+
+ printSanctionLetter(a: LoanApplication): void {
+ const doc = this.buildSanctionLetterPdf(a);
+ doc.autoPrint();
+ window.open(doc.output('bloburl'), '_blank');
+ }
+
+ /** Personal Discussion -> Loan Committee for a loan against a deposit: the plan is reviewed; no meeting to schedule. */
+ proceedLoanPlan(a: LoanApplication): void {
+ this.runStageAction(
+ this.originationService.clearPersonalDiscussion(
+ a.applicationNo,
+ this.currentUserName,
+ 'Loan plan reviewed — proceeding to Loan Committee.'
+ )
+ );
+ }
+
+ /** A loan against an FD / RD / DDS / LIC: the deposit is the security. */
+ isDepositLoan(a: LoanApplication): boolean {
+ return isDepositLoanCode(a.productCode);
+ }
+
affordabilityOk(a: LoanApplication): boolean {
- if (!a.coBorrowerRequired) {
+ if (this.isDepositLoan(a) || !a.coBorrowerRequired) {
return true;
}
if (!this.coBorrowerDetailsComplete(a)) {
@@ -1934,6 +2912,10 @@ I confirm that:
}
allKycDone(a: LoanApplication): boolean {
+ if (this.isDepositLoan(a)) {
+ // Income proof, bank statement, address proof and monthly income are optional here.
+ return a.aadhaarVerified && a.panVerified && a.productDocuments.every((doc) => doc.submitted);
+ }
return (
a.aadhaarVerified &&
a.panVerified &&
@@ -2479,6 +3461,226 @@ I confirm that:
return this.committeeReportsReady(a) && !!a.committeeResolutionNo.trim() && !!a.committeeResolutionDate.trim();
}
+ /** What is verified at an approval stage right now: the snapshot sent with the request and reviewed by the approver. */
+ approvalChecklist(a: LoanApplication, field: string): { label: string; ok: boolean }[] {
+ const deposit = this.isDepositLoan(a);
+ const rows: { label: string; ok: boolean }[] = [];
+ if (field === 'creditCommitteeApproval') {
+ if (!deposit) {
+ rows.push({ label: 'CIBIL / credit bureau report on file', ok: !!a.cicReportUploaded });
+ }
+ rows.push({ label: 'Background check', ok: !!a.backgroundCheckDone });
+ if (a.secured) {
+ rows.push({ label: 'CERSAI search', ok: !!a.cersaiSearchDone });
+ }
+ rows.push({ label: 'OVD — Aadhaar and PAN verified', ok: !!a.aadhaarVerified && !!a.panVerified });
+ if (!deposit) {
+ rows.push({ label: 'Income proof', ok: !!a.incomeProofSubmitted });
+ rows.push({ label: 'Field investigation report', ok: this.fiComplete(a) });
+ rows.push({ label: 'Manager meeting', ok: !!a.managerMeetingDone });
+ }
+ rows.push({
+ label: 'Resolution ' + (a.committeeResolutionNo || '—') + ' dated ' + (a.committeeResolutionDate || '—'),
+ ok: !!a.committeeResolutionNo?.trim() && !!a.committeeResolutionDate?.trim()
+ });
+ } else if (field === 'riskApproval') {
+ if (deposit) {
+ rows.push(...this.depositRiskChecks(a));
+ } else {
+ rows.push({ label: 'ID / address verified', ok: a.addressVerification.status === 'VERIFIED' });
+ rows.push({ label: 'Income proof verified', ok: !!a.incomeProofSubmitted });
+ rows.push({ label: 'Field investigation report', ok: this.fiComplete(a) });
+ }
+ rows.push({ label: 'Previous loan declaration', ok: !!a.previousLoanDeclarationDone });
+ if (a.secured) {
+ rows.push({ label: 'Property search report', ok: !!a.propertySearchReportDone });
+ rows.push({ label: 'Valuation report', ok: !!a.valuationReportConfirmed });
+ }
+ rows.push({ label: 'Personal background check', ok: !!a.backgroundCheckDone });
+ if (a.guarantorRequired) {
+ rows.push({
+ label: 'Guarantor selected and a member',
+ ok: !!a.guarantorClientId && !!a.guarantorHasShareAccount
+ });
+ }
+ } else if (field === 'legalApproval') {
+ if (!deposit) {
+ rows.push({ label: 'Processing fee collected', ok: !!a.feesAndAccountOpened });
+ }
+ if (a.secured) {
+ rows.push({ label: 'Mortgage created', ok: !!a.mortgageCreated });
+ }
+ rows.push({ label: 'Legal stamp done', ok: !!a.legalStampDone });
+ if (!deposit) {
+ rows.push({ label: 'File filing done', ok: !!a.fileFilingDone });
+ }
+ (a.legalDocuments ?? []).forEach((doc) => rows.push({ label: doc.label, ok: !!doc.submitted }));
+ if (deposit) {
+ this.collectedDocs(a).forEach((doc) => rows.push({ label: doc.label + ' (' + doc.where + ')', ok: doc.ok }));
+ }
+ } else if (field === 'postCheckApproval') {
+ if (!deposit) {
+ rows.push({ label: 'Signed by agent', ok: !!a.signedByAgent });
+ }
+ rows.push({ label: 'Signed by manager', ok: !!a.signedByManager });
+ if (!deposit) {
+ rows.push({ label: 'Signed by committee', ok: !!a.signedByCommittee });
+ rows.push({ label: 'Signed by risk approval', ok: !!a.signedByRiskApproval });
+ rows.push({ label: 'Signed by legal approval', ok: !!a.signedByLegalApproval });
+ }
+ }
+ return rows;
+ }
+
+ /** The four approvals shown in the tracking panel, in the order they happen. */
+ readonly approvalTiles = [
+ { field: 'creditCommitteeApproval', label: 'Loan Committee' },
+ { field: 'riskApproval', label: 'Risk Approval' },
+ { field: 'legalApproval', label: 'Legal Approval' },
+ { field: 'postCheckApproval', label: 'Post-Disbursement Check' }
+ ];
+
+ approvalOf(a: LoanApplication, field: string): any {
+ return (a as any)[field];
+ }
+
+ /**
+ * Approvals that really stand: granted, not waiting on a new request, and not the stage the application is
+ * on right now (a decision left over from an earlier pass must not read as approved while it is being redone).
+ */
+ grantedApprovals(a: LoanApplication): { label: string; approverName: string; date: string; exception: boolean }[] {
+ const stageField: Record = {
+ CREDIT_COMMITTEE: 'creditCommitteeApproval',
+ RISK_APPROVAL: 'riskApproval',
+ LEGAL_APPROVAL: 'legalApproval',
+ POST_CHECK: 'postCheckApproval'
+ };
+ return this.approvalTiles
+ .filter((tile) => {
+ const ap = this.approvalOf(a, tile.field);
+ const granted = ap?.decision === 'APPROVED' || ap?.decision === 'EXCEPTION_APPROVED';
+ return granted && !this.approvalRequest(a, tile.field) && stageField[a.currentStage] !== tile.field;
+ })
+ .map((tile) => {
+ const ap = this.approvalOf(a, tile.field);
+ return {
+ label: tile.label,
+ approverName: ap.approverName,
+ date: ap.date,
+ exception: ap.decision === 'EXCEPTION_APPROVED'
+ };
+ });
+ }
+
+ /** The latest request for this stage whatever its state (pending, decided or withdrawn). */
+ approvalRequestAny(a: LoanApplication, field: string): ApprovalRequest | null {
+ return a.approvalRequests?.[field] ?? null;
+ }
+
+ /** The request sent for this stage, while it is waiting for a decision. */
+ approvalRequest(a: LoanApplication, field: string): ApprovalRequest | null {
+ const request = a.approvalRequests?.[field];
+ return request && request.status === 'PENDING' ? request : null;
+ }
+
+ isMyRequest(request: ApprovalRequest): boolean {
+ return (request.submittedBy ?? '').toLowerCase() === (this.currentUserName ?? '').toLowerCase();
+ }
+
+ /** Maker step: send the stage for approval to another user. */
+ async sendForApproval(
+ a: LoanApplication,
+ field: string,
+ approverRole: string,
+ decision: 'APPROVED' | 'EXCEPTION_APPROVED' = 'APPROVED'
+ ): Promise {
+ const committee = field === 'creditCommitteeApproval';
+ // The server checks the stage's checklist against what is stored, so what is on screen is saved first.
+ try {
+ await firstValueFrom(this.originationService.save(a));
+ } catch {
+ this.toast('Could not save the application before sending it. Nothing was sent.');
+ return;
+ }
+ this.runStageAction(
+ this.originationService.submitApproval(a.applicationNo, field, {
+ decision,
+ approverRole,
+ remarks: this.actionRemarks.trim() || undefined,
+ resolutionNo: committee ? a.committeeResolutionNo.trim() : undefined,
+ resolutionDate: committee ? a.committeeResolutionDate.trim() : undefined,
+ checklist: this.approvalChecklist(a, field)
+ })
+ );
+ this.actionRemarks = '';
+ }
+
+ /** The approver's own ticks, keyed by stage and row, while the request is open. */
+ checkerTicks: Record = {};
+
+ /** The rows the approver reviews: what the sender verified (or, for an older request, what is on the stage now). */
+ checkerItems(a: LoanApplication, field: string, request: ApprovalRequest): { label: string; ok: boolean }[] {
+ return request.checklist?.length ? request.checklist : this.approvalChecklist(a, field);
+ }
+
+ /** "6 of 6" -- how many rows the approver ticked, from the stored record. */
+ checkerSummary(request: ApprovalRequest): string {
+ const items = request.checkerChecklist ?? [];
+ return items.filter((item) => item.ok).length + ' of ' + items.length;
+ }
+
+ checkerKey(field: string, label: string): string {
+ return field + '|' + label;
+ }
+
+ setCheckerTick(field: string, label: string, value: boolean): void {
+ this.checkerTicks[this.checkerKey(field, label)] = !!value;
+ }
+
+ /** Approve opens only when every row the sender verified is ticked by the approver, and none was left unverified. */
+ checkerReady(a: LoanApplication, field: string, request: ApprovalRequest): boolean {
+ const items = this.checkerItems(a, field, request);
+ return items.every((item) => item.ok && !!this.checkerTicks[this.checkerKey(field, item.label)]);
+ }
+
+ checkerCount(a: LoanApplication, field: string, request: ApprovalRequest): string {
+ const items = this.checkerItems(a, field, request);
+ const done = items.filter((item) => item.ok && !!this.checkerTicks[this.checkerKey(field, item.label)]).length;
+ return done + ' of ' + items.length;
+ }
+
+ /** Checker step: a different user approves or rejects what was sent. */
+ decideRequest(a: LoanApplication, field: string, approverRole: string, decision: 'APPROVED' | 'REJECTED'): void {
+ if (decision === 'REJECTED' && !this.actionRemarks.trim()) {
+ this.toast('Give the reason for rejecting in the remarks box.');
+ return;
+ }
+ const request = this.approvalRequest(a, field);
+ const checkerChecklist =
+ decision === 'APPROVED' && request
+ ? this.checkerItems(a, field, request).map((item) => ({
+ label: item.label,
+ ok: !!this.checkerTicks[this.checkerKey(field, item.label)]
+ }))
+ : undefined;
+ this.runStageAction(
+ this.originationService.decideApproval(a.applicationNo, field, {
+ decision,
+ approverName: this.currentUserName,
+ approverRole,
+ remarks:
+ this.actionRemarks.trim() || (decision === 'REJECTED' ? 'Declined on review.' : 'Reviewed and approved.'),
+ checkerChecklist
+ })
+ );
+ this.actionRemarks = '';
+ this.checkerTicks = {};
+ }
+
+ withdrawRequest(a: LoanApplication, field: string): void {
+ this.runStageAction(this.originationService.withdrawApproval(a.applicationNo, field));
+ }
+
approveCommitteeStage(a: LoanApplication): void {
if (!this.committeeCanApprove(a)) {
return;
@@ -2528,30 +3730,136 @@ I confirm that:
);
}
+ /** Documents of a loan against a deposit that were already collected earlier, shown at Legal Approval, not asked again. */
+ collectedDocs(a: LoanApplication): { label: string; ok: boolean; where: string; storeKey: string }[] {
+ const rows = (a.productDocuments ?? []).map((doc) => ({
+ label: doc.label,
+ ok: !!doc.submitted,
+ where: 'KYC & Documents',
+ storeKey: 'PRODUCT_DOC:' + doc.key
+ }));
+ rows.push({
+ label: 'Letter of lien and set-off',
+ ok: this.lienLetterStored,
+ where: 'Lien Marking',
+ storeKey: 'LIEN:letter'
+ });
+ return rows;
+ }
+
+ /** Update an already-collected document: the new file goes in first, then the old one(s) are removed. */
+ replaceCollectedDoc(a: LoanApplication, row: { label: string; storeKey: string }, event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ input.value = '';
+ if (!file || this.approvalRequest(a, 'legalApproval')) {
+ return;
+ }
+ const old = this.storedDocsFor(row.storeKey);
+ this.uploadCaptured(file, row.storeKey, row.label, () => {
+ old.forEach((doc) => this.removeStoredDoc(a, doc));
+ });
+ }
+
legalChecklistDone(a: LoanApplication): boolean {
- return a.feesAndAccountOpened && (!a.secured || a.mortgageCreated) && a.legalStampDone && a.fileFilingDone;
+ return (
+ (this.isDepositLoan(a) || a.feesAndAccountOpened) &&
+ (!a.secured || a.mortgageCreated) &&
+ a.legalStampDone &&
+ (this.isDepositLoan(a) || a.fileFilingDone) &&
+ (!this.isDepositLoan(a) || this.collectedDocs(a).every((doc) => doc.ok))
+ );
}
committeeReportsReady(a: LoanApplication): boolean {
+ const deposit = this.isDepositLoan(a); // no bureau report, income proof or field investigation on a deposit loan
return (
- a.cicReportUploaded &&
+ (deposit || a.cicReportUploaded) &&
a.backgroundCheckDone &&
(!a.secured || a.cersaiSearchDone) &&
a.aadhaarVerified &&
a.panVerified &&
- a.incomeProofSubmitted &&
- this.fiComplete(a) &&
- a.managerMeetingDone
+ (deposit || a.incomeProofSubmitted) &&
+ (deposit || this.fiComplete(a)) &&
+ (deposit || a.managerMeetingDone)
);
}
+ /**
+ * What Risk Approval confirms on a loan against a deposit. Every row is worked out from the facts on the application,
+ * so it can only show a tick when it is actually true.
+ */
+ depositRiskChecks(a: LoanApplication): { label: string; ok: boolean }[] {
+ const selection = a.depositSelection;
+ const saved = !!selection?.saved;
+ const limitPct = selection?.limitPct ?? 85;
+ const value = selection?.securityValue ?? 0;
+ const usedPct = value > 0 ? (a.requestedAmount / value) * 100 : 0;
+ const withinLimit = saved && a.requestedAmount <= (value * limitPct) / 100 + 0.5;
+ const pledged = (selection?.accountIds ?? []).map((id) => ({
+ id,
+ row: this.realDeposits.find((r) => r.id === id)
+ }));
+ const elsewhere = pledged.map((p) => this.otherPledges[p.id]).filter((x) => !!x);
+ const underLien = pledged.some((p) => p.row?.underLien);
+ const monthsLeft = pledged.length
+ ? Math.min(...pledged.map((p) => p.row?.monthsLeft ?? selection?.maxTenure ?? 0))
+ : 0;
+
+ const rows: { label: string; ok: boolean }[] = [
+ { label: 'KYC — Aadhaar and PAN verified', ok: !!a.aadhaarVerified && !!a.panVerified },
+ {
+ label: elsewhere.length
+ ? 'Deposit is already pledged to ' + elsewhere.join(', ')
+ : underLien
+ ? 'Deposit already has a lien on it'
+ : 'Deposit pledged is free of any other lien',
+ ok: saved && elsewhere.length === 0 && !underLien
+ }
+ ];
+ if (withinLimit) {
+ rows.push({
+ label: 'Loan is ' + usedPct.toFixed(0) + '% of the deposit — within the ' + limitPct + '% limit',
+ ok: true
+ });
+ } else {
+ const reason = (selection?.over85Reason ?? '').trim();
+ rows.push({
+ label:
+ 'Loan is ' +
+ usedPct.toFixed(0) +
+ '% of the deposit — above the ' +
+ limitPct +
+ '% limit, ' +
+ (selection?.over85 && reason
+ ? "sanctioned on officer's discretion: " + reason
+ : 'no discretion reason recorded'),
+ ok: saved && !!selection?.over85 && !!reason
+ });
+ }
+ rows.push({
+ label:
+ 'Tenure ' +
+ a.tenureMonths +
+ ' months — ' +
+ (a.tenureMonths <= monthsLeft ? 'within' : 'runs past') +
+ ' the deposit maturity (' +
+ monthsLeft +
+ ' months left)',
+ ok: saved && a.tenureMonths <= monthsLeft
+ });
+ return rows;
+ }
+
riskChecklistReady(a: LoanApplication): boolean {
+ const deposit = this.isDepositLoan(a);
return (
- a.addressVerification.status === 'VERIFIED' &&
- a.incomeProofSubmitted &&
+ (deposit || a.addressVerification.status === 'VERIFIED') &&
+ (deposit || a.incomeProofSubmitted) &&
a.previousLoanDeclarationDone &&
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
- this.fiComplete(a) &&
+ (deposit || this.fiComplete(a)) &&
+ (!deposit || this.depositRiskChecks(a).every((check) => check.ok)) &&
a.backgroundCheckDone &&
(!a.guarantorRequired || (!!a.guarantorClientId && a.guarantorHasShareAccount))
);
@@ -2636,6 +3944,10 @@ I confirm that:
}
postCheckSignaturesDone(a: LoanApplication): boolean {
+ if (this.isDepositLoan(a)) {
+ // Committee, risk and legal already approved through their own maker-checker stages; only the manager signs here.
+ return a.signedByManager;
+ }
return (
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
);
@@ -2732,6 +4044,26 @@ I confirm that:
* 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 {
+ if (this.isDepositLoan(a)) {
+ const issue = this.disbursementLienIssue(a);
+ if (issue) {
+ this.toast(issue);
+ return;
+ }
+ if (this.depositRules(a).lienRequired && !a.lien?.marked) {
+ // The product marks the lien at disbursement: it is recorded now, and stored before the server checks it.
+ if (!this.applyLien(a)) {
+ return;
+ }
+ try {
+ await firstValueFrom(this.originationService.save(a));
+ } catch {
+ a.lien = undefined;
+ this.toast('Could not record the lien. Nothing was disbursed.');
+ return;
+ }
+ }
+ }
let realLoan: { fineractLoanId: number; fineractLoanAccountNo: string } | undefined;
if (a.customerType === 'EXISTING' && a.clientId) {
this.creatingRealLoan = true;
diff --git a/src/app/loan-origination/loan-fineract-bridge.ts b/src/app/loan-origination/loan-fineract-bridge.ts
index a74c8d3b1..049b69c7d 100644
--- a/src/app/loan-origination/loan-fineract-bridge.ts
+++ b/src/app/loan-origination/loan-fineract-bridge.ts
@@ -50,7 +50,8 @@ export async function createRealLoanAccount(
const locale = settings.language.code;
const dateFormat = settings.dateFormat;
- const today = dateUtils.formatDate(new Date(), dateFormat);
+ // Fineract rejects a submitted-on date later than its business date, so use that (the date shown in the header), not the PC clock.
+ const today = dateUtils.formatDate(settings.businessDate, dateFormat);
const payload = {
clientId: app.clientId,
diff --git a/src/app/loan-origination/loan-origination-data.ts b/src/app/loan-origination/loan-origination-data.ts
index 34569a52a..5fe9738dc 100644
--- a/src/app/loan-origination/loan-origination-data.ts
+++ b/src/app/loan-origination/loan-origination-data.ts
@@ -29,6 +29,7 @@ export { BRANCHES, BranchMaster };
export type OriginationStage =
| 'APPLICATION'
| 'KYC_DOCUMENTS'
+ | 'DEPOSIT_SELECTION'
| 'CIC_CHECK'
| 'FIELD_INVESTIGATION'
| 'PERSONAL_DISCUSSION'
@@ -43,6 +44,7 @@ export type OriginationStage =
export const STAGE_ORDER: OriginationStage[] = [
'APPLICATION',
'KYC_DOCUMENTS',
+ 'DEPOSIT_SELECTION',
'CIC_CHECK',
'FIELD_INVESTIGATION',
'PERSONAL_DISCUSSION',
@@ -58,6 +60,7 @@ export const STAGE_ORDER: OriginationStage[] = [
export const STAGE_LABELS: Record = {
APPLICATION: 'Application Received',
KYC_DOCUMENTS: 'KYC & Document Collection',
+ DEPOSIT_SELECTION: 'Deposit Selection & Eligibility',
CIC_CHECK: 'CIC / Credit Bureau Check',
FIELD_INVESTIGATION: 'Field Investigation (FI)',
PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
@@ -70,6 +73,18 @@ export const STAGE_LABELS: Record = {
DISBURSED: 'Disbursed'
};
+/** The stages a given product actually goes through (a loan against a deposit has no CIC and no field investigation): Deposit Selection only for a loan against a deposit, and no
+ * credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
+export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
+ if (isDepositLoanCode(productCode)) {
+ // No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien); every other stage stays.
+ return STAGE_ORDER.filter(
+ (stage) => stage !== 'CIC_CHECK' && stage !== 'FIELD_INVESTIGATION' && stage !== 'CHEQUE_ACH'
+ );
+ }
+ return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
+}
+
export function stageIndex(stage: OriginationStage): number {
return STAGE_ORDER.indexOf(stage);
}
@@ -180,7 +195,54 @@ export interface StageHistoryEntry {
remarks: string;
}
+/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
+export interface DepositSelection {
+ accountIds: number[];
+ accountNos: string[];
+ securityValue: number;
+ limitPct: number;
+ eligibleAmount: number;
+ depositRate: number;
+ spread: number;
+ maxTenure: number;
+ over85: boolean;
+ over85Reason: string;
+ thirdParty: boolean;
+ /** The product requires a lien on the deposit (a product can switch this off). */
+ lienRequired: boolean;
+ saved: boolean;
+}
+
+/** Lien noted on the pledged deposit(s) for this application. */
+export interface LienRecord {
+ marked: boolean;
+ certificateNo: string;
+ markedOn: string;
+ amount: number;
+ accountNos: string[];
+}
+
+/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
+export interface ApprovalRequest {
+ status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN';
+ submittedBy: string;
+ submittedOn: string;
+ requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
+ remarks: string;
+ resolutionNo?: string;
+ resolutionDate?: string;
+ /** What the sender had verified at the moment of sending -- what the approver reviews. */
+ checklist?: { label: string; ok: boolean }[];
+ /** What the approver ticked as checked herself/himself before approving. */
+ checkerChecklist?: { label: string; ok: boolean }[];
+ decidedBy?: string;
+ decidedOn?: string;
+}
+
export interface LoanApplication {
+ approvalRequests?: Partial>;
+ depositSelection?: DepositSelection;
+ lien?: LienRecord;
/** Database row id, assigned on save. Uploaded files hang off this (documents are stored against
* the application's entity id), so it's absent only on an application not yet persisted. */
id?: number;
@@ -451,7 +513,47 @@ interface OriginationProduct {
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
shareLinkagePercent: number;
+ /** Loan against a deposit: the share of the deposit value that can be lent (policy 9.5.5: 85%). */
+ ltvPct?: number;
+ /** Loan against a deposit: percentage points added to the deposit's own rate. */
+ spread?: number;
+ /** Loan against a deposit: when the lien is marked, as set on the product ("disbursement" or "sanction"). */
+ lienStage?: string;
+ lienRequired?: boolean;
+ /** What the eligible amount is calculated on: "balance_accrued" (balance + accrued interest) or "balance". */
+ eligibleBasis?: string;
+ /** "deposit_spread" = the deposit's own rate plus the spread; anything else uses the product's own rate. */
+ interestBasis?: string;
+ /** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
+ baseRate?: number;
+ allowMultipleDeposits?: boolean;
+ allowThirdParty?: boolean;
+ /** The loan tenure may not run past the deposit's maturity. */
+ tenureWithinMaturity?: boolean;
+ certificateRequired?: boolean;
+ certificatePrefix?: string;
+ /** Terms shown on the loan plan of a deposit loan. */
+ penalRate?: number;
+ penalApplicable?: boolean;
+ repaymentType?: string;
+ interestPostingFrequency?: string;
+ facilityType?: string;
+ preclosure?: string;
+ renewalAllowed?: boolean;
}
+/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
+ * proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
+export const DEPOSIT_PRODUCT_CODES = [
+ 'FD',
+ 'RD',
+ 'DS',
+ 'LI'
+];
+
+export function isDepositLoanCode(productCode: string | undefined | null): boolean {
+ return !!productCode && DEPOSIT_PRODUCT_CODES.includes(productCode);
+}
+
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
{
code: 'PL',
@@ -586,6 +688,53 @@ interface ProductDocumentTemplate {
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
* borrower's own identity/income proof. */
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record = {
+ FD: [
+ {
+ key: 'fdReceipt',
+ label: 'FD receipt — original, discharged on the back',
+ hint: 'Signed by the depositor'
+ },
+ { key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
+ {
+ key: 'letterOfContinuity',
+ label: 'Letter of continuity',
+ hint: 'Liability and security continue until the loan is repaid'
+ }
+ ],
+ RD: [
+ { key: 'rdPassbook', label: 'RD passbook', hint: 'Kept by the bank' },
+ {
+ key: 'adjustDeclaration',
+ label: 'Declaration: bank may adjust the loan on RD maturity',
+ hint: 'Bank may adjust the loan from the deposit on maturity'
+ },
+ { key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
+ {
+ key: 'letterOfContinuity',
+ label: 'Letter of continuity',
+ hint: 'Liability and security continue until the loan is repaid'
+ }
+ ],
+ DS: [
+ { key: 'ddsPassbook', label: 'DDS passbook', hint: 'Kept by the bank' },
+ {
+ key: 'adjustDeclaration',
+ label: 'Declaration: bank may adjust the loan on DDS maturity',
+ hint: 'Bank may adjust the loan from the deposit on maturity'
+ },
+ { key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
+ {
+ key: 'letterOfContinuity',
+ label: 'Letter of continuity',
+ hint: 'Liability and security continue until the loan is repaid'
+ }
+ ],
+ LI: [
+ { key: 'licPolicyBond', label: 'Original LIC policy bond', hint: 'Kept by the bank' },
+ { key: 'licAssignment', label: 'Assignment deed in favour of the bank', hint: 'Registered with the LIC branch' },
+ { key: 'licSurrenderLetter', label: 'LIC surrender value letter', hint: 'Not older than 1 month' },
+ { key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' }
+ ],
HL: [
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
@@ -636,15 +785,75 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
}
+/**
+ * Makes a product saved in Product Creation usable by the application screens: the detail page, the eligibility
+ * check and the Fineract bridge all look a product up by its code in ORIGINATION_PRODUCTS, so a saved product is
+ * registered there under its Short Name (replacing an earlier registration of the same code).
+ */
+export function registerOriginationProduct(p: any): OriginationProduct {
+ const ext = p?.advanceExtensions ?? {};
+ const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
+ const rate = Number(p?.interestRatePerPeriod ?? 0);
+ const entry: OriginationProduct = {
+ code: String(p.shortName),
+ name: String(p.name).replace(/\s+/g, ' ').trim(),
+ secured: !isDepositLoanCode(String(p.shortName)) && (ext.productTypeGroup === 'advances' || !!ext.securityType),
+ minAmt: Number(p?.minPrincipal ?? p?.principal ?? 0),
+ maxAmt: Number(p?.maxPrincipal ?? p?.principal ?? 0),
+ minRate: Number(p?.minInterestRatePerPeriod ?? rate),
+ maxRate: Number(p?.maxInterestRatePerPeriod ?? rate),
+ minTenure: Number(inMonths ? (p?.minNumberOfRepayments ?? p?.numberOfRepayments ?? 1) : 1),
+ maxTenure: Number(inMonths ? (p?.maxNumberOfRepayments ?? p?.numberOfRepayments ?? 360) : 360),
+ incomeMultiplier: 20,
+ shareLinkagePercent: 5,
+ ltvPct: Number(ext.ltv ?? 85),
+ spread: Number(ext.spread ?? 0),
+ lienStage: String(ext.lienMarkingStage ?? 'disbursement'),
+ lienRequired: ext.lienRequired !== false,
+ eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
+ interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
+ baseRate: rate,
+ allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
+ allowThirdParty: ext.lienAllowThirdParty === true,
+ tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
+ certificateRequired: ext.lienCertificateRequired !== false && ext.lienCertificateRequired !== 'NO',
+ certificatePrefix: ext.lienCertificatePrefix ? String(ext.lienCertificatePrefix) : undefined,
+ penalRate: Number(ext.penalRate ?? 0),
+ penalApplicable: ext.penalApplicable === true,
+ repaymentType: String(ext.repaymentType ?? 'bullet'),
+ interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
+ facilityType: String(ext.facilityType ?? 'demand'),
+ preclosure: String(ext.preclosureAllowed ?? 'anytime'),
+ renewalAllowed: ext.renewalAllowed === true
+ };
+ const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
+ if (at >= 0) {
+ ORIGINATION_PRODUCTS[at] = entry;
+ } else {
+ ORIGINATION_PRODUCTS.push(entry);
+ }
+ return entry;
+}
+
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
* as the product-specific documents above, since a specific matter can always need one more
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
-export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
+export function buildLegalDocuments(secured: boolean, depositLoan = false): ProductDocumentEntry[] {
+ if (depositLoan) {
+ // The DP Note, letter of continuity, deposit receipt and letter of lien were collected earlier (KYC & Documents and
+ // Lien Marking); only what is new at the legal stage is asked here. No mortgage, title or legal opinion: the security is a deposit.
+ return [
+ {
+ key: 'stampDutyReceipt',
+ label: 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)'
+ }
+ ].map((doc) => ({ ...doc, custom: false, submitted: false }));
+ }
const templates: ProductDocumentTemplate[] = [
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
- { key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
+ { key: 'feeReceipt', label: 'Processing Fee Receipt' }
];
if (secured) {
templates.push(
@@ -1839,7 +2048,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
const actionBy =
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
return {
- applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`,
+ applicationNo: `LN${input.branchCode.replace(/^(OFF|BR)/, '').padStart(2, '0')}${input.productCode}${String(seq).padStart(6, '0')}`,
applicationDate: todayDDMMYYYY(),
applicantName: input.applicantName,
branchCode: input.branchCode,
@@ -1932,7 +2141,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
legalStampDone: false,
fileFilingDone: false,
agreementSigningSlot: '—',
- legalDocuments: buildLegalDocuments(input.secured),
+ legalDocuments: buildLegalDocuments(input.secured, isDepositLoanCode(input.productCode)),
customerSigningDate: '—',
chequeReceived: false,
achSigned: false,
diff --git a/src/app/loan-origination/loan-origination-shared.scss b/src/app/loan-origination/loan-origination-shared.scss
index e4a3af9de..a592aabd8 100644
--- a/src/app/loan-origination/loan-origination-shared.scss
+++ b/src/app/loan-origination/loan-origination-shared.scss
@@ -865,6 +865,16 @@
}
}
+.loi-legal-doc-thumb-remove.loi-bank-remove {
+ height: 34px;
+ padding: 0 14px;
+ border: 1px solid #fecaca;
+ border-radius: 999px;
+ background: #fef2f2;
+ color: #dc2626;
+ font-weight: 700;
+}
+
// CIC Credit Bureau Hero Card in Stage detail
.loi-cic-hero-card {
display: flex;
@@ -978,3 +988,211 @@
font-weight: 600;
}
}
+
+.loi-optional-tag {
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: 0;
+ color: #0369a1;
+}
+
+.loi-deposit-note {
+ margin-bottom: 14px;
+ padding: 10px 14px;
+ border: 1px solid #bae6fd;
+ border-radius: 8px;
+ background: #f0f9ff;
+ color: #075985;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.loi-dep-wrap {
+ overflow-x: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fff;
+}
+
+.loi-dep-table {
+ width: 100%;
+ border-collapse: collapse;
+
+ th {
+ padding: 10px 12px;
+ text-align: left;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #64748b;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 10px 12px;
+ font-size: 13.5px;
+ border-bottom: 1px solid #f1f5f9;
+ }
+
+ .r {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+
+ tr.loi-dep-selected td {
+ background: #eef2ff;
+ }
+
+ tr.loi-dep-disabled td {
+ color: #94a3b8;
+ }
+}
+
+.loi-dep-calc {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px 16px;
+ margin: 12px 0;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fff;
+}
+
+.loi-dep-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13.5px;
+ color: #334155;
+
+ b {
+ font-variant-numeric: tabular-nums;
+ color: #0f172a;
+ }
+}
+
+.loi-dep-total {
+ padding-top: 10px;
+ border-top: 1px dashed #cbd5e1;
+ font-size: 15px;
+
+ b {
+ color: #15803d;
+ font-size: 16px;
+ }
+}
+
+.loi-dep-fields {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 10px;
+
+ @media (width <= 700px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.loi-deposit-ok {
+ margin: 6px 0;
+ padding: 10px 14px;
+ border: 1px solid #bbf7d0;
+ border-radius: 8px;
+ background: #f0fdf4;
+ color: #15803d;
+ font-size: 13px;
+}
+
+.loi-lien-cert {
+ margin: 12px 0;
+ padding: 14px 18px;
+ border: 2px dashed #94a3b8;
+ border-radius: 10px;
+ background: #fffef7;
+ font-size: 13px;
+ line-height: 1.6;
+ color: #334155;
+}
+
+.pg-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.loi-approval-wait {
+ margin: 10px 0;
+ padding: 12px 14px;
+ border: 1px solid #fde68a;
+ border-radius: 8px;
+ background: #fffbeb;
+ color: #92400e;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.loi-verified {
+ margin: 8px 0 12px;
+ padding: 12px 14px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #fff;
+ font-size: 13px;
+ color: #334155;
+
+ ul {
+ margin: 6px 0 0;
+ padding-left: 0;
+ list-style: none;
+ }
+
+ li {
+ padding: 2px 0;
+ color: #15803d;
+ }
+
+ .loi-verified--no {
+ color: #b91c1c;
+ }
+}
+
+.loi-locked {
+ pointer-events: none;
+ opacity: 0.75;
+}
+
+.loi-checker {
+ margin: 8px 0 12px;
+ padding: 12px 14px;
+ border: 1px solid #c7d2fe;
+ border-radius: 8px;
+ background: #eef2ff;
+ font-size: 13px;
+ color: #1e1b4b;
+}
+
+/* A stage sent for approval: files can still be opened (View), nothing can be added, replaced or removed. */
+.loi-docs-locked {
+ label.pg-btn,
+ .loi-legal-doc-thumb-remove,
+ .loi-doc-remove,
+ .loi-custom-doc-input,
+ .loan-action-row {
+ display: none !important;
+ }
+}
+
+.loi-view-link {
+ margin-left: 8px;
+ padding: 2px 10px;
+ border: 1px solid #c7d2fe;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #3730a3;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
diff --git a/src/app/loan-origination/loan-origination.service.ts b/src/app/loan-origination/loan-origination.service.ts
index cf1317b68..70025c494 100644
--- a/src/app/loan-origination/loan-origination.service.ts
+++ b/src/app/loan-origination/loan-origination.service.ts
@@ -9,7 +9,13 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
-import { LoanApplication } from './loan-origination-data';
+import { map } from 'rxjs/operators';
+import { BranchMaster, LoanApplication } from './loan-origination-data';
+
+/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
+export function officeBranchCode(officeId: number | string): string {
+ return `OFF${officeId}`;
+}
/**
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
@@ -29,6 +35,23 @@ export class LoanOriginationService {
private readonly baseUrl = '/loan-origination/applications';
+ /** One fixed or recurring deposit account with its balance, rate and maturity. */
+ getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable {
+ return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
+ }
+
+ /** The bank's real branches: every office of the organisation, in hierarchy order. */
+ listBranches(): Observable {
+ return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
+ map((offices) =>
+ (offices ?? [])
+ .slice()
+ .sort((a, b) => (a.hierarchy ?? '').localeCompare(b.hierarchy ?? '') || a.id - b.id)
+ .map((office) => ({ code: officeBranchCode(office.id), name: office.name }))
+ )
+ );
+ }
+
list(): Observable {
return this.http.get(this.baseUrl);
}
@@ -67,6 +90,10 @@ export class LoanOriginationService {
return this.http.post(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
}
+ completeDepositSelection(applicationNo: string, actionBy: string, remarks?: string): Observable {
+ return this.stage(applicationNo, 'deposit-selection-complete', { actionBy, remarks });
+ }
+
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable {
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
}
@@ -124,11 +151,31 @@ export class LoanOriginationService {
remarks?: string;
resolutionNo?: string;
resolutionDate?: string;
+ checkerChecklist?: { label: string; ok: boolean }[];
}
): Observable {
return this.stage(applicationNo, `approvals/${field}`, body);
}
+ submitApproval(
+ applicationNo: string,
+ field: string,
+ body: {
+ decision: 'APPROVED' | 'EXCEPTION_APPROVED';
+ approverRole: string;
+ remarks?: string;
+ resolutionNo?: string;
+ resolutionDate?: string;
+ checklist?: { label: string; ok: boolean }[];
+ }
+ ): Observable {
+ return this.stage(applicationNo, `approvals/${field}/submit`, body);
+ }
+
+ withdrawApproval(applicationNo: string, field: string): Observable {
+ return this.stage(applicationNo, `approvals/${field}/withdraw`, {});
+ }
+
markSigned(applicationNo: string, actionBy: string): Observable {
return this.stage(applicationNo, 'signing', { actionBy });
}
diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html
index 44b124f24..3388d2c55 100644
--- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html
+++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html
@@ -171,6 +171,51 @@
Applications ({{ filteredApplications.length }})
@@ -304,18 +349,37 @@
+
@@ -325,7 +389,12 @@
diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
index 100103041..4316f9cdb 100644
--- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
+++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
@@ -13,9 +13,10 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClientsService } from '../../clients/clients.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
-import { LoanOriginationService } from '../loan-origination.service';
+import { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
+import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
+import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
import {
- BRANCHES,
BranchMaster,
branchTargetAmount,
Channel,
@@ -25,10 +26,10 @@ import {
generateSeedApplications,
LoanApplication,
ORIGINATION_PRODUCTS,
+ registerOriginationProduct,
OriginationStage,
OverallStatus,
PRODUCT_TARGET_PCT,
- PRODUCT_TYPE_LABELS,
ProductType,
SHARE_LINKAGE_SLABS,
STAGE_LABELS,
@@ -36,9 +37,7 @@ import {
STATUS_LABELS,
TARGET_STATUS_LABELS,
TargetStatus,
- allowedProductTypes,
createApplication,
- defaultProductType,
progressPct,
stageBadgeClass,
targetStatus,
@@ -124,7 +123,12 @@ export class LoanPipelineComponent implements OnInit {
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly channelLabels = CHANNEL_LABELS;
- readonly branches = BRANCHES;
+ /** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
+ branches: BranchMaster[] = [];
+ /** Active products saved in Product Creation -- the only products an application can be taken for. */
+ dbProducts: any[] = [];
+ loadingProducts = true;
+ readonly productGroups = PRODUCT_TYPE_GROUPS;
readonly products = ORIGINATION_PRODUCTS;
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
asOnDate = todayDDMMYYYY();
@@ -152,26 +156,63 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW' as CustomerType,
applicantName: '',
clientId: null as number | null,
- branchCode: this.branches[0].code,
- productCode: this.products[0].code,
- productType: defaultProductType(this.products[0].code),
+ branchCode: '',
+ productGroup: '',
+ productSubType: '',
+ productCode: '',
+ productType: 'TERM_LOAN' as ProductType,
requestedAmount: null as number | null,
tenureMonths: null as number | null
};
- readonly productTypeLabels = PRODUCT_TYPE_LABELS;
-
- /** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
- * are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
- get newAppProductTypeOptions(): ProductType[] {
- return allowedProductTypes(this.newApp.productCode);
+ /** Sub types of the chosen Product Type that actually have an active product saved. */
+ get subTypeOptions() {
+ const group = this.newApp.productGroup;
+ return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
+ this.dbProducts.some(
+ (p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
+ )
+ );
}
- /** Switching Product can change which Product Types are even valid -- reset to that product's
- * default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
- * on a product that doesn't offer it. */
+ /** The products saved in the database for the chosen Product Type and Sub Type. */
+ get productOptions(): any[] {
+ return this.dbProducts.filter(
+ (p) =>
+ p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
+ p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
+ );
+ }
+
+ /** Only the tenures the chosen product allows (its min to max, in months). */
+ get tenureOptions(): number[] {
+ const p = this.selectedProduct;
+ if (!p) {
+ return [];
+ }
+ return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
+ }
+
+ get selectedProduct() {
+ return this.products.find((p) => p.code === this.newApp.productCode) || null;
+ }
+
+ onProductGroupChange(): void {
+ this.newApp.productSubType = '';
+ this.newApp.productCode = '';
+ }
+
+ onSubTypeChange(): void {
+ const options = this.productOptions;
+ this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
+ this.onProductChange();
+ }
+
+ /** Advances have no EMI (revolving limit); Loans are EMI term loans. */
onProductChange(): void {
- this.newApp.productType = defaultProductType(this.newApp.productCode);
+ this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
+ const product = this.selectedProduct;
+ this.newApp.tenureMonths = product ? product.minTenure : null;
}
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
@@ -189,6 +230,7 @@ export class LoanPipelineComponent implements OnInit {
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly originationService = inject(LoanOriginationService);
+ private readonly catalog = inject(LoanProductCatalogService);
/** The live book, loaded from the database. Every count, report and filter on this screen derives
* from this one array, so the list can never disagree with what an application actually says. */
@@ -221,17 +263,27 @@ export class LoanPipelineComponent implements OnInit {
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
- if (this.isHeadOffice) {
- this.branchFilter = '';
- } else {
- this.ownBranchCode =
- this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
- ?.code || null;
- this.branchFilter = this.ownBranchCode || this.branches[0].code;
- // A branch-level login can only originate applications for its own branch — locked here,
- // not just hidden, since the New Application form posts whatever branchCode is bound.
- this.newApp.branchCode = this.branchFilter;
- }
+ this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
+ // The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
+ // the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
+ this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
+ this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
+ this.newApp.branchCode = this.ownBranchCode || '';
+ this.catalog.activeProducts().subscribe((products) => {
+ this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
+ this.dbProducts.forEach((p) => registerOriginationProduct(p));
+ this.loadingProducts = false;
+ });
+ this.originationService.listBranches().subscribe({
+ next: (offices) => {
+ if (offices.length > 0) {
+ this.branches = offices;
+ }
+ },
+ error: () => {
+ /* keep the user's own office as the only choice */
+ }
+ });
this.loadApplications();
}
@@ -310,6 +362,42 @@ export class LoanPipelineComponent implements OnInit {
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
}
+ private get currentUserName(): string {
+ return (this.auth.getCredentials()?.username ?? '').toLowerCase();
+ }
+
+ /** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
+ get awaitingApproval() {
+ const rows: {
+ app: LoanApplication;
+ stage: string;
+ sentBy: string;
+ sentOn: string;
+ mine: boolean;
+ exception: boolean;
+ }[] = [];
+ this.applications.forEach((app) => {
+ Object.entries(app.approvalRequests ?? {}).forEach(
+ ([
+ ,
+ request
+ ]) => {
+ if (request && request.status === 'PENDING') {
+ rows.push({
+ app,
+ stage: STAGE_LABELS[app.currentStage],
+ sentBy: request.submittedBy,
+ sentOn: request.submittedOn,
+ mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
+ exception: request.requestedDecision === 'EXCEPTION_APPROVED'
+ });
+ }
+ }
+ );
+ });
+ return rows;
+ }
+
get filteredApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => {
if (this.channelFilter && a.channel !== this.channelFilter) {
@@ -543,7 +631,7 @@ export class LoanPipelineComponent implements OnInit {
// -------------------- New Application intake --------------------
productOf(code: string) {
- return this.products.find((p) => p.code === code) || this.products[0];
+ return this.products.find((p) => p.code === code);
}
/** Switching Customer Type away from "Existing" drops any client picked under the old mode --
@@ -608,8 +696,27 @@ export class LoanPipelineComponent implements OnInit {
if (!this.isHeadOffice && this.ownBranchCode) {
this.newApp.branchCode = this.ownBranchCode;
}
- const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
+ const branch = this.branches.find((b) => b.code === this.newApp.branchCode) ||
+ this.branches[0] || { code: this.newApp.branchCode, name: this.currentOfficeName };
const product = this.productOf(this.newApp.productCode);
+ if (!product) {
+ this.toast('Select the Product Type, Sub Type and Product.');
+ return;
+ }
+ if (
+ this.newApp.requestedAmount &&
+ (this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
+ ) {
+ this.toast(
+ `Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
+ );
+ return;
+ }
+ const tenure = this.newApp.tenureMonths || product.minTenure;
+ if (tenure < product.minTenure || tenure > product.maxTenure) {
+ this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
+ return;
+ }
const seq = this.applications.length + 20000;
const app = createApplication(
{
@@ -644,9 +751,11 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW',
applicantName: '',
clientId: null,
- branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
- productCode: this.products[0].code,
- productType: defaultProductType(this.products[0].code),
+ branchCode: this.ownBranchCode || this.branches[0]?.code || '',
+ productGroup: '',
+ productSubType: '',
+ productCode: '',
+ productType: 'TERM_LOAN',
requestedAmount: null,
tenureMonths: null
};
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html
index 362964072..81e89d70f 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html
@@ -90,7 +90,7 @@
@if (tenureBand; as band) {
{{ band.label }} policy: max {{ band.max }} months
- @if (band.label === 'FD') {
+ @if (band.depositBacked) {
(also capped at deposit maturity)
}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts
index c3ed357e1..4ba044ffc 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts
@@ -111,14 +111,27 @@ export class LoanProductAdvanceLimitTenureStepComponent {
}
/** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */
- private readonly tenureBands: Record
= {
- fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12 },
+ private readonly tenureBands: Record<
+ string,
+ { label: string; min: number; max: number; defaultTenure: number; depositBacked?: boolean }
+ > = {
+ // A loan against a deposit or policy is a short-term advance: 12 to 15 months for all four, and never past maturity.
+ fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
+ rd: { label: 'RD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
+ dds: { label: 'DDS', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
+ lic: { label: 'LIC', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 }
};
/** The policy band in force for the chosen sub type, or null when the sub type is open. */
- get tenureBand(): { label: string; min: number; max: number; defaultTenure: number } | null {
+ get tenureBand(): {
+ label: string;
+ min: number;
+ max: number;
+ defaultTenure: number;
+ depositBacked?: boolean;
+ } | null {
return this.tenureBands[this._productSubType] ?? null;
}
@@ -131,7 +144,7 @@ export class LoanProductAdvanceLimitTenureStepComponent {
}
/**
- * FD (12–15 months) and Gold (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
+ * FD, RD, DDS and LIC (12–15 months), Gold and Warehouse (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product.
*/
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index e1c0fccd9..1daf18e27 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
- 'version': '261013',
- 'hash': 'd88f2a6eb'
+ 'version': '261022',
+ 'hash': 'ec550b179'
},
'allow_switching_backend_instance': true
};