feat(loan-origination): purpose and priority sector at KYC, simpler deposit-loan sanction, account wizard from LOS

- Purpose & Priority Sector section in KYC (priority sector Yes/No against the product's setting with a reason when
  they differ, category from the PrioritySectorCategory Code, weaker section and its type, supporting documents per
  category); Complete KYC and the sanction need it.
- Deposit loans: Deposit Selection in two steps (deposit, then lien letter); no checkbox lists at sanction; the
  approver approves without ticking rows; sanction letter released on approval, with the sanctioning authority's name
  and date; Personal Discussion plan lists what was collected and the product terms; income, EMI and co-borrower
  hidden at KYC; loan-specific documents can always be added.
- Loan account wizard takes purpose, priority sector, weaker section from the LOS application; LOS applications can be
  picked from a client's view; Limit & Security, Guarantors and SMS steps for advances.
- FP-style deposit products are recognised as deposit loans once the product catalog is registered.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 15:30:34 +05:30
co-authored by Claude Sonnet 5.5
parent 9bde697925
commit ef1efb78e3
42 changed files with 16318 additions and 2432 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,664 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as pdfjsLib from 'pdfjs-dist';
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
/** One block of the form. The screen works out the rows; this file only draws them. */
export type LoanFormSection =
| { title: string; type: 'kv'; rows: [string, string][] }
| { title: string; type: 'compare'; rows: [string, string, string][]; jointName: string }
| { title: string; type: 'table'; head: string[]; rows: string[][]; note?: string; minRows?: number }
| { title: string; type: 'text'; text: string };
export interface LoanApplicationData {
bankName: string;
branchName: string;
applicationNo: string;
applicationDate: string;
generatedOn: string;
amount: string;
amountInWords: string;
scheme: string;
purpose: string;
/** The applicant's photograph as a data URL, when the client record has one. */
photo: string | null;
jointPhoto: boolean;
form: LoanFormSection[];
declaration: string;
/** What the bank fills in. */
bankUse: [string, string][];
/** After the form: what the sanctioning authority needs to see. */
annexure: LoanFormSection[];
}
/** A file that goes into the pack after the form: the customer's eKYC documents, the lien documents and the rest. */
export interface LoanApplicationAttachment {
group: string;
label: string;
fileName: string;
blob: Blob;
}
const MARGIN = 12;
const NAVY: [number, number, number] = [
15,
23,
42
];
const BLUE: [number, number, number] = [
30,
64,
175
];
const SOFT: [number, number, number] = [
241,
245,
249
];
const LINE: [number, number, number] = [
203,
213,
225
];
const MUTED: [number, number, number] = [
100,
116,
139
];
/** A long PDF would make the pack unmanageable: its first pages go in, and the pack says so. */
const MAX_PDF_PAGES = 12;
type Doc = jsPDF & { lastAutoTable?: { finalY: number } };
const finalY = (doc: Doc): number => doc.lastAutoTable?.finalY ?? 0;
// ───────────────────────────── helpers ─────────────────────────────
/** 125000 -> "One Lakh Twenty Five Thousand" (Indian grouping). */
export function amountInWords(value: number): string {
const ones = [
'',
'One',
'Two',
'Three',
'Four',
'Five',
'Six',
'Seven',
'Eight',
'Nine',
'Ten',
'Eleven',
'Twelve',
'Thirteen',
'Fourteen',
'Fifteen',
'Sixteen',
'Seventeen',
'Eighteen',
'Nineteen'
];
const tens = [
'',
'',
'Twenty',
'Thirty',
'Forty',
'Fifty',
'Sixty',
'Seventy',
'Eighty',
'Ninety'
];
const below100 = (n: number): string =>
n < 20 ? ones[n] : tens[Math.floor(n / 10)] + (n % 10 ? ' ' + ones[n % 10] : '');
const below1000 = (n: number): string =>
(n >= 100 ? ones[Math.floor(n / 100)] + ' Hundred' + (n % 100 ? ' ' : '') : '') +
(n % 100 ? below100(n % 100) : '');
let n = Math.floor(Math.abs(value));
if (n === 0) {
return 'Zero';
}
const parts: string[] = [];
const crore = Math.floor(n / 10000000);
n %= 10000000;
const lakh = Math.floor(n / 100000);
n %= 100000;
const thousand = Math.floor(n / 1000);
n %= 1000;
if (crore) parts.push(below1000(crore) + ' Crore');
if (lakh) parts.push(below100(lakh) + ' Lakh');
if (thousand) parts.push(below100(thousand) + ' Thousand');
if (n) parts.push(below1000(n));
return parts.join(' ');
}
function readAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image'));
img.src = src;
});
}
/** A numbered bar that opens each block of the form. */
function sectionBar(doc: Doc, number: number, title: string, y: number): number {
const pageWidth = doc.internal.pageSize.getWidth();
doc.setFillColor(...SOFT);
doc.roundedRect(MARGIN, y, pageWidth - MARGIN * 2, 7.5, 1.2, 1.2, 'F');
doc.setFillColor(...BLUE);
doc.roundedRect(MARGIN, y, 7.5, 7.5, 1.2, 1.2, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.text(String(number), MARGIN + 3.75, y + 5.2, { align: 'center' });
doc.setTextColor(...NAVY);
doc.setFontSize(9.5);
doc.text(title.toUpperCase(), MARGIN + 11, y + 5.2);
return y + 10.5;
}
/** Starts a new page when what comes next would not fit. */
function room(doc: Doc, y: number, needed: number): number {
if (y + needed > doc.internal.pageSize.getHeight() - 14) {
doc.addPage();
return 16;
}
return y;
}
const baseTable = {
theme: 'grid' as const,
styles: {
fontSize: 8.6,
cellPadding: { top: 2, bottom: 2, left: 2.4, right: 2.4 },
lineColor: LINE,
lineWidth: 0.2,
textColor: NAVY,
valign: 'middle' as const
},
headStyles: { fillColor: NAVY, textColor: [
255,
255,
255
] as [number, number, number], fontStyle: 'bold' as const, halign: 'left' as const },
alternateRowStyles: { fillColor: [
250,
251,
253
] as [number, number, number] },
margin: { left: MARGIN, right: MARGIN }
};
/** About how tall a block will be, so a block that fits on a page is not split across two. */
function estimatedHeight(s: LoanFormSection): number {
const rowH = 9;
switch (s.type) {
case 'kv':
return 12 + s.rows.length * rowH;
case 'compare':
return 14 + s.rows.length * rowH + 14;
case 'table':
return 14 + Math.max(s.rows.length, s.minRows ?? 1, 1) * rowH;
default:
return 30;
}
}
function drawSection(doc: Doc, n: number, s: LoanFormSection, startY: number): number {
// a block that fits on one page starts on a new page rather than being cut in two
const usable = doc.internal.pageSize.getHeight() - 30;
const needed = Math.min(estimatedHeight(s), usable);
let y = room(doc, startY, needed);
y = sectionBar(doc, n, s.title, y);
if (s.type === 'kv') {
autoTable(doc, {
...baseTable,
startY: y,
body: s.rows.map(
([
k,
v
]) => [
k,
v
]
),
columnStyles: { 0: { cellWidth: 62, fontStyle: 'bold', fillColor: SOFT, textColor: [
51,
65,
85
] } },
bodyStyles: { minCellHeight: 7 }
});
} else if (s.type === 'compare') {
autoTable(doc, {
...baseTable,
startY: y,
head: [[
'Particulars',
'Applicant',
s.jointName || 'Joint applicant'
]],
body: s.rows.map((r) => [
r[0],
r[1],
r[2]
]),
columnStyles: {
0: { cellWidth: 46, fontStyle: 'bold', fillColor: SOFT, textColor: [
51,
65,
85
] },
1: { cellWidth: 70 }
},
bodyStyles: { minCellHeight: 7 }
});
} else if (s.type === 'table') {
const rows = s.rows.length ? s.rows : [];
const padded = [...rows];
while (padded.length < (s.minRows ?? 0)) {
padded.push(s.head.map(() => ''));
}
if (!padded.length) {
padded.push([
s.note ?? 'Nil',
...s.head.slice(1).map(() => '')
]);
}
autoTable(doc, { ...baseTable, startY: y, head: [s.head], body: padded, bodyStyles: { minCellHeight: 7 } });
} else {
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.6);
doc.setTextColor(...NAVY);
const lines = doc.splitTextToSize(s.text, doc.internal.pageSize.getWidth() - MARGIN * 2);
doc.text(lines, MARGIN, y + 3);
return y + 3 + lines.length * 4 + 4;
}
return finalY(doc) + 6;
}
// ───────────────────────────── attachments ─────────────────────────────
function attachmentHeader(doc: Doc, index: number, total: number, a: LoanApplicationAttachment, note: string): number {
const pageWidth = doc.internal.pageSize.getWidth();
doc.setFillColor(...SOFT);
doc.rect(0, 0, pageWidth, 16, 'F');
doc.setFillColor(...BLUE);
doc.rect(0, 0, 3, 16, 'F');
doc.setTextColor(...NAVY);
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(`Attachment ${index} of ${total} — ${a.label}`, MARGIN, 7);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(...MUTED);
doc.text(`${a.group} · ${a.fileName}${note ? ' · ' + note : ''}`, MARGIN, 12.5);
doc.setTextColor(0, 0, 0);
return 20;
}
function placePicture(doc: Doc, dataUrl: string, format: 'JPEG' | 'PNG', w: number, h: number, top: number): void {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const maxW = pageWidth - MARGIN * 2;
const maxH = pageHeight - top - 12;
const ratio = Math.min(maxW / w, maxH / h);
const width = w * ratio;
const height = h * ratio;
doc.addImage(dataUrl, format, (pageWidth - width) / 2, top, width, height);
}
async function appendImage(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
const dataUrl = await readAsDataUrl(a.blob);
const img = await loadImage(dataUrl);
doc.addPage();
const top = attachmentHeader(doc, index, total, a, '');
placePicture(
doc,
dataUrl,
/png/i.test(a.blob.type) ? 'PNG' : 'JPEG',
img.naturalWidth || 1,
img.naturalHeight || 1,
top
);
}
async function appendPdf(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
const data = new Uint8Array(await a.blob.arrayBuffer());
const pdf = await (pdfjsLib as any).getDocument({ data }).promise;
const pages = Math.min(pdf.numPages, MAX_PDF_PAGES);
for (let p = 1; p <= pages; p++) {
const page = await pdf.getPage(p);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('canvas');
}
await page.render({ canvasContext: context, viewport }).promise;
doc.addPage();
const note =
pdf.numPages > 1
? `page ${p} of ${pdf.numPages}${pdf.numPages > pages && p === pages ? ' (first ' + pages + ' pages only)' : ''}`
: '';
const top = attachmentHeader(doc, index, total, a, note);
placePicture(doc, canvas.toDataURL('image/jpeg', 0.82), 'JPEG', canvas.width, canvas.height, top);
}
}
async function appendAttachment(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
try {
if (/pdf/i.test(a.blob.type) || /\.pdf$/i.test(a.fileName)) {
await appendPdf(doc, index, total, a);
} else if (/^image\//i.test(a.blob.type)) {
await appendImage(doc, index, total, a);
} else {
throw new Error('type');
}
} catch {
doc.addPage();
const top = attachmentHeader(doc, index, total, a, '');
doc.setFontSize(10);
doc.text(
'This file could not be placed in the pack (' +
(a.blob.type || 'unknown type') +
'). Open it from the application.',
MARGIN,
top + 6
);
}
}
// ───────────────────────────── the form ─────────────────────────────
function drawHeader(doc: Doc, d: LoanApplicationData): number {
const pageWidth = doc.internal.pageSize.getWidth();
// top band
doc.setFillColor(...NAVY);
doc.rect(0, 0, pageWidth, 34, 'F');
doc.setFillColor(...BLUE);
doc.rect(0, 34, pageWidth, 1.6, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(17);
doc.text(d.bankName.toUpperCase(), MARGIN, 14);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(186, 200, 230);
doc.text(d.branchName, MARGIN, 20);
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(14);
doc.text('LOAN APPLICATION FORM', MARGIN, 29);
// application number card
const cardW = 56;
doc.setFillColor(255, 255, 255);
doc.roundedRect(pageWidth - MARGIN - cardW, 7, cardW, 20, 2, 2, 'F');
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(7);
doc.text('APPLICATION NO.', pageWidth - MARGIN - cardW + 4, 13);
doc.setTextColor(...NAVY);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.text(d.applicationNo, pageWidth - MARGIN - cardW + 4, 19);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(7.5);
doc.text('Date: ' + d.applicationDate, pageWidth - MARGIN - cardW + 4, 24.5);
// addressee and photographs
let y = 43;
doc.setTextColor(...NAVY);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('PART I — Basic information common to all schemes', MARGIN, y);
y += 8;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9.5);
doc.text('To,', MARGIN, y);
doc.text('THE BRANCH MANAGER', MARGIN, y + 5.5);
doc.setFont('helvetica', 'normal');
doc.text(d.bankName + ' — ' + d.branchName, MARGIN, y + 11);
const boxW = 27;
const boxH = 33;
const boxTop = 38;
const rightX = pageWidth - MARGIN - boxW;
const leftX = rightX - boxW - 5;
const drawBox = (x: number, label: string, image: string | null) => {
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.roundedRect(x, boxTop, boxW, boxH, 1.5, 1.5);
if (image) {
try {
doc.addImage(image, 'JPEG', x + 0.8, boxTop + 0.8, boxW - 1.6, boxH - 1.6);
return;
} catch {
// an image that cannot be drawn leaves the box empty
}
}
doc.setTextColor(...MUTED);
doc.setFontSize(6.8);
doc.text('PHOTOGRAPH', x + boxW / 2, boxTop + 13, { align: 'center' });
doc.text('OF', x + boxW / 2, boxTop + 17, { align: 'center' });
doc.text(label, x + boxW / 2, boxTop + 21, { align: 'center' });
};
drawBox(leftX, 'APPLICANT', d.photo);
if (d.jointPhoto) {
drawBox(rightX, 'JOINT-APPLICANT', null);
} else {
drawBox(rightX, 'JOINT-APPLICANT', null);
}
// the request, in a panel
y = boxTop + boxH + 6;
const panelW = pageWidth - MARGIN * 2;
const text =
`I/We request for a loan of Rs. ${d.amount} (Rupees ${d.amountInWords} only) under ${d.scheme} Scheme` +
(d.purpose ? ` for the purpose of ${d.purpose}.` : '.') +
' I/We furnish our particulars as below.';
doc.setFont('helvetica', 'normal');
doc.setFontSize(9.2);
const lines = doc.splitTextToSize(text, panelW - 10);
const panelH = lines.length * 4.6 + 7;
doc.setFillColor(239, 246, 255);
doc.roundedRect(MARGIN, y, panelW, panelH, 1.8, 1.8, 'F');
doc.setFillColor(...BLUE);
doc.rect(MARGIN, y, 1.6, panelH, 'F');
doc.setTextColor(...NAVY);
doc.text(lines, MARGIN + 5, y + 6);
return y + panelH + 6;
}
function drawSignatures(doc: Doc, y: number, jointName: string): number {
const pageWidth = doc.internal.pageSize.getWidth();
y = room(doc, y, 36);
const colW = (pageWidth - MARGIN * 2 - 8) / 2;
const boxes: [string, string][] = [
[
'Signature of the Applicant',
''
],
[
'Signature of the Joint-Applicant',
jointName
]
];
boxes.forEach(
([
label,
name
], i) => {
const x = MARGIN + i * (colW + 8);
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.roundedRect(x, y, colW, 24, 1.5, 1.5);
doc.setDrawColor(...MUTED);
doc.line(x + 6, y + 17, x + colW - 6, y + 17);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(7.5);
doc.text(label, x + colW / 2, y + 21.5, { align: 'center' });
if (name) {
doc.setTextColor(...NAVY);
doc.setFontSize(8);
doc.text(name, x + colW / 2, y + 13, { align: 'center' });
}
}
);
doc.setTextColor(...NAVY);
doc.setFontSize(8.6);
doc.text('Place: ______________________', MARGIN, y + 30);
doc.text('Date: ______________________', MARGIN + colW + 8, y + 30);
return y + 36;
}
function drawBankUse(doc: Doc, y: number, rows: [string, string][]): number {
y = room(doc, y, 20 + rows.length * 7);
const pageWidth = doc.internal.pageSize.getWidth();
doc.setFillColor(...NAVY);
doc.roundedRect(MARGIN, y, pageWidth - MARGIN * 2, 7, 1.2, 1.2, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(8.8);
doc.text('FOR BANK USE ONLY', MARGIN + 4, y + 4.8);
autoTable(doc, {
...baseTable,
startY: y + 8,
body: rows,
columnStyles: { 0: { cellWidth: 62, fontStyle: 'bold', fillColor: SOFT, textColor: [
51,
65,
85
] } },
bodyStyles: { minCellHeight: 7 }
});
return finalY(doc) + 6;
}
/** The loan application form, then the annexure for the sanctioning authority, then every document attached. */
export async function buildLoanApplication(
d: LoanApplicationData,
attachments: LoanApplicationAttachment[]
): Promise<jsPDF> {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }) as Doc;
const pageWidth = doc.internal.pageSize.getWidth();
let y = drawHeader(doc, d);
let n = 1;
for (const section of d.form) {
y = drawSection(doc, n++, section, y);
}
// declaration
y = room(doc, y, 60);
y = sectionBar(doc, n++, 'Declaration', y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(7.8);
doc.setTextColor(51, 65, 85);
const declaration = doc.splitTextToSize(d.declaration, pageWidth - MARGIN * 2);
doc.text(declaration, MARGIN, y + 3);
y += 3 + declaration.length * 3.6 + 6;
const jointName = (d.form.find((s) => s.type === 'compare') as { jointName: string } | undefined)?.jointName ?? '';
y = drawSignatures(doc, y, jointName);
y = drawBankUse(doc, y, d.bankUse);
// annexure
if (d.annexure.length || attachments.length) {
doc.addPage();
y = 16;
doc.setFillColor(...NAVY);
doc.rect(0, 0, pageWidth, 12, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text('ANNEXURE — For the sanctioning authority', MARGIN, 8);
y = 20;
let a = 1;
for (const section of d.annexure) {
y = drawSection(doc, a++, section, y);
}
y = room(doc, y, 30);
y = sectionBar(doc, a++, `Documents attached (${attachments.length})`, y);
autoTable(doc, {
...baseTable,
startY: y,
head: [[
'#',
'Group',
'Document',
'File'
]],
body: attachments.length
? attachments.map((x, i) => [
String(i + 1),
x.group,
x.label,
x.fileName
])
: [[
'',
'No document is on file for this application.',
'',
''
]],
columnStyles: { 0: { cellWidth: 8 } },
styles: { ...baseTable.styles, fontSize: 8 }
});
}
for (let i = 0; i < attachments.length; i++) {
await appendAttachment(doc, i + 1, attachments.length, attachments[i]);
}
// footer on every page
const pages = doc.getNumberOfPages();
for (let p = 1; p <= pages; p++) {
doc.setPage(p);
doc.setDrawColor(...LINE);
doc.setLineWidth(0.2);
doc.line(
MARGIN,
doc.internal.pageSize.getHeight() - 10,
pageWidth - MARGIN,
doc.internal.pageSize.getHeight() - 10
);
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text(
`Loan Application ${d.applicationNo} · ${d.bankName} · generated ${d.generatedOn}`,
MARGIN,
doc.internal.pageSize.getHeight() - 6
);
doc.text(`Page ${p} of ${pages}`, pageWidth - MARGIN, doc.internal.pageSize.getHeight() - 6, { align: 'right' });
}
return doc;
}
@@ -68,6 +68,8 @@ export async function createRealLoanAccount(
interestCalculationPeriodType: template.interestCalculationPeriodType?.id,
amortizationType: template.amortizationType?.id,
transactionProcessingStrategyCode: template.transactionProcessingStrategyCode,
// the application number is the loan's external id, so the application can find (and show) this account
externalId: app.applicationNo,
submittedOnDate: today,
expectedDisbursementDate: today,
dateFormat,
@@ -85,7 +87,7 @@ export async function createRealLoanAccount(
})
);
await firstValueFrom(
const disbursed: any = await firstValueFrom(
loansService.loanActionButtons(loanId, 'disburse', {
actualDisbursementDate: today,
transactionAmount: app.requestedAmount,
@@ -93,6 +95,16 @@ export async function createRealLoanAccount(
locale
})
);
if (disbursed?.rollbackTransaction) {
// Disbursement needs a checker (maker-checker): nothing is disbursed until it is approved, so the application must not say so.
throw new Error(
'The disbursement of loan ' +
loanId +
' was sent for approval (request ' +
disbursed.commandId +
'). It happens when it is approved in the Approvals inbox.'
);
}
const account: any = await firstValueFrom(loansService.getLoanAccountResource(String(loanId), ''));
return {
@@ -100,3 +112,55 @@ export async function createRealLoanAccount(
loanAccountNo: account?.accountNo || String(loanId)
};
}
export interface CoreLienTarget {
/** The Fineract deposit account (fixed or recurring) the loan is secured on. */
savingsId: number;
accountNo: string;
/** How much of the loan this deposit secures. */
amount: number;
}
/**
* Puts a real lien on the pledged FD / RD accounts once the loan account exists.
*
* Fineract has no separate lien call; its own way to bind a deposit to a loan is a guarantor funded from that deposit:
* the amount is held on the account (it cannot be withdrawn or closed), an RD counts it as a lien, and it is released
* in step with the loan's repayments and in full when the loan is repaid. The customer guarantees their own loan with
* their own deposit, which Fineract allows when the guarantor is funded from a savings account.
*
* Returns the accounts it could not lien, with the reason, so the screen can say so rather than leaving the
* application showing a lien that does not exist.
*/
export async function placeCoreLien(
loansService: LoansService,
dateUtils: Dates,
settings: SettingsService,
loanId: number,
clientId: number,
targets: CoreLienTarget[]
): Promise<{ placed: CoreLienTarget[]; failed: { target: CoreLienTarget; reason: string }[] }> {
const placed: CoreLienTarget[] = [];
const failed: { target: CoreLienTarget; reason: string }[] = [];
for (const target of targets) {
try {
await firstValueFrom(
loansService.createNewGuarantor(String(loanId), {
guarantorTypeId: 1,
entityId: clientId,
savingsId: target.savingsId,
amount: target.amount,
locale: settings.language.code,
dateFormat: settings.dateFormat
})
);
placed.push(target);
} catch (err: any) {
failed.push({
target,
reason: err?.error?.errors?.[0]?.defaultUserMessage || err?.message || 'Could not place the lien.'
});
}
}
return { placed, failed };
}
@@ -77,14 +77,25 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
* 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.
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien), and no
// separate risk or legal approval: the one Sanction step (the committee stage) takes their place. Every other stage stays.
return STAGE_ORDER.filter(
(stage) => stage !== 'CIC_CHECK' && stage !== 'FIELD_INVESTIGATION' && stage !== 'CHEQUE_ACH'
(stage) =>
stage !== 'CIC_CHECK' &&
stage !== 'FIELD_INVESTIGATION' &&
stage !== 'CHEQUE_ACH' &&
stage !== 'RISK_APPROVAL' &&
stage !== 'LEGAL_APPROVAL'
);
}
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
}
/** What a stage is called for this product: on a loan against a deposit the committee stage is the single Sanction step. */
export function stageLabelFor(stage: OriginationStage, productCode: string | undefined | null): string {
return stage === 'CREDIT_COMMITTEE' && isDepositLoanCode(productCode) ? 'Sanction' : STAGE_LABELS[stage];
}
export function stageIndex(stage: OriginationStage): number {
return STAGE_ORDER.indexOf(stage);
}
@@ -151,13 +162,14 @@ export const DISCREPANCY_LABELS: Record<DiscrepancyType, string> = {
FAKE_DECLARATION: 'Fake Declaration Suspected'
};
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
export const APPROVAL_LABELS: Record<ApprovalDecision, string> = {
PENDING: 'Pending',
APPROVED: 'Approved',
REJECTED: 'Rejected',
EXCEPTION_APPROVED: 'Approved on Exception'
EXCEPTION_APPROVED: 'Approved on Exception',
REVOKED: 'Returned for Rectification'
};
export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED';
@@ -195,8 +207,34 @@ export interface StageHistoryEntry {
remarks: string;
}
/** A LIC policy pledged for a loan against LIC: not a bank account, so the officer records it from the policy documents. */
export interface LicPolicy {
policyNo: string;
insurer: string;
policyHolder: string;
sumAssured: number;
premiumPaid: number;
/** As per the insurer's surrender value letter (not older than a month). */
surrenderValue: number;
/** DD-MM-YYYY */
maturityDate: string;
/** The loan's interest rate, entered by the officer (a policy has no deposit rate to follow). */
loanRate: number;
}
/** One guarantor named on an application. */
export interface GuarantorEntry {
clientId: number;
name: string;
hasShareAccount: boolean;
/** The share accounts found for the guarantor when membership was checked. */
shareAccountNos?: string[];
}
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
export interface DepositSelection {
/** The LIC policy, for a loan against LIC (then `accountIds` is empty and `accountNos` holds the policy number). */
policy?: LicPolicy;
accountIds: number[];
accountNos: string[];
securityValue: number;
@@ -220,15 +258,24 @@ export interface LienRecord {
markedOn: string;
amount: number;
accountNos: string[];
/** Whether the lien really stands in core banking (funds held on the FD / RD, or the DDS account marked), as opposed to
* only being recorded here. */
coreStatus?: 'PLACED' | 'FAILED' | 'NOT_APPLICABLE';
/** What was done in core banking, or why it could not be. */
coreNote?: 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';
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN' | 'REVOKED';
submittedBy: string;
submittedOn: string;
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
remarks: string;
revokeRemarks?: string;
previousRevokeRemarks?: string;
previousRevokedBy?: string;
previousRevokedOn?: string;
resolutionNo?: string;
resolutionDate?: string;
/** What the sender had verified at the moment of sending -- what the approver reviews. */
@@ -379,6 +426,13 @@ export interface LoanApplication {
guarantorClientId: number | null;
guarantorName: string;
guarantorHasShareAccount: boolean;
/** How many guarantors this application has to name, from the product it was raised on. Absent on an application raised
* before products carried it, which keeps the officer's own Guarantor Required tick. */
guarantorsRequiredCount?: number;
/** Whether those guarantors have to hold a share account (be members), from the product. */
guarantorMustBeMember?: boolean;
/** The guarantors named so far (each a real, already-onboarded bank client). */
guarantors?: GuarantorEntry[];
// "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk
// Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by
@@ -398,9 +452,22 @@ export interface LoanApplication {
* is what actually gets quoted in the sanction letter later. */
committeeResolutionNo: string;
committeeResolutionDate: string;
/** What the loan is for, asked after KYC: the priority sector and weaker section decisions follow from it. */
loanPurpose?: string;
/** The officer's priority sector decision ('yes' | 'no'); the product's own setting is only the starting point. */
pslClassified?: string;
/** Priority sector lending category (RBI Master Directions para 5); one only, set when classified as priority sector. */
prioritySectorCategory?: string;
/** Why the decision differs from the product's priority sector setting. */
pslOverrideReason?: string;
/** Weaker section ('yes' | 'no') and its type (RBI Master Directions para 17.1). */
weakerSection?: string;
weakerSectionType?: string;
riskApproval: ApprovalRecord;
legalApproval: ApprovalRecord;
postCheckApproval: ApprovalRecord;
/** The approval of the loan account's disbursement (asked at the Disbursement stage; absent until approved). */
disbursementApproval?: ApprovalRecord;
// Legal / documentation
feesAndAccountOpened: boolean;
@@ -526,6 +593,11 @@ interface OriginationProduct {
interestBasis?: string;
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
baseRate?: number;
/** The deposit product(s) a loan is lent against, as named on the loan product (Product Creation > Security & Lien). */
eligibleDepositProducts?: string;
/** How many guarantors an application on this product has to name (Product Creation); absent on an older product. */
guarantorsRequired?: number;
guarantorMustBeMember?: boolean;
allowMultipleDeposits?: boolean;
allowThirdParty?: boolean;
/** The loan tenure may not run past the deposit's maturity. */
@@ -540,7 +612,106 @@ interface OriginationProduct {
facilityType?: string;
preclosure?: string;
renewalAllowed?: boolean;
/** The product was created as priority sector lending (Product Creation): the sanction names the category. */
prioritySector?: boolean;
}
/** RBI's priority sector lending categories. */
export const PRIORITY_SECTOR_CATEGORIES = [
'Agriculture',
'Micro, Small and Medium Enterprises',
'Export Credit',
'Education',
'Housing',
'Social Infrastructure',
'Renewable Energy',
'Others'
];
/** Weaker section types (RBI Master Directions on PSL, para 17.1): the fallback when the Code "WeakerSectionType" cannot be read. */
export const WEAKER_SECTION_TYPES = [
'Small and Marginal Farmers',
'Artisans, village and cottage industries (individual credit limit up to ₹2 lakh)',
'Beneficiaries of Government Sponsored Schemes (NRLM, SRMS)',
'Scheduled Castes and Scheduled Tribes',
'Beneficiaries of Differential Rate of Interest (DRI) scheme',
'Self Help Groups / Joint Liability Groups',
'Individuals and members of SHGs/JLGs availing microfinance loans',
'Individual women beneficiaries (up to ₹2 lakh)',
'Distressed farmers indebted to non-institutional lenders',
'Distressed persons other than farmers (up to ₹1 lakh)',
'Persons with disabilities',
'Transgenders',
'Minority communities notified by Government of India'
];
/** Proof an officer can attach for a priority sector classification, by category. */
export const PSL_DOCUMENT_TYPES: Record<string, string[]> = {
Agriculture: [
'Land record (7/12, Khata, Patta)',
'Crop / cultivation details',
'Kisan Credit Card',
'Tenancy / lease agreement',
'Allied activity licence (dairy, fisheries, poultry)',
'Project report / quotation'
],
'Micro, Small and Medium Enterprises': [
'Udyam Registration Certificate',
'GST registration',
'Shop & Establishment / trade licence',
'Project report / quotation',
'Latest ITR / financial statements'
],
'Export Credit': [
'Export order / Letter of credit',
'Import Export Code (IEC)',
'Shipping bill / invoice'
],
Education: [
'Admission letter',
'Fee structure',
'Marksheet of the last exam'
],
Housing: [
'Sale agreement / allotment letter',
'Property title documents',
'Cost estimate / approved plan',
'Property tax receipt (for repairs)'
],
'Social Infrastructure': [
'School / hospital registration',
'Project report and approvals',
'Land / building documents'
],
'Renewable Energy': [
'Project report',
'Vendor quotation',
'DISCOM / MNRE approval'
],
Others: [
'Self Help Group registration / resolution',
'Start-up recognition (DPIIT)',
'Debt statement (distressed person)',
'Project report'
]
};
/** Offered for every category and for the weaker section proof. */
export const PSL_OTHER_DOCUMENT = 'Other supporting document';
/** Proof of the weaker section the borrower belongs to. */
export const WEAKER_SECTION_DOCUMENT_TYPES = [
'Caste certificate (SC/ST)',
'Disability certificate / UDID',
'Minority community proof',
'Landholding record (small / marginal farmer)',
'SHG / JLG registration',
'Government scheme beneficiary certificate (NRLM, SRMS, DRI)',
'Artisan card / village industry registration',
'Debt statement (distressed farmer / person)',
'Aadhaar (women / transgender beneficiary)',
PSL_OTHER_DOCUMENT
];
/** 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 = [
@@ -550,8 +721,33 @@ export const DEPOSIT_PRODUCT_CODES = [
'LI'
];
/** A loan against a LIC policy: the security is a policy recorded on the application, not a deposit account. */
export function isLicLoanCode(productCode: string | undefined | null): boolean {
return productCode === 'LI' || (!!productCode && licProductCodes.has(productCode));
}
/**
* Short names of saved products whose Product Creation sub type is a deposit or policy (fd, rd, dds, lic). Filled by
* registerOriginationProduct, so a product made later under any short name (a second FD product) is also a loan
* against a deposit; the fixed codes above stay as the answer for the original products.
*/
const depositSubTypeCodes = new Set<string>();
const licProductCodes = new Set<string>();
const depositKindBySubType: Record<string, string> = { fd: 'FD', rd: 'RD', dds: 'DS', lic: 'LI' };
const depositKindByCode = new Map<string, string>();
/**
* The kind of deposit a product is a loan against, as the original short name of that kind: 'FD', 'RD', 'DS' (daily
* deposit scheme) or 'LI' (LIC policy). A product made later under another short name answers by its sub type, so
* screens that tell the kinds apart treat it like the original; any other product gets its own code back.
*/
export function depositKindCode(productCode: string | undefined | null): string {
const code = productCode ?? '';
return DEPOSIT_PRODUCT_CODES.includes(code) ? code : (depositKindByCode.get(code) ?? code);
}
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
return !!productCode && DEPOSIT_PRODUCT_CODES.includes(productCode);
return !!productCode && (DEPOSIT_PRODUCT_CODES.includes(productCode) || depositSubTypeCodes.has(productCode));
}
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
@@ -792,6 +988,24 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
*/
export function registerOriginationProduct(p: any): OriginationProduct {
const ext = p?.advanceExtensions ?? {};
const shortName = String(p.shortName);
if ([
'fd',
'rd',
'dds',
'lic'
].includes(ext.productTypeSubGroup)) {
depositSubTypeCodes.add(shortName);
depositKindByCode.set(shortName, depositKindBySubType[ext.productTypeSubGroup]);
} else {
depositSubTypeCodes.delete(shortName);
depositKindByCode.delete(shortName);
}
if (ext.productTypeSubGroup === 'lic') {
licProductCodes.add(shortName);
} else {
licProductCodes.delete(shortName);
}
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
const rate = Number(p?.interestRatePerPeriod ?? 0);
const entry: OriginationProduct = {
@@ -813,6 +1027,12 @@ export function registerOriginationProduct(p: any): OriginationProduct {
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
baseRate: rate,
eligibleDepositProducts: ext.eligibleDepositProducts ? String(ext.eligibleDepositProducts) : undefined,
guarantorsRequired:
ext.guarantorsRequired === undefined || ext.guarantorsRequired === null || ext.guarantorsRequired === ''
? undefined
: Number(ext.guarantorsRequired),
guarantorMustBeMember: ext.guarantorMustBeMember === undefined ? undefined : ext.guarantorMustBeMember === true,
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
allowThirdParty: ext.lienAllowThirdParty === true,
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
@@ -824,7 +1044,8 @@ export function registerOriginationProduct(p: any): OriginationProduct {
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
facilityType: String(ext.facilityType ?? 'demand'),
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
renewalAllowed: ext.renewalAllowed === true
renewalAllowed: ext.renewalAllowed === true,
prioritySector: String(ext.prioritySector ?? '').toLowerCase() === 'yes'
};
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
if (at >= 0) {
@@ -1001,7 +1222,11 @@ export function progressPct(app: LoanApplication): number {
if (app.overallStatus === 'DISBURSED') {
return 100;
}
return round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
const stages = stagesFor(app.productCode);
const at = stages.indexOf(app.currentStage);
return at >= 0
? round(((at + 1) / stages.length) * 100)
: round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
}
export function stageBadgeClass(app: LoanApplication): string {
@@ -1745,6 +1970,19 @@ export function decideApproval(
approverRole: string,
remarks: string
): void {
if (decision === 'REVOKED') {
const req = app.approvalRequests?.[field];
if (req) {
req.status = 'REVOKED';
req.decidedBy = approverName;
req.decidedOn = todayDDMMYYYY();
req.remarks = remarks;
req.revokeRemarks = remarks;
}
delete app[field];
recordStageProgress(app, approverName, 'Returned for Rectification', remarks);
return;
}
app[field] = { decision, approverName, approverRole, date: todayDDMMYYYY(), remarks };
if (decision === 'REJECTED') {
app.overallStatus = 'REJECTED';
@@ -2120,7 +2358,11 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
personalDiscussionDate: '—',
declarationTaken: false,
declarationDate: '—',
guarantorRequired: false,
// What the product asks for; an older product that does not say leaves it to the officer's own tick at Risk Approval.
guarantorRequired: (ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorsRequired ?? 0) > 0,
guarantorsRequiredCount: ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorsRequired,
guarantorMustBeMember: ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorMustBeMember,
guarantors: [],
guarantorClientId: null,
guarantorName: '—',
guarantorHasShareAccount: false,
@@ -430,14 +430,18 @@
// one long stacked column, so a 6-7 item checklist reads as a glanceable board, not a scroll.
.loi-kyc-doc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
align-items: start;
margin-bottom: 10px;
}
.loi-kyc-card {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-self: start;
height: auto;
gap: 6px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
@@ -1178,6 +1182,7 @@
.loi-docs-locked {
label.pg-btn,
.loi-legal-doc-thumb-remove,
.loi-doc-thumb-remove,
.loi-doc-remove,
.loi-custom-doc-input,
.loan-action-row {
@@ -8,9 +8,33 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BranchMaster, LoanApplication } from './loan-origination-data';
import { Observable, of } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import {
BranchMaster,
LoanApplication,
PRIORITY_SECTOR_CATEGORIES,
WEAKER_SECTION_TYPES
} from './loan-origination-data';
/** The core banking loan account an application has been given, with where it stands. */
export interface LoanAccountState {
id: number;
accountNo: string;
status: string;
pendingApproval: boolean;
/** Disbursed and running (the loan has money out). */
active: boolean;
}
/** What an opened loan account shows on the stages after signing. */
export interface AccountOverview {
account: { label: string; value: string }[];
approval: { by: string; on: string; amount: string };
charges: { name: string; amount: string; when: string; added: boolean }[];
glAccounts: { label: string; value: string }[];
posting: string | null;
}
/** 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 {
@@ -35,11 +59,159 @@ export class LoanOriginationService {
private readonly baseUrl = '/loan-origination/applications';
private readonly codeLists = new Map<string, Observable<string[]>>();
/**
* The active values of a system Code (Admin > Codes), in their order. The built-in list is the fallback only when the
* Code cannot be read or has no values.
*/
private codeNames(codeName: string, fallback: string[]): Observable<string[]> {
let list = this.codeLists.get(codeName);
if (!list) {
list = this.http.get<any[]>('/codes').pipe(
switchMap((codes) => {
const code = (codes ?? []).find((c) => c.name === codeName);
return code ? this.http.get<any[]>(`/codes/${code.id}/codevalues`) : of([] as any[]);
}),
map((values) =>
(values ?? [])
.filter((v) => v.active !== false && v.isActive !== false)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.map((v) => String(v.name))
),
map((names) => (names.length ? names : fallback)),
catchError(() => of(fallback)),
shareReplay(1)
);
this.codeLists.set(codeName, list);
}
return list;
}
/** The priority sector lending categories (Code "PrioritySectorCategory"). */
prioritySectorCategories(): Observable<string[]> {
return this.codeNames('PrioritySectorCategory', PRIORITY_SECTOR_CATEGORIES);
}
/** The weaker section types (Code "WeakerSectionType"). */
weakerSectionTypes(): Observable<string[]> {
return this.codeNames('WeakerSectionType', WEAKER_SECTION_TYPES);
}
/** One fixed or recurring deposit account with its balance, rate and maturity. */
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
}
/** A customer's open Daily Deposit Scheme accounts (the bank's DDS module keeps these, not Fineract's deposit accounts). */
listDdsAccounts(clientId: number): Observable<any[]> {
return this.http
.get<any[]>('/dds/parties')
.pipe(map((parties) => (parties ?? []).filter((p) => p.clientId === clientId && p.status !== 'CLOSED')));
}
/**
* The core banking loan account opened for this application, if any. The account screen gives the loan the application
* number as its external id, so the two can be matched; null when no account has been created.
*/
loanForApplication(applicationNo: string): Observable<LoanAccountState | null> {
// A search, not /loans/external-id/{id}: that answers 404 when there is no account, which the app shows as an error popup.
return this.http.get<any>(`/loans?externalId=${encodeURIComponent(applicationNo)}&limit=1`).pipe(
map((result) => result?.pageItems?.[0]),
map((loan) =>
loan?.id
? {
id: loan.id,
accountNo: loan.accountNo,
status: loan.status?.value ?? '—',
pendingApproval: !!loan.status?.pendingApproval,
active: !!loan.status?.active
}
: null
),
catchError(() => of(null))
);
}
/** Everything the Post-Disbursement / Disbursement stages show about the account that was opened for an application. */
loanAccountOverview(loanId: number): Observable<AccountOverview> {
return this.http.get<any>(`/loans/${loanId}?associations=charges`).pipe(
switchMap((loan) =>
this.http.get<any>(`/loanproducts/${loan.loanProductId}`).pipe(
catchError(() => of({})),
map((product) => {
const date = (v: any) =>
Array.isArray(v)
? v
.slice(0, 3)
.reverse()
.map((n: number) => String(n).padStart(2, '0'))
.join('-')
: (v ?? '—');
const inr = (v: any) =>
v === null || v === undefined || v === '' ? '—' : '₹' + Number(v).toLocaleString('en-IN');
const txt = (v: any) => (v === null || v === undefined || v === '' ? '—' : String(v));
const productChargeIds = new Set<number>((product.charges ?? []).map((c: any) => c.id));
const gl = (account: any) => (account?.glCode ? account.glCode + ' · ' + account.name : '—');
const maps = product.accountingMappings ?? {};
const cash = product.accountingRule?.id === 2;
return {
account: [
{ label: 'Account no.', value: txt(loan.accountNo) },
{ label: 'Status', value: txt(loan.status?.value) },
{ label: 'Product', value: txt(loan.loanProductName) },
{ label: 'Customer', value: txt(loan.clientName) },
{ label: 'Principal', value: inr(loan.principal) },
{
label: 'Interest rate',
value: loan.annualInterestRate !== undefined ? loan.annualInterestRate + '% p.a.' : '—'
},
{
label: 'Term',
value:
loan.termFrequency !== undefined
? loan.termFrequency + ' ' + (loan.termPeriodFrequencyType?.value ?? '')
: '—'
},
{ label: 'Purpose', value: txt(loan.loanPurposeName) },
{ label: 'Submitted on', value: date(loan.timeline?.submittedOnDate) },
{ label: 'Expected disbursement', value: date(loan.timeline?.expectedDisbursementDate) }
],
approval: {
by: txt(loan.timeline?.approvedByUsername),
on: date(loan.timeline?.approvedOnDate),
amount: inr(loan.approvedPrincipal)
},
charges: (loan.charges ?? []).map((c: any) => ({
name: c.name as string,
amount: inr(c.amount),
when: (c.chargeTimeType?.value ?? '—') as string,
added: !productChargeIds.has(c.chargeId)
})),
glAccounts: [
{ label: 'Fund source (paid out on disbursement)', value: gl(maps.fundSourceAccount) },
{ label: "Loan portfolio (the customer's loan account)", value: gl(maps.loanPortfolioAccount) },
{ label: 'Interest income', value: gl(maps.interestOnLoanAccount) },
{ label: 'Fee income', value: gl(maps.incomeFromFeeAccount) },
{ label: 'Penalty income', value: gl(maps.incomeFromPenaltyAccount) },
{ label: 'Overpayment liability', value: gl(maps.overpaymentLiabilityAccount) },
{ label: 'Write-off', value: gl(maps.writeOffAccount) }
],
posting: cash
? 'On disbursement: Debit ' +
gl(maps.loanPortfolioAccount) +
' · Credit ' +
gl(maps.fundSourceAccount) +
' · ' +
inr(loan.principal)
: null
} as AccountOverview;
})
)
)
);
}
/** The bank's real branches: every office of the organisation, in hierarchy order. */
listBranches(): Observable<BranchMaster[]> {
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
@@ -145,7 +317,7 @@ export class LoanOriginationService {
applicationNo: string,
field: string,
body: {
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
approverName: string;
approverRole: string;
remarks?: string;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -109,7 +109,8 @@ type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rate
'../../cashier/cashier-shared.scss',
'../../investment/investment-shared.scss',
'../../npa/npa-shared.scss',
'../loan-origination-shared.scss'
'../loan-origination-shared.scss',
'./loan-pipeline.component.scss'
],
standalone: true,
imports: [
@@ -6,13 +6,53 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="cla-container">
<!-- Hero header, same as the deposit account screens -->
<div class="cla-header">
<fa-icon icon="university" class="cla-header__icon"></fa-icon>
<div>
<h2 class="cla-header__title">Create Loan Account</h2>
<div class="cla-header__sub">
{{
viewMode
? 'The account created for this application, as it was filled (view only).'
: 'Pick a product, set terms and review before submit.'
}}
</div>
</div>
</div>
@if (submitBlocked) {
<div class="cla-existing">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Not submitted</strong>
<span>{{ submitBlocked }}</span>
</div>
</div>
}
@if (existingLosLoan) {
<div class="cla-existing">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Loan account {{ existingLosLoan.accountNo }} — {{ existingLosLoan.status }}</strong>
<span>{{
viewMode
? 'You are viewing the account that was created. Nothing here can be changed.'
: 'It is already created for this application. A second account cannot be created.'
}}</span>
</div>
</div>
}
<mat-stepper
orientation="vertical"
orientation="horizontal"
[linear]="false"
class="mat-elevation-z8"
labelPosition="bottom"
class="cla-stepper"
labelPosition="end"
#loansAccountStepper
(selectionChange)="refreshLosSections()"
>
<ng-template matStepperIcon="number">
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
@@ -35,7 +75,10 @@
</ng-template>
<mat-step [stepControl]="loansAccountDetailsForm">
<ng-template matStepLabel>{{ 'labels.inputs.DETAILS' | translate }}</ng-template>
<ng-template matStepLabel
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon
>{{ 'labels.inputs.DETAILS' | translate }}</ng-template
>
<mifosx-loans-account-details-step
[loansAccountTemplate]="loansAccountTemplate"
@@ -46,7 +89,9 @@
</mat-step>
<mat-step [stepControl]="loansAccountTermsForm">
<ng-template matStepLabel>{{ 'labels.inputs.TERMS' | translate }}</ng-template>
<ng-template matStepLabel
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.TERMS' | translate }}</ng-template
>
<mifosx-loans-account-terms-step
#loanAccountTerms
@@ -55,12 +100,103 @@
[collateralOptions]="collateralOptions"
[loansAccountFormValid]="loansAccountFormValid"
[loanPrincipal]="loanPrincipal"
[losApplication]="loansAccountDetailsStep?.losApplication"
[losAdvance]="
!!loansAccountDetailsStep?.losApplication && loansAccountDetailsStep?.categoryFilter === 'advances'
"
>
</mifosx-loans-account-terms-step>
</mat-step>
<!-- Opened from Loan Origination for an entity (not a person): its related persons, the authorised signatories. -->
@if (loansAccountDetailsStep?.losIsNonIndividual) {
<mat-step>
<ng-template matStepLabel
><fa-icon icon="university" class="cla-tab__icon"></fa-icon>Related Persons</ng-template
>
<table class="cla-related">
<thead>
<tr>
<th>Client ID</th>
<th>Title</th>
<th>Name</th>
<th>Pin</th>
<th>Birth date</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (person of loansAccountDetailsStep.losRelatedPersons; track $index) {
<tr>
<td>{{ person.clientId ?? '—' }}</td>
<td>{{ person.title || '—' }}</td>
<td>{{ person.loading ? 'Loading…' : person.name || '—' }}</td>
<td>{{ person.pincode || '—' }}</td>
<td>{{ person.birthDate ? (person.birthDate | date: 'dd-MM-yyyy') : '—' }}</td>
<td>{{ person.address || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6">No authorised signatories are recorded for this entity.</td>
</tr>
}
</tbody>
</table>
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button matStepperNext>
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</mat-step>
}
<!-- Advances opened from Loan Origination: the limit, its security and the purpose and renewal date the officer gives. -->
@if (loansAccountDetailsStep?.advancesSelected) {
<mat-step>
<ng-template matStepLabel
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon>Limit &amp; Security</ng-template
>
<mifosx-loans-account-los-limit-step
[losApplication]="loansAccountDetailsStep?.losApplication"
[productId]="loansAccountDetailsStep?.loansAccountDetailsForm?.get('productId')?.value"
>
</mifosx-loans-account-los-limit-step>
</mat-step>
}
<!-- Advances opened from Loan Origination: the SMS alerts the customer gets (the account master's SMS Alert page). -->
@if (loansAccountDetailsStep?.advancesSelected) {
<mat-step>
<ng-template matStepLabel><fa-icon icon="bell" class="cla-tab__icon"></fa-icon>SMS Alerts</ng-template>
<mifosx-loans-account-los-sms-step [chequeBookOffered]="!!loansAccountTermsStep?.chequeBookOffered">
</mifosx-loans-account-los-sms-step>
</mat-step>
}
<!-- The guarantors named at Loan Origination (the account master's guarantor page). -->
<!-- Not for a loan on a deposit (FD / RD / DDS / LIC): the deposit is the security, no guarantor is taken. -->
@if (
loansAccountDetailsStep?.losApplication &&
!loansAccountDetailsStep?.losApplication?.depositSelection &&
(loansAccountDetailsStep?.losApplication?.guarantorsRequiredCount ||
loansAccountDetailsStep?.losApplication?.guarantors?.length)
) {
<mat-step>
<ng-template matStepLabel><fa-icon icon="user-shield" class="cla-tab__icon"></fa-icon>Guarantors</ng-template>
<mifosx-loans-account-los-guarantors-step [losApplication]="loansAccountDetailsStep?.losApplication">
</mifosx-loans-account-los-guarantors-step>
</mat-step>
}
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.CHARGES' | translate }}</ng-template>
<ng-template matStepLabel
><fa-icon icon="coins" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.CHARGES' | translate }}</ng-template
>
<mifosx-loans-account-charges-step
#loanAccountCharges
@@ -68,13 +204,17 @@
[loansAccountTemplate]="loansAccountTemplate"
[loansAccountFormValid]="loansAccountFormValid"
[loansSavingsAccountLinked]="loansSavingsAccountLinked"
[viewOnly]="viewMode"
>
</mifosx-loans-account-charges-step>
</mat-step>
@if (loansAccountFormValid) {
@if (loansAccountFormValid && !losAdvance) {
<mat-step state="repayment" completed>
<ng-template matStepLabel>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template>
<ng-template matStepLabel
><fa-icon icon="university" class="cla-tab__icon"></fa-icon
>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template
>
<mifosx-loans-account-schedule-step
#loanAccountSchedule
[loansAccountTemplate]="loansAccountTemplate"
@@ -88,18 +228,25 @@
@for (datatable of datatables; track datatable) {
<mat-step>
<ng-template matStepLabel>{{ datatable.registeredTableName }}</ng-template>
<ng-template matStepLabel
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ datatable.registeredTableName }}</ng-template
>
<mifosx-loans-account-datatable-step [datatableData]="datatable" #dtloan></mifosx-loans-account-datatable-step>
</mat-step>
}
@if (loansAccountFormValid) {
<mat-step state="preview" completed>
<ng-template matStepLabel>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template>
<ng-template matStepLabel
><fa-icon icon="eye" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template
>
<mifosx-loans-account-preview-step
[loansAccountTemplate]="loansAccountTemplate"
[loansAccountProductTemplate]="loansAccountProductTemplate"
[loansAccount]="loansAccount"
[losSections]="losSections"
[losMode]="!!loansAccountDetailsStep?.losApplication"
[viewOnly]="viewMode"
(submitEvent)="submit()"
>
</mifosx-loans-account-preview-step>
@@ -5,3 +5,190 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* The Nexora account-creation look (same as the deposit account screens): hero header, a tab strip for the steps, a footer
with Previous / Next. The steps themselves still work as a stepper. */
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$indigo: #4f46e5;
$border: #e2e8f0;
.cla-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 24px;
}
.cla-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
&__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
flex-shrink: 0;
}
&__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: $navy;
line-height: 1.1;
letter-spacing: 0.02em;
}
&__sub {
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
}
.cla-tab__icon {
margin-right: 8px;
font-size: 13px;
}
.cla-stepper {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-horizontal-stepper-header-container {
background: #f8fafc;
border-bottom: 1px solid $border;
margin: 0;
}
/* Step headers read as tabs: label only, equal width, underline on the open one. */
.mat-step-header {
flex: 1;
height: 50px;
justify-content: center;
padding: 0 12px;
border-bottom: 3px solid transparent;
border-radius: 0;
}
.mat-step-header .mat-step-icon,
.mat-stepper-horizontal-line {
display: none;
}
.mat-step-header .mat-step-label {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: #94a3b8;
}
.mat-step-header[aria-selected='true'] {
border-bottom-color: $blue-d;
.mat-step-label {
color: $blue-d;
font-weight: 700;
}
}
.mat-step-header:hover:not([aria-selected='true']) {
background: rgb(30 64 175 / 5%);
.mat-step-label {
color: $navy;
}
}
.mat-horizontal-content-container {
padding: 24px;
@media (width <= 768px) {
padding: 16px;
}
}
/* Previous on the left, Next on the right, with a rule above, as on the deposit screens. */
.stepper-buttons {
justify-content: space-between;
margin-top: 24px !important;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
/* The shared row class clips what is taller than it; the 40px buttons must show whole. */
height: auto !important;
min-height: 56px;
overflow: visible !important;
align-items: center;
button {
height: 40px;
border-radius: 10px;
font-weight: 600;
padding: 0 18px;
}
[matStepperPrevious][disabled] {
display: none;
}
[matStepperNext] {
margin-left: auto;
}
}
}
}
/* Related persons of an entity (its authorised signatories), as on the account master. */
.cla-related {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f8fafc;
color: #475569;
font-weight: 600;
}
}
/* An account is already open for the Loan Origination application. */
.cla-existing {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
padding: 12px 16px;
border: 1px solid #fcd34d;
border-radius: 12px;
background: #fffbeb;
color: #92400e;
font-size: 13.5px;
div {
display: flex;
flex-direction: column;
gap: 2px;
}
}
@@ -7,7 +7,16 @@
*/
/** Angular Imports */
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
import { AfterViewChecked, Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { Dates } from 'app/core/utils/dates';
import { LoansAccountLosLimitStepComponent } from '../loans-account-stepper/loans-account-los-limit-step/loans-account-los-limit-step.component';
import { LoansAccountLosGuarantorsStepComponent } from '../loans-account-stepper/loans-account-los-guarantors-step/loans-account-los-guarantors-step.component';
import { LoansAccountLosSmsStepComponent } from '../loans-account-stepper/loans-account-los-sms-step/loans-account-los-sms-step.component';
import { LosPreviewSection } from '../loans-account-stepper/los-preview-section';
import { LoanAccountState, LoanOriginationService } from 'app/loan-origination/loan-origination.service';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Services */
@@ -20,7 +29,14 @@ import { LoansAccountDetailsStepComponent } from '../loans-account-stepper/loans
import { LoansAccountTermsStepComponent } from '../loans-account-stepper/loans-account-terms-step/loans-account-terms-step.component';
import { LoansAccountChargesStepComponent } from '../loans-account-stepper/loans-account-charges-step/loans-account-charges-step.component';
import { LoansAccountDatatableStepComponent } from '../loans-account-stepper/loans-account-datatable-step/loans-account-datatable-step.component';
import { MatStepper, MatStepperIcon, MatStep, MatStepLabel } from '@angular/material/stepper';
import {
MatStepper,
MatStepperIcon,
MatStep,
MatStepLabel,
MatStepperNext,
MatStepperPrevious
} from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoansAccountScheduleStepComponent } from '../loans-account-stepper/loans-account-schedule-step/loans-account-schedule-step.component';
import { LoansAccountPreviewStepComponent } from '../loans-account-stepper/loans-account-preview-step/loans-account-preview-step.component';
@@ -40,25 +56,90 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent,
MatStep,
MatStepLabel,
MatStepperNext,
MatStepperPrevious,
LoansAccountDetailsStepComponent,
LoansAccountTermsStepComponent,
LoansAccountLosLimitStepComponent,
LoansAccountLosGuarantorsStepComponent,
LoansAccountLosSmsStepComponent,
LoansAccountChargesStepComponent,
LoansAccountScheduleStepComponent,
LoansAccountDatatableStepComponent,
LoansAccountPreviewStepComponent
]
})
export class CreateLoansAccountComponent {
export class CreateLoansAccountComponent implements AfterViewChecked {
private route = inject(ActivatedRoute);
private router = inject(Router);
private loansService = inject(LoansService);
private settingsService = inject(SettingsService);
private clientService = inject(ClientsService);
private http = inject(HttpClient);
private dateUtils = inject(Dates);
private originationService = inject(LoanOriginationService);
/** Imports all the step component */
@ViewChild(LoansAccountDetailsStepComponent, { static: true })
loansAccountDetailsStep: LoansAccountDetailsStepComponent;
@ViewChild(LoansAccountTermsStepComponent, { static: true }) loansAccountTermsStep: LoansAccountTermsStepComponent;
/** The Limit & Security step an Advances account opened from Loan Origination has. */
@ViewChild(LoansAccountLosLimitStepComponent) loansAccountLosLimitStep: LoansAccountLosLimitStepComponent;
@ViewChild(LoansAccountLosGuarantorsStepComponent)
loansAccountLosGuarantorsStep: LoansAccountLosGuarantorsStepComponent;
/** What the Loan Origination steps filled, listed in the Preview; rebuilt whenever the officer moves to another step. */
losSections: LosPreviewSection[] = [];
/** An Advances account opened from Loan Origination has no repayment schedule. */
get losAdvance(): boolean {
return (
!!this.loansAccountDetailsStep?.losApplication && this.loansAccountDetailsStep?.categoryFilter === 'advances'
);
}
refreshLosSections(): void {
const details = this.loansAccountDetailsStep;
if (!details?.losApplication) {
// an Advances account opened without a Loan Origination application still lists the limit and SMS steps it filled
this.losSections = details?.advancesSelected ? [
...(this.loansAccountLosLimitStep?.previewSections() ?? []),
...(this.loansAccountLosSmsStep?.previewSections() ?? [])
] : [];
return;
}
this.originationService.loanForApplication(details.losApplication.applicationNo).subscribe((loan) => {
this.existingLosLoan = loan;
});
const persons = details.losIsNonIndividual
? [
{
title: 'Related persons',
rows: (details.losRelatedPersons ?? []).map((p: any, i: number) => ({
label: 'Person ' + (i + 1),
value: [
p.name,
p.title,
p.pincode,
p.address
]
.filter(Boolean)
.join(' · ')
}))
}
]
: [];
this.losSections = [
...details.previewSections(),
...persons,
...(this.loansAccountTermsStep?.previewSections() ?? []),
...(this.loansAccountLosLimitStep?.previewSections() ?? []),
...(this.loansAccountLosSmsStep?.previewSections() ?? []),
...(this.loansAccountLosGuarantorsStep?.previewSections() ?? [])
];
}
/** The SMS Alerts step of an account opened from Loan Origination. */
@ViewChild(LoansAccountLosSmsStepComponent) loansAccountLosSmsStep: LoansAccountLosSmsStepComponent;
@ViewChild(LoansAccountChargesStepComponent, { static: true })
loansAccountChargesStep: LoansAccountChargesStepComponent;
/** Get handle on dtloan tags in the template */
@@ -170,6 +251,138 @@ export class CreateLoansAccountComponent {
* Submits Data to create loan account
*/
submit() {
// An Advances account needs the Limit & Security step filled (purpose, and the sanction when there is no application).
this.submitBlocked = '';
const limit = this.loansAccountLosLimitStep;
if (this.loansAccountDetailsStep?.advancesSelected && limit?.form.invalid) {
limit.form.markAllAsTouched();
this.submitBlocked =
'Complete the Limit & Security step before submitting: the purpose of the loan and, without a Loan Origination application, the sanction no. and date.';
return;
}
// An application that does not belong to this customer, or is not ready for an account, is never linked.
if (this.loansAccountDetailsStep?.losMode && this.loansAccountDetailsStep.losError) {
return;
}
const los = this.loansAccountDetailsStep?.losApplication;
if (!los) {
this.createAccount();
return;
}
// One account per Loan Origination application: look again just before creating (it may have been opened meanwhile).
this.originationService.loanForApplication(los.applicationNo).subscribe((existing) => {
this.existingLosLoan = existing;
if (!existing) {
this.createAccount();
}
});
}
/** Why Submit did nothing (shown above the steps); empty when nothing is wrong. */
submitBlocked = '';
/** The account already opened for the Loan Origination application on screen (null: none yet). */
existingLosLoan: LoanAccountState | null = null;
/** Opened with ?view=1 from Loan Origination (View Account): the account that was created, shown read-only. */
get viewMode(): boolean {
return this.route.snapshot.queryParamMap.get('view') === '1';
}
private viewApplied = false;
ngAfterViewChecked(): void {
const details = this.loansAccountDetailsStep;
if (
!this.viewMode ||
this.viewApplied ||
!details?.losApplication ||
!this.loansAccountLosLimitStep ||
!this.loansAccountLosSmsStep ||
!this.loansAccountTermsStep
) {
return;
}
this.viewApplied = true;
this.originationService.loanForApplication(details.losApplication.applicationNo).subscribe((loan) => {
if (!loan) {
return;
}
this.existingLosLoan = loan;
forkJoin({
full: this.http.get<any>(`/loans/${loan.id}?associations=charges`),
services: this.http.get<any[]>(`/datatables/loan_account_services/${loan.id}`).pipe(catchError(() => of([])))
}).subscribe(({ full, services }) => this.showCreatedAccount(full, services?.[0] ?? {}));
});
}
/** Fills every step from what was saved with the account, and locks them. */
private showCreatedAccount(loan: any, row: any): void {
const yes = (v: any) => v === true || v === 'true';
const iso = (v: any) =>
Array.isArray(v)
? v
.slice(0, 3)
.map((n: number) => String(n).padStart(2, '0'))
.join('-')
.replace(/^(\d+)-/, '$1-')
: '';
const num = (v: any) => (v === null || v === undefined ? null : Number(v));
const limit = this.loansAccountLosLimitStep;
limit.form.patchValue({
purpose: row.loan_purpose_text ?? loan.loanPurposeName ?? '',
documentRenewalDate: iso(row.document_renewal_date),
prioritySectorCategory: row.priority_sector_category ?? ''
});
limit.form.disable();
const sms = this.loansAccountLosSmsStep;
sms.form.patchValue({
smsActive: yes(row.sms_active) ? 'yes' : 'no',
smsCharge: yes(row.sms_charge) ? 'yes' : 'no',
disbursementAlert: yes(row.sms_disbursement_alert),
dueAlert: yes(row.sms_due_alert),
overdueAlert: yes(row.sms_overdue_alert),
renewAlert: yes(row.sms_renew_alert),
closureAlert: yes(row.sms_closure_alert),
repaymentAlert: yes(row.sms_credit_clearing),
debitAlert: yes(row.sms_debit_clearing),
chargeInterest: yes(row.sms_charge_interest),
chequeBook: yes(row.sms_cheque_book),
debitAbove: num(row.sms_debit_above) ?? 0,
creditAbove: num(row.sms_credit_above) ?? 0
});
sms.form.disable();
const terms = this.loansAccountTermsStep;
terms.losServicesForm.patchValue({
chequeBookIssue: yes(row.cheque_book_issue),
ppsLimit: num(row.pps_limit),
atmActive: yes(row.atm_active),
impsActive: yes(row.imps_active),
impsPrimary: yes(row.imps_primary_account),
mmid: row.mmid ?? '',
upiActive: yes(row.upi_active),
posActive: yes(row.pos_active),
atmPerTxn: num(row.atm_per_txn_limit),
atmDay: num(row.atm_day_limit),
posPerTxn: num(row.pos_per_txn_limit),
posDay: num(row.pos_day_limit),
upiPerTxn: num(row.upi_per_txn_limit),
upiDay: num(row.upi_day_limit)
});
terms.losServicesForm.disable();
// the charges the account actually has
this.loansAccountChargesStep.chargesDataSource = (loan.charges ?? []).map((c: any) => ({
...c,
chargeId: c.chargeId ?? c.id
}));
this.refreshLosSections();
}
private createAccount() {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const payload = this.loansService.buildLoanRequestPayload(
@@ -179,6 +392,10 @@ export class CreateLoansAccountComponent {
locale,
dateFormat
);
// The application number is the loan's external id: it is how Loan Origination finds (and shows the status of) this account.
if (this.loansAccountDetailsStep?.losApplication) {
payload['externalId'] = this.loansAccountDetailsStep.losApplication.applicationNo;
}
if (this.loansAccountProductTemplate.datatables && this.loansAccountProductTemplate.datatables.length > 0) {
const datatables: any[] = [];
@@ -188,15 +405,84 @@ export class CreateLoansAccountComponent {
payload['datatables'] = datatables;
}
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId,
'general'
],
{ relativeTo: this.route }
);
});
// Opened from Loan Origination for an Advances account: the services the officer filled are saved with the new loan.
const losServices =
this.loansAccountDetailsStep.losMode && this.loansAccountTermsStep.losAdvance
? {
...this.loansAccountTermsStep.losServices,
// sanction no. and date, secured, security type and the document renewal date, with the services
...(this.loansAccountLosLimitStep?.datatableValues((iso) =>
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
) ?? {}),
...(this.loansAccountLosSmsStep?.datatableValues((iso) =>
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
) ?? {})
}
: this.loansAccountDetailsStep.advancesSelected
? {
// no application: only what the Limit & Security and SMS Alerts steps took
...(this.loansAccountLosLimitStep?.datatableValues((iso) =>
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
) ?? {}),
...(this.loansAccountLosSmsStep?.datatableValues((iso) =>
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
) ?? {})
}
: null;
const create = () =>
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
if (losServices && response.loanId) {
const body = { ...losServices, locale: locale, dateFormat: dateFormat };
this.http.post(`/datatables/loan_account_services/${response.loanId}`, body).subscribe();
}
this.router.navigate(
[
'../',
response.resourceId,
'general'
],
{ relativeTo: this.route }
);
});
// The purpose is typed, but the loan's own purpose field (shown in the approval list and on the loan) takes a code value:
// use the matching one, or add it to the Loan Purpose code.
const purpose = this.loansAccountLosLimitStep?.purposeText;
if (purpose) {
this.purposeCodeValueId(purpose).subscribe((id) => {
if (id) {
payload['loanPurposeId'] = id;
}
create();
});
} else {
create();
}
}
/** The Loan Purpose code value for this text: an existing one (by name or description), else a new one. */
private purposeCodeValueId(text: string): Observable<number | null> {
const wanted = text.trim().toLowerCase();
return this.http.get<any[]>('/codes/3/codevalues').pipe(
switchMap((values) => {
const found = (values ?? []).find(
(v) => v.name?.trim().toLowerCase() === wanted || v.description?.trim().toLowerCase() === wanted
);
if (found) {
return of(found.id as number);
}
const position = (values ?? []).reduce((max, v) => Math.max(max, v.position ?? 0), 0) + 1;
return this.http
.post<any>('/codes/3/codevalues', {
name: text.trim().slice(0, 100),
description: text.trim(),
isActive: true,
position
})
.pipe(map((r) => r.subResourceId as number));
}),
catchError(() => of(null))
);
}
}
@@ -6,24 +6,26 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
<mat-select #charge>
@for (charge of chargeData; track charge) {
<mat-option [value]="charge">
{{ charge.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<div class="layout-row-wrap gap-2px responsive-column" [class.view-only]="viewOnly">
@if (!viewOnly) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
<mat-select #charge>
@for (charge of chargeData; track charge) {
<mat-option [value]="charge">
{{ charge.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<div class="flex-48 align-center">
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
<div class="flex-48 align-center">
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
}
<table
class="flex-98 mat-elevation-z1"
@@ -102,9 +104,13 @@
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let charge">
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
<fa-icon icon="trash"></fa-icon>
</button>
@if (canDelete(charge)) {
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
<fa-icon icon="trash"></fa-icon>
</button>
} @else {
<span class="charge-locked" title="Comes with the product">Product</span>
}
</td>
</ng-container>
@@ -42,3 +42,19 @@ mat-divider {
.tableName {
padding-left: 2%;
}
/* A charge the product brings: shown, but not removable. */
.charge-locked {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: #475569;
background: #e2e8f0;
}
/* An account already created: the charges are shown, not edited. */
.view-only [mat-icon-button] {
display: none;
}
@@ -147,6 +147,9 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
this.loanId = this.route.snapshot.params['loanId'];
}
/** Shows the charges of an account already created (opened from Loan Origination): nothing can be added, changed or removed. */
@Input() viewOnly = false;
ngOnInit() {
if (this.loansAccountTemplate && this.loansAccountTemplate.charges) {
this.chargesDataSource =
@@ -210,9 +213,16 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
* Add a charge
*/
addCharge(charge: any) {
const chargeId = charge.value.id || charge.value.chargeId;
// A charge the product already brings stays as it is (and cannot be removed); adding it again would replace it.
if (this.chargesDataSource.some((c: any) => (c.chargeId ?? c.id) === chargeId)) {
charge.value = '';
return;
}
const newCharge = {
...charge.value,
chargeId: charge.value.id || charge.value.chargeId
chargeId,
addedAtCreation: true
};
this.chargesDataSource = this.chargesDataSource.concat([newCharge]);
charge.value = '';
@@ -356,12 +366,20 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
get loansAccountCharges() {
const uniqueCharges = this.getUniqueCharges(this.chargesDataSource);
return {
charges: uniqueCharges.map((charge: any) => ({
charges: uniqueCharges.map(({ addedAtCreation, ...charge }: any) => ({
...charge,
chargeId: charge.chargeId ?? charge.id
}))
};
}
/**
* A charge the officer added while opening the account can be removed again; the ones the product brings cannot. (On an
* existing account being modified every charge keeps its delete button.)
*/
canDelete(charge: any): boolean {
return !this.viewOnly && (this.loanId != null || !!charge.addedAtCreation);
}
private getUniqueCharges<T extends { id?: number | string; chargeId?: number | string }>(charges: T[]): T[] {
const uniqueChargesMap = new Map<number | string, T>();
@@ -7,58 +7,218 @@
-->
<form [formGroup]="loansAccountDetailsForm">
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
<mat-option value="">All categories</mat-option>
@for (category of categories; track category.id) {
<mat-option [value]="category.id">{{ category.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
<mat-option value="">All types</mat-option>
@for (type of typeOptions; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (losMode) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<input matInput [value]="losCategoryLabel" readonly />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<input matInput [value]="losTypeLabel" readonly />
</mat-form-field>
</div>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input matInput [value]="losProductName" readonly />
</mat-form-field>
</div>
}
@if (!losMode) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<mat-select
[value]="categoryFilter"
[disabled]="losMode"
(selectionChange)="categoryFilter = $event.value; onCategoryChange()"
>
<mat-option value="">All categories</mat-option>
@for (category of categories; track category.id) {
<mat-option [value]="category.id">{{ category.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<mat-select
[value]="typeFilter"
[disabled]="losMode"
(selectionChange)="typeFilter = $event.value; applyProductFilters()"
>
<mat-option value="">All types</mat-option>
@for (type of typeOptions; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<mat-select
required
matTooltip="{{ 'tooltips.Name of the loan product' | translate }}"
formControlName="productId"
>
<mat-option>
<ngx-mat-select-search
placeholderLabel="{{ placeHolderLabel }}"
noEntriesFoundLabel="{{ noEntriesFoundLabel }}"
[formControl]="filterFormCtrl"
></ngx-mat-select-search>
</mat-option>
@for (product of productData | async; track product) {
<mat-option [value]="product.id">
{{ product.name }}
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<mat-select
required
matTooltip="{{ 'tooltips.Name of the loan product' | translate }}"
formControlName="productId"
>
<mat-option>
<ngx-mat-select-search
placeholderLabel="{{ placeHolderLabel }}"
noEntriesFoundLabel="{{ noEntriesFoundLabel }}"
[formControl]="filterFormCtrl"
></ngx-mat-select-search>
</mat-option>
@for (product of productData | async; track product) {
<mat-option [value]="product.id">
{{ product.name }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
<!-- The customer's Loan Origination applications that are ready for an account, narrowed by the category, type and product above. -->
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>Link to a Loan Origination application</mat-label>
<mat-select [disabled]="!losChoices.length" (selectionChange)="useLosApplication($event.value)">
@for (choice of losChoices; track choice.app.applicationNo) {
<mat-option [value]="choice.app.applicationNo">{{ choice.label }}</mat-option>
}
</mat-select>
<mat-hint>
@if (losChoices.length) {
Choosing one opens this screen for that application, with its product, customer and terms filled in.
} @else if (hasLosCandidates) {
No application at Customer Signing matches this category, type and product for this customer.
} @else {
This customer has no Loan Origination application at Customer Signing waiting for an account.
}
</mat-hint>
</mat-form-field>
</div>
}
@if (losMode) {
@if (losError) {
<p class="los-error">{{ losError }}</p>
}
@if (losApplication; as app) {
<div class="los-card">
<div class="los-card__head">
<strong>Loan Origination application</strong>
<a class="los-card__link" [routerLink]="['/loan-origination/application', app.applicationNo]">{{
app.applicationNo
}}</a>
<span class="los-card__chip">{{ app.currentStage }}</span>
</div>
<div class="los-card__grid">
<div>
<span class="los-card__label">Amount</span><span>₹{{ app.requestedAmount | number: '1.0-0' }}</span>
</div>
<div>
<span class="los-card__label">Tenure</span><span>{{ app.tenureMonths }} months</span>
</div>
<div>
<span class="los-card__label">Interest rate</span><span>{{ app.interestRate }}% p.a.</span>
</div>
<div>
<span class="los-card__label">Secured on</span
><span>{{ app.depositSelection?.accountNos?.join(', ') || '—' }}</span>
</div>
</div>
</div>
}
@if (losCustomer; as c) {
<div class="los-customer">
<h3 class="mat-h3">Customer details</h3>
@if (c.missing) {
<p class="los-error">This application is not linked to a bank client yet.</p>
} @else {
<div class="los-customer__grid">
<div>
<span class="los-card__label">Client ID</span><span>{{ c.accountNo || c.clientId }}</span>
</div>
<div>
<span class="los-card__label">Name</span><span>{{ c.name }}</span>
</div>
<div>
<span class="los-card__label">Client type</span><span>{{ c.clientType || '—' }}</span>
</div>
<div>
<span class="los-card__label">Father's name</span><span>{{ c.fatherName || '—' }}</span>
</div>
<div>
<span class="los-card__label">Gender</span><span>{{ c.gender || '—' }}</span>
</div>
<div>
<span class="los-card__label">Date of birth</span><span>{{ c.dateOfBirth | date: 'dd-MM-yyyy' }}</span>
</div>
<div>
<span class="los-card__label">Adult / Minor</span><span>{{ c.minor ? 'Minor' : 'Adult' }}</span>
</div>
<div class="los-customer__wide">
<span class="los-card__label">Address</span>
<span>
@if (c.address; as ad) {
{{ ad.lines.join(', ') }}{{ ad.city ? ', ' + ad.city : '' }}{{ ad.district ? ', ' + ad.district : ''
}}{{ ad.state ? ', ' + ad.state : '' }}{{ ad.pincode ? ' - ' + ad.pincode : '' }}
} @else {
—
}
</span>
</div>
<div>
<span class="los-card__label">Mobile</span><span>{{ c.mobile || '—' }}</span>
</div>
<div>
<span class="los-card__label">Occupation</span><span>{{ c.occupation || '—' }}</span>
</div>
<div>
<span class="los-card__label">Nationality</span><span>{{ c.nationality || '—' }}</span>
</div>
<div>
<span class="los-card__label">Branch</span><span>{{ c.branch || '—' }}</span>
</div>
<div>
<span class="los-card__label">PAN</span
><span
>{{ c.pan || '—' }}
@if (c.pan) {
({{ c.panVerified ? 'verified' : 'not verified' }})
}
</span>
</div>
<div>
<span class="los-card__label">Aadhaar</span
><span
>{{ c.aadhaar || '—' }}
@if (c.aadhaar) {
({{ c.aadhaarVerified ? 'verified' : 'not verified' }})
}
</span>
</div>
<div>
<span class="los-card__label">KYC status</span><span>{{ c.kycStatus || '—' }}</span>
</div>
<div>
<span class="los-card__label">Risk</span><span>{{ c.risk || '—' }}</span>
</div>
</div>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</div>
}
} @else {
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
}
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
@if (loanProductSelected) {
@if (loanProductSelected && !losMode) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.External id' | translate }}</mat-label>
@@ -30,3 +30,55 @@ mat-divider {
.margin-t {
margin-top: 1em;
}
/* Opened from Loan Origination: the application card and the customer's details. */
.los-card,
.los-customer {
border: 1px solid #c9d7f2;
border-radius: 8px;
background: #f3f7ff;
padding: 12px 16px;
margin: 8px 0 16px;
}
.los-card__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.los-card__link {
font-weight: 600;
}
.los-card__chip {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #e3ebff;
}
.los-card__grid,
.los-customer__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px 16px;
}
.los-customer__wide {
grid-column: 1 / -1;
}
.los-card__label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #5b6b8c;
}
.los-error {
color: #b3261e;
font-size: 13px;
}
@@ -35,6 +35,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
import { ClientsService } from 'app/clients/clients.service';
import { LoanOriginationService } from 'app/loan-origination/loan-origination.service';
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
import { LosPreviewSection } from '../los-preview-section';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
/**
* Loans Account Details Step
@@ -64,6 +70,96 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
private settingsService = inject(SettingsService);
private commons = inject(Commons);
private catalog = inject(LoanProductCatalogService);
private clientsService = inject(ClientsService);
private originationService = inject(LoanOriginationService);
/**
* Set when this screen was opened from the Loan Origination pipeline (?los=<application no>): the category, type and product
* come from that application and cannot be changed, only what a loan account needs is asked, and the customer's details are
* shown from the client record. Opened any other way, the screen is exactly as it always was.
*/
losApplicationNo: string | null = null;
losApplication: LoanApplication | null = null;
/**
* The customer's Loan Origination applications an account can be opened for: at Customer Signing, still in progress and with no
* account opened yet. They are matched to loan products by name, so the category and type come from the product, not from a list here.
*/
private losCandidates: { app: LoanApplication; productId: number; group: string; type: string; label: string }[] = [];
/** The candidates that fit the category, type and product chosen above; refreshed whenever one of them changes. */
losChoices: { app: LoanApplication; productId: number; label: string }[] = [];
losError = '';
losProductName = '';
/** The customer's details for the Customer block (read from the client record, KYC and address). */
losCustomer: any = null;
/** The application and the customer it was raised for, for the Preview. */
previewSections(): LosPreviewSection[] {
const app = this.losApplication;
const c = this.losCustomer;
if (!app) {
return [];
}
const sections: LosPreviewSection[] = [
{
title: 'Loan Origination application',
rows: [
{ label: 'Application no.', value: app.applicationNo },
{ label: 'Product', value: app.productName ?? '—' },
{ label: 'Branch', value: app.branchName ?? '—' }
]
}
];
if (c && !c.missing) {
const a = c.address;
sections.push({
title: 'Customer',
rows: [
{ label: 'Client ID', value: String(c.clientId ?? '—') },
{ label: 'Name', value: c.name ?? '—' },
{ label: 'Client type', value: c.clientType ?? '—' },
{ label: "Father's name", value: c.fatherName ?? '—' },
{ label: 'Gender', value: c.gender ?? '—' },
{
label: 'Date of birth',
value: Array.isArray(c.dateOfBirth) ? c.dateOfBirth.slice(0, 3).reverse().join('-') : (c.dateOfBirth ?? '—')
},
{ label: 'Mobile', value: c.mobile ?? '—' },
{ label: 'Occupation', value: c.occupation ?? '—' },
{ label: 'PAN', value: c.pan ?? '—' },
{ label: 'Aadhaar', value: c.aadhaar ?? '—' },
{ label: 'KYC status', value: c.kycStatus ?? '—' },
{ label: 'Risk', value: c.risk ?? '—' },
{
label: 'Address',
value: a
? [
...a.lines,
a.city,
a.district,
a.state,
a.pincode
]
.filter(Boolean)
.join(', ')
: '—'
}
]
});
}
return sections;
}
/** An entity (company, firm, trust) has related persons -- its authorised signatories -- shown on their own step; a person has none. */
losIsNonIndividual = false;
losRelatedPersons: {
clientId: number | null;
title: string;
name: string;
pincode: string;
birthDate: any;
address: string;
loading: boolean;
}[] = [];
//** Defining PlaceHolders for the search bar */
placeHolderLabel = '';
@@ -135,7 +231,22 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.createLoansAccountDetailsForm();
}
/** The category and type of the application's product, named for the read-only fields. */
get losCategoryLabel(): string {
return PRODUCT_TYPE_GROUPS.find((g) => g.id === this.categoryFilter)?.label ?? '';
}
get losTypeLabel(): string {
return (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []).find((s) => s.id === this.typeFilter)?.label ?? '';
}
/** True when the screen was opened from a Loan Origination application. */
get losMode(): boolean {
return !!this.losApplicationNo;
}
ngOnInit() {
this.losApplicationNo = this.route.snapshot.queryParamMap.get('los');
this.placeHolderLabel = this.translateService.instant('labels.text.Search');
this.noEntriesFoundLabel = this.translateService.instant('labels.text.No data found');
this.maxDate = this.settingsService.maxFutureDate;
@@ -164,6 +275,279 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
});
this.productData.next(this.productList.slice());
this.loadActiveProducts();
if (this.losMode) {
this.loadLosApplication();
} else {
this.loadLosCandidates();
this.loansAccountDetailsForm
.get('productId')
?.valueChanges.pipe(takeUntil(this._onDestroy))
.subscribe(() => this.refreshLosChoices());
}
}
private sameName(a: string | undefined, b: string | undefined): boolean {
const clean = (v: string | undefined) => (v ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
return !!clean(a) && clean(a) === clean(b);
}
/** Reads this customer's applications that are ready for an account and keeps those with no account yet. */
private loadLosCandidates(): void {
const clientId = Number(this.loansAccountTemplate?.clientId);
if (!clientId) {
return;
}
forkJoin({
apps: this.originationService.list().pipe(catchError(() => of([] as LoanApplication[]))),
catalog: this.catalog.activeProducts().pipe(catchError(() => of([])))
}).subscribe(({ apps }) => {
const ready = apps.filter(
(a) => a.clientId === clientId && a.overallStatus === 'IN_PROGRESS' && a.currentStage === 'CUSTOMER_SIGNING'
);
if (!ready.length) {
return;
}
// an application that already has its account cannot be linked a second time
forkJoin(ready.map((a) => this.originationService.loanForApplication(a.applicationNo))).subscribe((loans) => {
this.losCandidates = ready
.filter((_a, i) => !loans[i])
.map((app) => {
const product = this.allProducts.find((p: any) => this.sameName(p.name, app.productName));
const ext = product ? this.catalog.classification(product.id) : {};
return {
app,
productId: product ? Number(product.id) : 0,
group: ext['productTypeGroup'] ?? '',
type: ext['productTypeSubGroup'] ?? '',
label:
app.applicationNo +
' · ' +
(app.productName ?? '') +
' · ₹' +
Number(app.requestedAmount).toLocaleString('en-IN') +
' · ' +
app.tenureMonths +
' months'
};
})
.filter((c) => c.productId > 0);
this.refreshLosChoices();
});
});
}
/** The applications that fit what is chosen above: category, product type and product. Nothing chosen shows all of them. */
refreshLosChoices(): void {
const productId = Number(this.loansAccountDetailsForm.get('productId')?.value) || 0;
this.losChoices = this.losCandidates.filter(
(c) =>
(!this.categoryFilter || c.group === this.categoryFilter) &&
(!this.typeFilter || c.type === this.typeFilter) &&
(!productId || c.productId === productId)
);
}
/** The product picked is an Advances product (by its classification): the Limit & Security and SMS Alerts steps apply to it. */
get advancesSelected(): boolean {
const id = this.loansAccountDetailsForm?.get('productId')?.value;
return !!id && this.catalog.classification(id)['productTypeGroup'] === 'advances';
}
get hasLosCandidates(): boolean {
return this.losCandidates.length > 0;
}
/** Opens the screen for the chosen application: the same screen as when it is opened from Loan Origination. */
useLosApplication(applicationNo: string): void {
const clientId = this.loansAccountTemplate?.clientId;
if (!applicationNo || !clientId) {
return;
}
window.location.hash = '#/clients/' + clientId + '/loans-accounts/create?los=' + encodeURIComponent(applicationNo);
window.location.reload();
}
/** Reads the application and sets the category, type and product from it; the product is then fixed. */
private loadLosApplication(): void {
this.originationService.get(this.losApplicationNo as string).subscribe({
next: (app) => {
this.losApplication = app;
// An application can only be linked to its own customer, and only once it is ready for an account.
const clientId = Number(this.loansAccountTemplate?.clientId);
if (clientId && app.clientId !== clientId) {
this.losError =
'Application ' + app.applicationNo + ' belongs to another customer and cannot be linked to this account.';
this.losApplication = null;
return;
}
if (
this.route.snapshot.queryParamMap.get('view') !== '1' &&
(app.currentStage !== 'CUSTOMER_SIGNING' || app.overallStatus !== 'IN_PROGRESS')
) {
this.losError =
'Application ' +
app.applicationNo +
' is not at Customer Signing, so an account cannot be opened for it yet.';
this.losApplication = null;
return;
}
const wanted = (app.productName ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
const product = this.allProducts.find(
(p: any) => (p.name ?? '').trim().toLowerCase().replace(/\s+/g, ' ') === wanted
);
if (!product) {
this.losError = 'The product "' + app.productName + '" was not found among the loan products.';
} else {
// The product catalog is read first, so its category and type are known when they are named.
this.catalog.activeProducts().subscribe(() => {
const ext = this.catalog.classification(product.id);
this.categoryFilter = ext['productTypeGroup'] ?? '';
this.typeFilter = ext['productTypeSubGroup'] ?? '';
this.losProductName = product.name;
const today = this.settingsService.businessDate;
this.loansAccountDetailsForm.patchValue({
productId: product.id,
submittedOnDate: today,
expectedDisbursementDate: today
});
});
}
this.loadLosCustomer(app);
},
error: () => {
this.losError = 'Could not read the loan origination application ' + this.losApplicationNo + '.';
}
});
}
/**
* The entity's authorised signatories are stored as two comma-separated columns, names and client ids in the same order. Each one
* linked to a client is filled in from that client's own record (title, name, birth date, address, pincode).
*/
private loadLosRelatedPersons(entity: any): void {
const split = (v: any): string[] =>
String(v ?? '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
const names = split(entity?.clientNonPersonDetails?.authorizedSignatory);
const ids = split(entity?.clientNonPersonDetails?.authorizedSignatoryClientIds);
const count = Math.max(names.length, ids.length);
this.losRelatedPersons = Array.from({ length: count }, (_u, i) => {
const id = ids[i] && Number.isFinite(Number(ids[i])) ? Number(ids[i]) : null;
return {
clientId: id,
title: '',
name: names[i] ?? '',
pincode: '',
birthDate: null as any,
address: '',
loading: id !== null
};
});
const quiet = catchError(() => of(null));
this.losRelatedPersons
.filter((r) => r.clientId !== null)
.forEach((row) => {
forkJoin({
person: this.clientsService.getClientData(String(row.clientId), true).pipe(quiet),
address: this.clientsService.getClientAddressData(String(row.clientId), true).pipe(quiet)
}).subscribe(({ person, address }: any) => {
const list: any[] = Array.isArray(address) ? address : [];
const addr = list.find((a) => a.isActive) ?? list[0];
row.title = person?.salutation?.name ?? '';
row.name = person?.displayName ?? row.name;
row.birthDate = person?.dateOfBirth ?? null;
row.pincode = addr?.postalCode ?? '';
row.address = addr
? [
addr.addressLine1,
addr.addressLine2,
addr.addressLine3,
addr.city,
addr.stateName
]
.filter(Boolean)
.join(', ')
: '';
row.loading = false;
});
});
}
/** The customer's id, name, address, risk and the rest a loan needs, from the client record (nothing typed again). */
private loadLosCustomer(app: LoanApplication): void {
const clientId = String(app.clientId ?? this.loansAccountTemplate?.clientId ?? '');
if (!clientId) {
this.losCustomer = { missing: true };
return;
}
const quiet = catchError(() => of(null));
forkJoin({
client: this.clientsService.getClientData(clientId, true).pipe(quiet),
kyc: this.clientsService.getClientKyc(clientId, true).pipe(quiet),
address: this.clientsService.getClientAddressData(clientId, true).pipe(quiet),
family: this.clientsService.getClientFamilyMembers(clientId).pipe(quiet)
}).subscribe(({ client, kyc, address, family }: any) => {
const addresses: any[] = Array.isArray(address) ? address : [];
const addr =
addresses.find((a) => a.isActive && /permanent/i.test(a.addressType ?? '')) ??
addresses.find((a) => a.isActive) ??
addresses[0];
const members: any[] = Array.isArray(family) ? family : [];
const father = members.find((m) => /father/i.test(m.relationship ?? ''));
const riskLabels: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
this.losIsNonIndividual = !!client?.legalForm && client.legalForm.id !== 1;
if (this.losIsNonIndividual) {
this.loadLosRelatedPersons(client);
}
this.losCustomer = {
clientId: client?.id ?? clientId,
accountNo: client?.accountNo,
name: client?.displayName ?? app.applicantName,
// Individual or Non-individual, from the client's legal form (an entity -- firm, company, trust -- is non-individual).
clientType: client?.legalForm
? /^legalFormType\.individual$/i.test(client.legalForm.code ?? '') || client.legalForm.id === 1
? 'Individual'
: 'Non-Individual'
: null,
fatherName: father
? [
father.firstName,
father.middleName,
father.lastName
]
.filter(Boolean)
.join(' ')
: null,
gender: client?.gender?.name,
dateOfBirth: client?.dateOfBirth,
mobile: client?.mobileNo,
occupation: client?.occupation?.name ?? client?.occupationCategory?.name,
nationality: client?.nationality,
branch: client?.officeName ?? app.branchName,
minor: client?.isMinor === true,
address: addr
? {
lines: [
addr.addressLine1,
addr.addressLine2,
addr.addressLine3
].filter(Boolean),
city: addr.city,
district: addr.countyDistrict,
state: addr.stateName,
pincode: addr.postalCode
}
: null,
pan: kyc?.panNumber,
panVerified: !!kyc?.panVerified,
aadhaar: kyc?.aadhaarNumberMasked,
aadhaarVerified: !!kyc?.aadhaarVerified,
kycStatus: kyc?.kycStatusCode ? String(kyc.kycStatusCode).replace('clientKycStatus.', '') : null,
risk: kyc ? (riskLabels[kyc.riskCategory] ?? null) : null
};
});
}
/**
@@ -190,6 +574,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
applyProductFilters(): void {
this.refreshLosChoices();
this.productList = this.allProducts.filter((product: any) => {
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
return false;
@@ -0,0 +1,51 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h4 class="mat-h4">Guarantor master</h4>
<p class="lgs-note">
Guarantors required by the product: <strong>{{ required }}</strong> &middot; named at Loan Origination:
<strong>{{ rows.length }}</strong>
</p>
<table class="lgs-table">
<thead>
<tr>
<th>Client ID</th>
<th>Guarantor name</th>
<th>Membership (share a/c)</th>
<th>Member</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.clientId) {
<tr>
<td>{{ row.clientId }}</td>
<td>{{ row.name }}</td>
<td>{{ row.membership }}</td>
<td>{{ row.member ? 'Yes' : 'No' }}</td>
<td>{{ row.address }}</td>
</tr>
} @empty {
<tr>
<td colspan="5">No guarantor was named for this application.</td>
</tr>
}
</tbody>
</table>
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
Previous
</button>
<button mat-raised-button matStepperNext>
Next
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
@@ -0,0 +1,32 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.lgs-note {
margin: 0 0 12px;
font-size: 13px;
color: #475569;
}
.lgs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f8fafc;
color: #475569;
font-weight: 600;
}
}
@@ -0,0 +1,119 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, inject } from '@angular/core';
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from 'app/clients/clients.service';
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
import { LosPreviewSection } from '../los-preview-section';
interface GuarantorRow {
clientId: number;
name: string;
membership: string;
member: boolean;
address: string;
}
/**
* Guarantors: the people named for this loan at Loan Origination (each an onboarded client), with their membership (share
* account) and address from the client record. Read-only here.
*/
@Component({
selector: 'mifosx-loans-account-los-guarantors-step',
templateUrl: './loans-account-los-guarantors-step.component.html',
styleUrls: ['./loans-account-los-guarantors-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepperPrevious,
MatStepperNext,
FaIconComponent
]
})
export class LoansAccountLosGuarantorsStepComponent implements OnChanges {
private clientsService = inject(ClientsService);
@Input() losApplication: LoanApplication | null = null;
rows: GuarantorRow[] = [];
private loadedFor: string | null = null;
get required(): number {
return this.losApplication?.guarantorsRequiredCount ?? 0;
}
/** Everything on this step, for the Preview. */
previewSections(): LosPreviewSection[] {
return [
{
title: 'Guarantors',
rows: this.rows.map((r, i) => ({
label: 'Guarantor ' + (i + 1),
value:
r.name +
' (client ' +
r.clientId +
') · member ' +
(r.member ? 'yes, ' + r.membership : 'no') +
' · ' +
r.address
}))
}
];
}
ngOnChanges(): void {
const app = this.losApplication;
if (!app || this.loadedFor === app.applicationNo) {
return;
}
this.loadedFor = app.applicationNo;
const guarantors = app.guarantors ?? [];
this.rows = guarantors.map((g) => ({
clientId: g.clientId,
name: g.name,
membership: g.shareAccountNos?.join(', ') || '—',
member: g.hasShareAccount,
address: '…'
}));
if (!guarantors.length) {
return;
}
forkJoin(
guarantors.map((g) =>
this.clientsService.getClientAddressData(String(g.clientId), true).pipe(catchError(() => of([])))
)
).subscribe((all: any[]) => {
this.rows = this.rows.map((row, i) => {
const list: any[] = Array.isArray(all[i]) ? all[i] : [];
const a =
list.find((x) => x.isActive && /permanent/i.test(x.addressType ?? '')) ??
list.find((x) => x.isActive) ??
list[0];
const text = a
? [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.townVillage,
a.city,
a.countyDistrict,
a.postalCode
]
.filter(Boolean)
.join(', ')
: '';
return { ...row, address: text || '—' };
});
});
}
}
@@ -0,0 +1,171 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="form">
<div class="lls-grid">
<div>
<span class="lls-label">Nature of limit</span><span>{{ natureOfLimit }}</span>
</div>
<div>
<span class="lls-label">Security type</span><span>{{ securityType }}</span>
</div>
<div>
<span class="lls-label">Secured / Unsecured</span><span>{{ secured ? 'Secured' : 'Unsecured' }}</span>
</div>
<div>
<span class="lls-label">Priority sector</span><span>{{ prioritySector ? 'Yes' : 'No' }}</span>
</div>
<div>
<span class="lls-label">Sanction date</span><span>{{ sanctionDate }}</span>
</div>
<div>
<span class="lls-label">Sanction no.</span><span>{{ sanctionNo }}</span>
</div>
@if (!manual) {
<div>
<span class="lls-label">Sanction limit</span
><span>₹{{ losApplication?.requestedAmount | number: '1.0-0' }}</span>
</div>
<div>
<span class="lls-label">Period</span><span>{{ losApplication?.tenureMonths }} months</span>
</div>
}
<div>
<span class="lls-label">Margin %</span><span>{{ productExt['margin'] ?? '—' }}</span>
</div>
</div>
@if (manual) {
<p class="lls-note">
This account is being opened without a Loan Origination application, so the sanction is entered here. To take it,
and the security, from an application, pick one on the Details step.
</p>
<div class="layout-row-wrap layout-lt-md-column gap-2percent lls-inputs">
<mat-form-field class="flex-48">
<mat-label>Sanction no.</mat-label>
<input matInput formControlName="sanctionNoInput" maxlength="40" required />
<mat-error>Sanction no. is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Sanction date</mat-label>
<input matInput type="date" formControlName="sanctionDateInput" required />
<mat-error>Sanction date is <strong>required</strong></mat-error>
</mat-form-field>
</div>
}
<div class="layout-row-wrap layout-lt-md-column gap-2percent lls-inputs">
<mat-form-field class="flex-48">
<mat-label>Purpose of the loan</mat-label>
<input matInput formControlName="purpose" maxlength="200" required />
<mat-error>Purpose is <strong>required</strong></mat-error>
</mat-form-field>
@if (prioritySector) {
<mat-form-field class="flex-48">
<mat-label>Priority sector category</mat-label>
<mat-select formControlName="prioritySectorCategory" required>
@for (category of prioritySectorCategories; track category) {
<mat-option [value]="category">{{ category }}</mat-option>
}
</mat-select>
<mat-error>Priority sector category is <strong>required</strong></mat-error>
</mat-form-field>
}
@if (showRenewal) {
<mat-form-field class="flex-48">
<mat-label>Document renewal date</mat-label>
<input matInput type="date" formControlName="documentRenewalDate" />
</mat-form-field>
}
</div>
<h4 class="mat-h4">Product terms</h4>
<div class="lls-grid lls-grid--plain">
@for (row of productRows; track row.label) {
<div>
<span class="lls-label">{{ row.label }}</span
><span>{{ row.value }}</span>
</div>
}
</div>
<h4 class="mat-h4">SMA</h4>
<div class="lls-grid lls-grid--plain">
@for (row of smaRows; track row.label) {
<div>
<span class="lls-label">{{ row.label }}</span
><span>{{ row.value }}</span>
</div>
}
</div>
<h4 class="mat-h4">NPA</h4>
<div class="lls-grid lls-grid--plain">
@for (row of npaRows; track row.label) {
<div>
<span class="lls-label">{{ row.label }}</span
><span>{{ row.value }}</span>
</div>
}
</div>
@if (!manual) {
<h4 class="mat-h4">As sanctioned</h4>
<div class="lls-grid lls-grid--plain">
@for (row of sanctionRows; track row.label) {
<div>
<span class="lls-label">{{ row.label }}</span
><span>{{ row.value }}</span>
</div>
}
</div>
<h4 class="mat-h4">Security details</h4>
<table class="lls-table">
<thead>
<tr>
<th>Reference</th>
<th>Type</th>
<th>Owner</th>
<th>Value</th>
<th>Rate</th>
<th>Maturity</th>
<th>Lien for this loan</th>
</tr>
</thead>
<tbody>
@for (row of security; track row.reference) {
<tr>
<td>{{ row.reference }}</td>
<td>{{ row.kind }}</td>
<td>{{ row.owner }}</td>
<td>₹{{ row.value | number: '1.2-2' }}</td>
<td>{{ row.rate }}</td>
<td>{{ row.maturity }}</td>
<td>₹{{ row.lien | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7">No security is recorded for this application.</td>
</tr>
}
</tbody>
</table>
}
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
Previous
</button>
<button mat-raised-button matStepperNext [disabled]="form.invalid">
Next
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</form>
@@ -0,0 +1,64 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.lls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px 16px;
border: 1px solid #c9d7f2;
border-radius: 8px;
background: #f3f7ff;
padding: 14px 16px;
margin-bottom: 20px;
}
.lls-grid--plain {
background: #fff;
border-color: #e2e8f0;
}
.lls-label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #5b6b8c;
}
.lls-inputs {
margin-bottom: 8px;
}
.lls-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f8fafc;
color: #475569;
font-weight: 600;
}
}
.lls-note {
margin: 0 0 12px;
padding: 10px 14px;
border: 1px solid #fcd34d;
border-radius: 10px;
background: #fffbeb;
color: #92400e;
font-size: 13px;
}
@@ -0,0 +1,493 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
LoanApplication,
PRIORITY_SECTOR_CATEGORIES,
depositKindCode,
sanctioningAuthority
} from 'app/loan-origination/loan-origination-data';
import { LoanOriginationService } from 'app/loan-origination/loan-origination.service';
import { LosPreviewSection } from '../los-preview-section';
/** One pledged deposit or policy, as listed under the limit. */
interface SecurityRow {
reference: string;
kind: string;
owner: string;
value: number;
rate: string;
maturity: string;
lien: number;
}
interface InfoRow {
label: string;
value: string;
}
/**
* Limit & Security: what an Advances account is for, taken from the Loan Origination application and the product, with the
* security (the pledged FD / RD / DDS or LIC policy) listed below. The officer only gives the purpose and the date the
* documents are due for renewal.
*/
@Component({
selector: 'mifosx-loans-account-los-limit-step',
templateUrl: './loans-account-los-limit-step.component.html',
styleUrls: ['./loans-account-los-limit-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepperPrevious,
MatStepperNext,
FaIconComponent
]
})
export class LoansAccountLosLimitStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private http = inject(HttpClient);
private originationService = inject(LoanOriginationService);
@Input() losApplication: LoanApplication | null = null;
@Input() productId: any = null;
readonly form: UntypedFormGroup = this.formBuilder.group({
purpose: [
'',
Validators.required
],
documentRenewalDate: [''],
prioritySectorCategory: [''],
// Asked only when the account is opened without a Loan Origination application (nothing to take them from).
sanctionNoInput: [''],
sanctionDateInput: ['']
});
/** RBI's priority sector lending categories. */
/** From the system Code "PrioritySectorCategory"; the built-in list shows until it is read. */
prioritySectorCategories: string[] = PRIORITY_SECTOR_CATEGORIES;
productExt: Record<string, any> = {};
productName = '';
security: SecurityRow[] = [];
private loadedFor: string | null = null;
/** True when the account is opened without a Loan Origination application: the sanction is typed in, there is no security list. */
get manual(): boolean {
return !this.losApplication;
}
ngOnChanges(): void {
const app = this.losApplication;
if (!this.productId) {
return;
}
// the sanction number and date are needed only when there is no application to take them from
for (const name of [
'sanctionNoInput',
'sanctionDateInput'
]) {
const control = this.form.get(name)!;
control.setValidators(app ? null : Validators.required);
control.updateValueAndValidity({ emitEvent: false });
}
const key = (app?.applicationNo ?? 'manual') + '|' + this.productId;
if (this.loadedFor === key) {
return;
}
this.loadedFor = key;
this.originationService.prioritySectorCategories().subscribe((names) => (this.prioritySectorCategories = names));
this.http.get<any>('/loanproducts/' + this.productId).subscribe((product) => {
this.productName = product?.name ?? '';
this.productExt = product?.advanceExtensions ?? {};
const category = this.form.get('prioritySectorCategory')!;
category.setValidators(this.prioritySector ? Validators.required : null);
// the category was set at Sanction in Loan Origination: it is shown here, not chosen again
if (this.prioritySector && app?.prioritySectorCategory) {
category.setValue(app.prioritySectorCategory);
category.disable();
} else {
category.enable();
}
category.updateValueAndValidity();
// the purpose was given in Loan Origination after KYC: shown here, not asked again
const purpose = this.form.get('purpose')!;
if (app?.loanPurpose?.trim()) {
purpose.setValue(app.loanPurpose.trim());
purpose.disable();
} else {
purpose.enable();
}
this.setRenewalDefault();
});
if (app) {
this.loadSecurity(app);
} else {
this.security = [];
}
}
/** Priority sector: the officer's decision at Sanction in Loan Origination, else what the product says. */
get prioritySector(): boolean {
const decided = this.losApplication?.pslClassified;
if (decided) {
return decided === 'yes';
}
return String(this.productExt['prioritySector'] ?? '').toLowerCase() === 'yes';
}
/** Weaker section, as decided in Loan Origination. */
get weakerSection(): boolean {
return this.losApplication?.weakerSection === 'yes';
}
/** The purpose the officer typed. */
get purposeText(): string {
return String(this.form.get('purpose')!.value ?? '').trim();
}
/** A loan on a deposit (FD / RD / DDS / LIC) runs for a fixed tenure; its documents are not renewed. */
get showRenewal(): boolean {
return this.losApplication ? !this.losApplication.depositSelection : !!this.productExt['renewalAllowed'];
}
/** The nature of the limit: the loan product (Loan against FD, LIC Policy, Cash Credit ...). */
get natureOfLimit(): string {
return this.losApplication?.productName ?? (this.productName || '—');
}
get securityType(): string {
return this.productExt['securityType'] ?? '—';
}
/** Secured when the product is backed by a security; a product whose security type is none is unsecured. */
get secured(): boolean {
const type = String(this.productExt['securityType'] ?? '').trim();
return !!type && !/^none/i.test(type);
}
/** The date the limit was sanctioned: the day the sanctioning authority approved it. */
get sanctionDate(): string {
const app = this.losApplication;
if (!app) {
// typed in as yyyy-MM-dd, shown as dd-MM-yyyy
const typed = String(this.form.get('sanctionDateInput')!.value ?? '');
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typed);
return m ? m[3] + '-' + m[2] + '-' + m[1] : '—';
}
return app?.creditCommitteeApproval?.date && app.creditCommitteeApproval.date !== '—'
? app.creditCommitteeApproval.date
: '—';
}
/** The sanction number: the committee's resolution number, else the application number. */
get sanctionNo(): string {
const app = this.losApplication;
if (!app) {
return String(this.form.get('sanctionNoInput')!.value ?? '').trim() || '—';
}
return app?.committeeResolutionNo || app?.applicationNo || '—';
}
/** What the product was created with (Product Creation) that bears on this limit. */
get productRows(): InfoRow[] {
const e = this.productExt;
const yesNo = (v: any) => (v === true || v === 'true' ? 'Yes' : 'No');
const basis: Record<string, string> = {
balance_accrued: 'Deposit balance + accrued interest',
balance: 'Deposit balance only',
surrender: 'Surrender value',
gold_value: 'Gold value',
commodity_value: 'Commodity value'
};
const rows: InfoRow[] = [
{ label: 'Facility', value: e['facilityType'] ?? '—' },
{ label: 'Maximum LTV', value: e['ltv'] !== undefined ? e['ltv'] + '%' : '—' },
{ label: 'Eligible amount basis', value: basis[e['eligibleAmountBasis']] ?? e['eligibleAmountBasis'] ?? '—' },
{
label: 'Lien marked',
value: (e['lienMarkingStage'] ?? '—') === 'sanction' ? 'At sanction' : 'At disbursement'
},
{
label: 'Renewal',
value: e['renewalAllowed']
? 'Allowed, every ' +
(e['renewalPeriodMonths'] ?? '—') +
' months (grace ' +
(e['renewalGraceDays'] ?? 0) +
' days)'
: 'Not allowed'
},
{ label: 'Pre-closure', value: e['preclosureAllowed'] ?? '—' },
{ label: 'Lock-in', value: (e['lockInMonths'] ?? 0) + ' months' },
{ label: 'Tenure within security maturity', value: yesNo(e['lienTenureNotExceedMaturity']) }
];
return rows;
}
/** When the account is watched as SMA (special mention), from the product. */
get smaRows(): InfoRow[] {
const sma = Number(this.productExt['smaStartAfterMonths'] ?? 0);
return [
{
label: 'SMA counting starts',
value: sma > 0 ? sma + ' month' + (sma > 1 ? 's' : '') + ' after disbursement' : 'From disbursement'
},
{ label: 'SMA-0', value: 'Overdue 1 to 30 days' },
{ label: 'SMA-1', value: 'Overdue 31 to 60 days' },
{ label: 'SMA-2', value: 'Overdue 61 to 90 days' }
];
}
/** When the account turns NPA, in words, from the product's NPA settings. */
get npaRows(): InfoRow[] {
const e = this.productExt;
const outOfOrder: Record<string, string> = {
LIMIT_EXCEEDED: 'the outstanding stays above the limit / drawing power continuously',
NO_CREDITS_90_DAYS: 'there are no credits for 90 days, or credits are less than the interest debited',
DP_NOT_RENEWED: 'the drawing power is not reassessed or renewed within 180 days'
};
const trigger: Record<string, string> = {
maturity: 'Dues stay overdue for 90 days (after maturity, or after an instalment falls due)',
out_of_order: 'The account is "out of order": ' + (outOfOrder[e['npaOutOfOrderBasis']] ?? '—'),
crop_season: 'Dues stay overdue for the crop season(s) (KCC)'
};
const interest: Record<string, string> = {
undebited: 'Moved to the Undebited Interest GL',
suspense: 'Moved to the Interest Suspense GL'
};
const rows: InfoRow[] = [
{ label: 'NPA when', value: trigger[e['npaTrigger']] ?? e['npaTrigger'] ?? '—' },
{ label: 'Interest after NPA', value: interest[e['interestAfterNpa']] ?? e['interestAfterNpa'] ?? '—' }
];
// A stock statement gives the drawing power on stock or receivables; a deposit loan has none.
if (e['stockStatementValidityDays'] && !this.losApplication?.depositSelection) {
rows.push({ label: 'Stock statement valid for', value: e['stockStatementValidityDays'] + ' days' });
}
rows.push({
label: 'Upgrade from NPA',
value: e['npaUpgradeOnlyAllArrears'] ? 'Only when all arrears are cleared' : 'As per bank policy'
});
if (e['npaKeepStandardIfDepositCovers']) {
rows.push({ label: 'Deposit-covered loan', value: 'Stays Standard while the deposit covers the dues' });
}
return rows;
}
/** What was decided on the application at Loan Origination. */
get sanctionRows(): InfoRow[] {
const app = this.losApplication;
if (!app) {
return [];
}
const approver = (field: string, label: string): InfoRow => {
const ap = (app as any)[field];
return {
label,
value: ap?.approverName && ap.approverName !== '—' ? ap.approverName + (ap.date ? ' · ' + ap.date : '') : '—'
};
};
const sel = app.depositSelection;
const share = sel?.securityValue
? ((app.requestedAmount / sel.securityValue) * 100).toFixed(1) + '% of the security'
: '—';
// A loan against a deposit has the one Sanction step, no committee, risk or legal approval.
const deposit = !!sel;
return [
{ label: 'Application no.', value: app.applicationNo },
{ label: 'Sanctioning authority', value: sanctioningAuthority(app.requestedAmount) },
{
label: deposit ? 'Sanction no.' : 'Resolution',
value: app.committeeResolutionNo ? app.committeeResolutionNo + ' dated ' + app.committeeResolutionDate : '—'
},
...(deposit ? [approver('creditCommitteeApproval', 'Sanctioned by')] : [
approver('creditCommitteeApproval', 'Loan Committee'),
approver('riskApproval', 'Risk approval'),
approver('legalApproval', 'Legal approval')
]),
approver('postCheckApproval', 'Post-disbursement check'),
{ label: 'Loan against the security', value: share },
{ label: 'Eligible amount', value: sel ? '₹' + Number(sel.eligibleAmount).toLocaleString('en-IN') : '—' },
{
label: 'Lien certificate',
value: app.lien?.certificateNo
? app.lien.certificateNo + (app.lien.markedOn ? ' · ' + app.lien.markedOn : '')
: 'Issued at disbursement'
}
];
}
/** A date written dd-MM-yyyy, as a Date (null when it is not a date). */
private parse(text: string | undefined): Date | null {
const m = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec((text ?? '').trim());
return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null;
}
private iso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
/** The documents come up for renewal a renewal period (the product's, usually a year) after the sanction. */
private setRenewalDefault(): void {
if (!this.showRenewal || this.form.get('documentRenewalDate')!.value) {
return;
}
const sanctioned = this.parse(this.sanctionDate);
const months = Number(this.productExt['renewalPeriodMonths'] ?? 12) || 12;
if (sanctioned) {
sanctioned.setMonth(sanctioned.getMonth() + months);
this.form.patchValue({ documentRenewalDate: this.iso(sanctioned) });
}
}
/** The deposits or the policy this limit is secured on, with what each is worth now. */
private loadSecurity(app: LoanApplication): void {
const sel = app.depositSelection;
if (!sel) {
return;
}
const lienShare = (value: number, total: number) =>
total > 0 ? Math.round((value / total) * app.requestedAmount * 100) / 100 : app.requestedAmount;
if (sel.policy) {
this.security = [
{
reference: sel.policy.policyNo,
kind: 'LIC policy (' + (sel.policy.insurer || 'LIC') + ')',
owner: sel.policy.policyHolder,
value: sel.policy.surrenderValue,
rate: '—',
maturity: sel.policy.maturityDate,
lien: app.requestedAmount
}
];
return;
}
const ids = sel.accountIds ?? [];
if (depositKindCode(app.productCode) === 'DS') {
this.originationService.listDdsAccounts(app.clientId as number).subscribe((parties) => {
const picked = parties.filter((p: any) => ids.includes(p.id));
const total = picked.reduce((s: number, p: any) => s + Number(p.currentBalance ?? 0), 0);
this.security = picked.map((p: any) => ({
reference: p.accountNo,
kind: 'DDS',
owner: app.applicantName,
value: Number(p.currentBalance ?? 0),
rate: p.interestRate + '%',
maturity: String(p.maturityDate ?? '—'),
lien: lienShare(Number(p.currentBalance ?? 0), total)
}));
});
return;
}
const kind = depositKindCode(app.productCode) === 'RD' ? 'RD' : 'FD';
forkJoin(
ids.map((id: number) => this.originationService.getDepositAccount(kind, id).pipe(catchError(() => of(null))))
).subscribe((accounts: any[]) => {
const list = accounts.filter((a) => !!a);
const total = list.reduce((s, a) => s + Number(a.summary?.accountBalance ?? 0), 0);
this.security = list.map((a) => ({
reference: a.accountNo,
kind: kind === 'RD' ? 'Recurring deposit' : 'Fixed deposit',
owner: app.applicantName,
value: Number(a.summary?.accountBalance ?? 0),
rate: (a.nominalAnnualInterestRate ?? '—') + '%',
maturity: Array.isArray(a.maturityDate)
? a.maturityDate.slice(0, 3).reverse().join('-')
: String(a.maturityDate ?? '—'),
lien: lienShare(Number(a.summary?.accountBalance ?? 0), total)
}));
});
}
/** Everything on this step, for the Preview. */
previewSections(): LosPreviewSection[] {
const f = this.form.getRawValue();
const limit: InfoRow[] = [
{ label: 'Nature of limit', value: this.natureOfLimit },
{ label: 'Security type', value: this.securityType },
{ label: 'Secured / Unsecured', value: this.secured ? 'Secured' : 'Unsecured' },
{ label: 'Priority sector', value: this.prioritySector ? 'Yes' : 'No' },
{ label: 'Sanction date', value: this.sanctionDate },
{ label: 'Sanction no.', value: this.sanctionNo },
{ label: 'Purpose of the loan', value: f.purpose || '—' }
];
if (this.prioritySector) {
limit.push({ label: 'Priority sector category', value: f.prioritySectorCategory || '—' });
}
if (this.losApplication?.pslOverrideReason) {
limit.push({ label: 'Priority sector — differs from product', value: this.losApplication.pslOverrideReason });
}
if (this.losApplication?.weakerSection) {
limit.push({
label: 'Weaker section',
value: this.weakerSection ? 'Yes — ' + (this.losApplication.weakerSectionType || '—') : 'No'
});
}
if (this.showRenewal) {
limit.push({ label: 'Document renewal date', value: f.documentRenewalDate || '—' });
}
const security = this.security.map((s) => ({
label: s.reference + ' (' + s.kind + ')',
value:
'Owner ' +
s.owner +
' · value ₹' +
s.value.toLocaleString('en-IN') +
' · maturity ' +
s.maturity +
' · lien ₹' +
s.lien.toLocaleString('en-IN')
}));
return [
{ title: 'Limit', rows: limit },
...(this.manual
? []
: [{ title: 'Security', rows: security.length ? security : [{ label: 'Security', value: '—' }] }]),
{ title: 'Product terms', rows: this.productRows },
{ title: 'SMA', rows: this.smaRows },
{ title: 'NPA', rows: this.npaRows },
...(this.manual ? [] : [{ title: 'As sanctioned', rows: this.sanctionRows }])
];
}
/** What is saved with the loan account (the loan_account_services datatable); dates as the server's date format. */
datatableValues(format: (isoDate: string) => string): Record<string, any> {
const sanctioned = this.parse(this.sanctionDate);
const data: Record<string, any> = {
sanction_no: this.sanctionNo === '—' ? undefined : this.sanctionNo,
secured: this.secured,
security_type: this.productExt['securityType'] || undefined,
loan_purpose_text: this.form.get('purpose')!.value?.trim() || undefined,
priority_sector: this.prioritySector,
priority_sector_category: (this.prioritySector && this.form.get('prioritySectorCategory')!.value) || undefined
};
if (this.losApplication?.weakerSection) {
data['weaker_section'] = this.weakerSection;
data['weaker_section_type'] = (this.weakerSection && this.losApplication.weakerSectionType) || undefined;
}
if (this.losApplication?.pslOverrideReason) {
data['psl_override_reason'] = this.losApplication.pslOverrideReason;
}
if (sanctioned) {
data['sanction_date'] = format(this.iso(sanctioned));
}
const renewal = this.showRenewal ? this.form.get('documentRenewalDate')!.value : '';
if (renewal) {
data['document_renewal_date'] = format(renewal);
}
return data;
}
}
@@ -0,0 +1,80 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="form">
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>SMS active</mat-label>
<mat-select formControlName="smsActive">
<mat-option value="yes">Yes</mat-option>
<mat-option value="no">No</mat-option>
</mat-select>
</mat-form-field>
@if (active) {
<mat-form-field class="flex-48">
<mat-label>SMS charge</mat-label>
<mat-select formControlName="smsCharge">
<mat-option value="yes">Yes</mat-option>
<mat-option value="no">No</mat-option>
</mat-select>
</mat-form-field>
}
</div>
@if (active) {
<h4 class="mat-h4">Free alerts</h4>
<div class="sms-checks">
<mat-checkbox formControlName="disbursementAlert">Account opened / limit sanctioned</mat-checkbox>
<mat-checkbox formControlName="dueAlert">Interest due reminder</mat-checkbox>
<mat-checkbox formControlName="overdueAlert">Overdue / limit exceeded notice</mat-checkbox>
<mat-checkbox formControlName="renewAlert">Limit expiry / renewal reminder</mat-checkbox>
<mat-checkbox formControlName="closureAlert">Account closed</mat-checkbox>
</div>
<h4 class="mat-h4">Paid alerts</h4>
<div class="sms-checks">
<mat-checkbox formControlName="repaymentAlert">Amount credited (deposit into the account)</mat-checkbox>
<mat-checkbox formControlName="debitAlert">Amount debited (drawing / charge)</mat-checkbox>
<mat-checkbox formControlName="chargeInterest">Interest and charges applied</mat-checkbox>
@if (chequeBookOffered) {
<mat-checkbox formControlName="chequeBook">Cheque book issued</mat-checkbox>
}
</div>
<div class="layout-row-wrap layout-lt-md-column gap-2percent sms-amounts">
<mat-form-field class="flex-48">
<mat-label>Debit alert above (₹)</mat-label>
<input matInput type="number" min="0" formControlName="debitAbove" />
<mat-hint>Alert only for debits greater than this amount</mat-hint>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Credit alert above (₹)</mat-label>
<input matInput type="number" min="0" formControlName="creditAbove" />
<mat-hint>Alert only for credits greater than this amount</mat-hint>
</mat-form-field>
</div>
<p class="sms-note">
SMS activation date: <strong>{{ activationDate }}</strong>
@if (form.getRawValue().smsCharge === 'yes') {
&middot; SMS charge date: <strong>{{ activationDate }}</strong>
}
</p>
}
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
Previous
</button>
<button mat-raised-button matStepperNext>
Next
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</form>
@@ -0,0 +1,24 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.sms-checks {
display: flex;
flex-wrap: wrap;
gap: 4px 28px;
margin-bottom: 12px;
}
.sms-amounts {
margin-top: 8px;
}
.sms-note {
margin: 8px 0 0;
font-size: 13px;
color: #475569;
}
@@ -0,0 +1,125 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { LosPreviewSection } from '../los-preview-section';
/**
* SMS Alerts: which messages the customer gets for this account. Free alerts (renewal and due reminders) and paid alerts
* (debits, credits, clearing ...). The activation date is the day the account is opened.
*/
@Component({
selector: 'mifosx-loans-account-los-sms-step',
templateUrl: './loans-account-los-sms-step.component.html',
styleUrls: ['./loans-account-los-sms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepperPrevious,
MatStepperNext,
FaIconComponent
]
})
export class LoansAccountLosSmsStepComponent {
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
/** Only a product that has a cheque book offers the cheque book alert. */
@Input() chequeBookOffered = false;
readonly activationDate: string = this.iso(this.settingsService.businessDate);
readonly form: UntypedFormGroup = this.formBuilder.group({
smsActive: ['yes'],
smsCharge: ['yes'],
disbursementAlert: [true],
dueAlert: [true],
overdueAlert: [true],
renewAlert: [true],
closureAlert: [true],
repaymentAlert: [true],
debitAlert: [true],
chargeInterest: [true],
chequeBook: [true],
debitAbove: [0],
creditAbove: [0]
});
get active(): boolean {
return this.form.getRawValue().smsActive === 'yes';
}
private iso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
/** Everything on this step, for the Preview. */
previewSections(): LosPreviewSection[] {
const v = this.form.getRawValue();
const yn = (b: boolean) => (b ? 'Yes' : 'No');
if (!this.active) {
return [{ title: 'SMS Alerts', rows: [{ label: 'SMS active', value: 'No' }] }];
}
const rows = [
{ label: 'SMS active', value: 'Yes' },
{ label: 'SMS charge', value: v.smsCharge === 'yes' ? 'Yes' : 'No' },
{ label: 'Account opened / limit sanctioned', value: yn(v.disbursementAlert) },
{ label: 'Interest due reminder', value: yn(v.dueAlert) },
{ label: 'Overdue / limit exceeded notice', value: yn(v.overdueAlert) },
{ label: 'Limit expiry / renewal reminder', value: yn(v.renewAlert) },
{ label: 'Account closed', value: yn(v.closureAlert) },
{ label: 'Amount credited', value: yn(v.repaymentAlert) },
{ label: 'Amount debited', value: yn(v.debitAlert) },
{ label: 'Interest and charges applied', value: yn(v.chargeInterest) }
];
if (this.chequeBookOffered) {
rows.push({ label: 'Cheque book issued', value: yn(v.chequeBook) });
}
rows.push(
{ label: 'Debit alert above', value: '₹' + (Number(v.debitAbove) || 0) },
{ label: 'Credit alert above', value: '₹' + (Number(v.creditAbove) || 0) },
{ label: 'SMS activation date', value: this.activationDate }
);
return [{ title: 'SMS Alerts', rows }];
}
/** What is saved with the loan account (the loan_account_services datatable); dates as the server's date format. */
datatableValues(format: (isoDate: string) => string): Record<string, any> {
const v = this.form.getRawValue();
const on = this.active;
const data: Record<string, any> = {
sms_active: on,
sms_charge: on && v.smsCharge === 'yes'
};
if (on) {
Object.assign(data, {
sms_disbursement_alert: !!v.disbursementAlert,
sms_due_alert: !!v.dueAlert,
sms_overdue_alert: !!v.overdueAlert,
sms_renew_alert: !!v.renewAlert,
sms_closure_alert: !!v.closureAlert,
// an Advances account has no repayments: a credit is money paid into it, a debit is a drawing or a charge
sms_credit_clearing: !!v.repaymentAlert,
sms_debit_clearing: !!v.debitAlert,
sms_charge_interest: !!v.chargeInterest,
sms_cheque_book: this.chequeBookOffered && !!v.chequeBook,
sms_debit_above: Number(v.debitAbove) || 0,
sms_credit_above: Number(v.creditAbove) || 0,
sms_activation_date: format(this.activationDate)
});
if (v.smsCharge === 'yes') {
data['sms_charge_date'] = format(this.activationDate);
}
}
return data;
}
}
@@ -6,6 +6,63 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (losSections?.length) {
<div class="lp">
@if (losMode) {
<div class="lp-hero">
<div class="lp-hero__who">
<span class="lp-hero__eyebrow">Review before you submit</span>
<h2 class="lp-hero__name">{{ losHighlight('Customer', 'Name') }}</h2>
<span class="lp-hero__sub">{{ losHighlight('Loan Origination application', 'Product') }}</span>
</div>
<div class="lp-kpis">
<div class="lp-kpi">
<span class="lp-kpi__label">Sanctioned amount</span>
<span class="lp-kpi__value">{{ losHighlight('Loan terms', 'Sanctioned amount') }}</span>
</div>
<div class="lp-kpi">
<span class="lp-kpi__label">Tenure</span>
<span class="lp-kpi__value">{{ losHighlight('Loan terms', 'Tenure') }}</span>
</div>
<div class="lp-kpi">
<span class="lp-kpi__label">Interest</span>
<span class="lp-kpi__value">{{ losHighlight('Loan terms', 'Debit interest rate') }}</span>
</div>
<div class="lp-kpi">
<span class="lp-kpi__label">Application</span>
<span class="lp-kpi__value">{{ losHighlight('Loan Origination application', 'Application no.') }}</span>
</div>
</div>
</div>
}
<div class="lp-grid">
@for (section of losSections; track section.title) {
<section class="lp-card" [class.lp-card--wide]="isWide(section.title)">
<header class="lp-card__head">
<fa-icon [icon]="sectionIcon(section.title)" class="lp-card__icon"></fa-icon>
<h3>{{ section.title }}</h3>
</header>
<dl class="lp-rows">
@for (row of section.rows; track $index) {
<div class="lp-row">
<dt>{{ row.label }}</dt>
<dd>
@if (row.value === 'Yes' || row.value === 'No') {
<span class="lp-pill" [class.lp-pill--yes]="row.value === 'Yes'">{{ row.value }}</span>
} @else {
{{ row.value }}
}
</dd>
</div>
}
</dl>
</section>
}
</div>
</div>
}
<div class="content responsive-column">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
@@ -100,204 +157,210 @@
</div>
}
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<!-- An account opened from Loan Origination lists its terms above (sanctioned amount, tenure, interest); an Advances
account has no repayment schedule, so the instalment terms are not repeated here. -->
@if (!losMode) {
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Principal' | translate }}:</span>
<span class="flex-60">
{{
loansAccount.principalAmount | currency: loansAccountProductTemplate.currency.code : 'symbol-narrow' : '1.2-2'
}}
<span class="m-l-5">{{ loansAccountProductTemplate.currency.code }}</span>
</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Loan Term' | translate }}:</span>
<span class="flex-60"
>{{ loansAccount.loanTermFrequency }}
{{
loansAccount.loanTermFrequencyType | find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
}}</span
>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Number of repayments' | translate }}:</span>
<span class="flex-60">{{ loansAccount.numberOfRepayments }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Repaid every' | translate }}:</span>
<span class="flex-60"
>{{ loansAccount.repaymentEvery }}
{{
loansAccount.repaymentFrequencyType | find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
}}
{{
loansAccount.repaymentFrequencyNthDayType
| find: loansAccountProductTemplate.repaymentFrequencyNthDayTypeOptions : 'id' : 'name'
}}
{{
loansAccount.repaymentFrequencyDayOfWeekType
| find: loansAccountProductTemplate.repaymentFrequencyDaysOfWeekTypeOptions : 'id' : 'name'
}}</span
>
</div>
@if (productEnableDownPayment) {
<mat-divider class="flex-fill"></mat-divider>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Enable Down Payments' | translate }}:</span>
<span class="flex-60">{{ loansAccount.enableDownPayment | yesNo }}</span>
<span class="flex-40">{{ 'labels.inputs.Principal' | translate }}:</span>
<span class="flex-60">
{{
loansAccount.principalAmount | currency: loansAccountProductTemplate.currency.code : 'symbol-narrow' : '1.2-2'
}}
<span class="m-l-5">{{ loansAccountProductTemplate.currency.code }}</span>
</span>
</div>
}
@if (loansAccount.repaymentsStartingFromDate) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.First repayment on' | translate }}:</span>
<span class="flex-60">{{ loansAccount.repaymentsStartingFromDate | dateFormat }}</span>
</div>
}
@if (loansAccount.interestChargedFromDate) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest charged from' | translate }}:</span>
<span class="flex-60">{{ loansAccount.interestChargedFromDate | dateFormat }}</span>
</div>
}
@if (loansAccount.interestRatePerPeriod) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Nominal interest rate' | translate }}:</span>
<span class="flex-40">{{ 'labels.inputs.Loan Term' | translate }}:</span>
<span class="flex-60"
>{{ loansAccount.interestRatePerPeriod }}&nbsp;&nbsp;{{
loansAccountProductTemplate.interestRateFrequencyType.value
>{{ loansAccount.loanTermFrequency }}
{{
loansAccount.loanTermFrequencyType
| find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
}}</span
>
</div>
}
@if (loansAccount.interestType) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest method' | translate }}: </span>
<span class="flex-60">{{ loansAccount.interestType }}</span>
<span class="flex-40">{{ 'labels.inputs.Number of repayments' | translate }}:</span>
<span class="flex-60">{{ loansAccount.numberOfRepayments }}</span>
</div>
}
@if (loansAccount.isEqualAmortization) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Is Equal Amortization' | translate }}:</span>
<span class="flex-60">{{ loansAccount.isEqualAmortization }}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Amortization' | translate }}</span>
<span class="flex-60">{{
loansAccount.amortizationType
| find: loansAccountProductTemplate.amortizationTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest calculation period' | translate }}: </span>
<span class="flex-60">{{
loansAccount.interestCalculationPeriodType
| find: loansAccountProductTemplate.interestCalculationPeriodTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
@if (loansAccount.allowPartialPeriodInterestCalculation) {
<div class="flex-fill">
<span class="flex-40"
>{{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }}:</span
<span class="flex-40">{{ 'labels.inputs.Repaid every' | translate }}:</span>
<span class="flex-60"
>{{ loansAccount.repaymentEvery }}
{{
loansAccount.repaymentFrequencyType
| find: loansAccountProductTemplate.termFrequencyTypeOptions : 'id' : 'name'
}}
{{
loansAccount.repaymentFrequencyNthDayType
| find: loansAccountProductTemplate.repaymentFrequencyNthDayTypeOptions : 'id' : 'name'
}}
{{
loansAccount.repaymentFrequencyDayOfWeekType
| find: loansAccountProductTemplate.repaymentFrequencyDaysOfWeekTypeOptions : 'id' : 'name'
}}</span
>
<span class="flex-60">{{ loansAccount.allowPartialPeriodInterestCalculation }}</span>
</div>
}
@if (loansAccount.inArrearsTolerance) {
@if (productEnableDownPayment) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Enable Down Payments' | translate }}:</span>
<span class="flex-60">{{ loansAccount.enableDownPayment | yesNo }}</span>
</div>
}
@if (loansAccount.repaymentsStartingFromDate) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.First repayment on' | translate }}:</span>
<span class="flex-60">{{ loansAccount.repaymentsStartingFromDate | dateFormat }}</span>
</div>
}
@if (loansAccount.interestChargedFromDate) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest charged from' | translate }}:</span>
<span class="flex-60">{{ loansAccount.interestChargedFromDate | dateFormat }}</span>
</div>
}
@if (loansAccount.interestRatePerPeriod) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Nominal interest rate' | translate }}:</span>
<span class="flex-60"
>{{ loansAccount.interestRatePerPeriod }}&nbsp;&nbsp;{{
loansAccountProductTemplate.interestRateFrequencyType.value
}}</span
>
</div>
}
@if (loansAccount.interestType) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest method' | translate }}: </span>
<span class="flex-60">{{ loansAccount.interestType }}</span>
</div>
}
@if (loansAccount.isEqualAmortization) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Is Equal Amortization' | translate }}:</span>
<span class="flex-60">{{ loansAccount.isEqualAmortization }}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Arrears tolerance' | translate }}: </span>
<span class="flex-60">{{ loansAccount.inArrearsTolerance }}</span>
<span class="flex-40">{{ 'labels.inputs.Amortization' | translate }}</span>
<span class="flex-60">{{
loansAccount.amortizationType
| find: loansAccountProductTemplate.amortizationTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
}
@if (loansAccount.graceOnInterestCharged) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest free period' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnInterestCharged }}</span>
<span class="flex-40">{{ 'labels.inputs.Interest calculation period' | translate }}: </span>
<span class="flex-60">{{
loansAccount.interestCalculationPeriodType
| find: loansAccountProductTemplate.interestCalculationPeriodTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Repayment strategy' | translate }}: </span>
<span class="flex-60">{{
loansAccount.transactionProcessingStrategyCode
| find: loansAccountProductTemplate.transactionProcessingStrategyOptions : 'code' : 'name'
| translateKey: 'catalogs'
}}</span>
</div>
@if (loansAccount.allowPartialPeriodInterestCalculation) {
<div class="flex-fill">
<span class="flex-40"
>{{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }}:</span
>
<span class="flex-60">{{ loansAccount.allowPartialPeriodInterestCalculation }}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40"> {{ 'labels.inputs.Installment Amount' | translate }} </span>
<span class="flex-60"> {{ loansAccount.fixedEmiAmount | formatNumber }} </span>
</div>
@if (loansAccount.inArrearsTolerance) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Arrears tolerance' | translate }}: </span>
<span class="flex-60">{{ loansAccount.inArrearsTolerance }}</span>
</div>
}
<div class="flex-fill">
<span class="flex-40"> {{ 'labels.inputs.Balloon Repayment Amount' | translate }} </span>
<span class="flex-60"> {{ loansAccount.balloonRepaymentAmount | formatNumber }} </span>
</div>
@if (loansAccount.graceOnInterestCharged) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Interest free period' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnInterestCharged }}</span>
</div>
}
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Moratorium' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
@if (loansAccount.graceOnPrincipalPayment) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.On principal payment' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnPrincipalPayment }}</span>
<span class="flex-40">{{ 'labels.inputs.Repayment strategy' | translate }}: </span>
<span class="flex-60">{{
loansAccount.transactionProcessingStrategyCode
| find: loansAccountProductTemplate.transactionProcessingStrategyOptions : 'code' : 'name'
| translateKey: 'catalogs'
}}</span>
</div>
}
@if (loansAccount.graceOnInterestPayment) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.On interest payment' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnInterestPayment }}</span>
<span class="flex-40"> {{ 'labels.inputs.Installment Amount' | translate }} </span>
<span class="flex-60"> {{ loansAccount.fixedEmiAmount | formatNumber }} </span>
</div>
}
@if (loansAccount.graceOnArrearsAgeing) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.On Arrears Aging' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnArrearsAgeing }}</span>
<span class="flex-40"> {{ 'labels.inputs.Balloon Repayment Amount' | translate }} </span>
<span class="flex-60"> {{ loansAccount.balloonRepaymentAmount | formatNumber }} </span>
</div>
}
<div class="flex-fill">
<span class="flex-40"> {{ 'labels.inputs.Enable installment level Delinquency' | translate }} </span>
<span class="flex-60"> {{ loansAccount.enableInstallmentLevelDelinquency | yesNo }} </span>
</div>
<h3 class="mat-h3 margin-t flex-fill">{{ 'labels.heading.Moratorium' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
@if (loansAccount.graceOnPrincipalPayment) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.On principal payment' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnPrincipalPayment }}</span>
</div>
}
@if (loansAccount.graceOnInterestPayment) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.On interest payment' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnInterestPayment }}</span>
</div>
}
@if (loansAccount.graceOnArrearsAgeing) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.On Arrears Aging' | translate }}: </span>
<span class="flex-60">{{ loansAccount.graceOnArrearsAgeing }}</span>
</div>
}
@if (loansAccount.isTopup) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Is Topup Loan' | translate }}? </span>
<span class="flex-60">{{ loansAccount.isTopup }}</span>
<span class="flex-40"> {{ 'labels.inputs.Enable installment level Delinquency' | translate }} </span>
<span class="flex-60"> {{ loansAccount.enableInstallmentLevelDelinquency | yesNo }} </span>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Recalculate Interest' | translate }}: </span>
<span class="flex-60">{{ loansAccountProductTemplate?.isInterestRecalculationEnabled | yesNo }}</span>
</div>
@if (loansAccount.isTopup) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Is Topup Loan' | translate }}? </span>
<span class="flex-60">{{ loansAccount.isTopup }}</span>
</div>
}
@if (loansAccountProductTemplate?.daysInMonthType) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Days in month' | translate }}: </span>
<span class="flex-60">{{ loansAccountProductTemplate?.daysInMonthType.value | translateKey: 'catalogs' }}</span>
<span class="flex-40">{{ 'labels.inputs.Recalculate Interest' | translate }}: </span>
<span class="flex-60">{{ loansAccountProductTemplate?.isInterestRecalculationEnabled | yesNo }}</span>
</div>
@if (loansAccountProductTemplate?.daysInMonthType) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Days in month' | translate }}: </span>
<span class="flex-60">{{ loansAccountProductTemplate?.daysInMonthType.value | translateKey: 'catalogs' }}</span>
</div>
}
}
@if (loansAccount.charges.length) {
@@ -395,10 +458,12 @@
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" (click)="submitEvent.emit()">
{{ 'labels.buttons.Submit' | translate }}
</button>
@if (!viewOnly) {
<button mat-raised-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" (click)="submitEvent.emit()">
{{ 'labels.buttons.Submit' | translate }}
</button>
}
</div>
@@ -32,3 +32,182 @@ mat-divider {
.margin-t {
margin-top: 1em;
}
/* Loan Origination preview: a header strip with the headline numbers, then a card per section. */
$navy: #0d1b2a;
$blue: #1e88e5;
$indigo: #4f46e5;
$border: #e2e8f0;
$muted: #64748b;
.lp {
margin-bottom: 24px;
}
.lp-hero {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px 32px;
padding: 20px 24px;
margin-bottom: 20px;
border-radius: 16px;
color: #fff;
background: linear-gradient(135deg, $navy 0%, #1e3a8a 55%, $indigo 100%);
box-shadow: 0 6px 20px rgb(30 58 138 / 22%);
&__eyebrow {
display: block;
margin: 0;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
}
&__name {
margin: 4px 0 2px;
font-size: 22px;
font-weight: 700;
color: #fff;
}
&__sub {
margin: 0;
font-size: 13px;
opacity: 0.85;
}
}
.lp-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 12px;
flex: 1 1 420px;
}
.lp-kpi {
padding: 10px 14px;
border-radius: 12px;
background: rgb(255 255 255 / 12%);
backdrop-filter: blur(4px);
&__label {
display: block;
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.75;
}
&__value {
display: block;
margin: 2px 0 0;
font-size: 15px;
font-weight: 700;
word-break: break-word;
}
}
.lp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
@media (width <= 520px) {
grid-template-columns: 1fr;
}
}
.lp-card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
overflow: hidden;
&--wide {
grid-column: 1 / -1;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: #f8fafc;
border-bottom: 1px solid $border;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 12px;
color: $blue;
background: rgb(30 136 229 / 12%);
}
}
.lp-rows {
margin: 0;
padding: 4px 18px 8px;
}
.lp-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 9px 0;
border-bottom: 1px dashed #edf1f6;
&:last-child {
border-bottom: 0;
}
dt {
flex: 0 0 42%;
margin: 0;
font-size: 12.5px;
color: $muted;
}
dd {
flex: 1;
margin: 0;
text-align: right;
font-size: 13.5px;
font-weight: 600;
color: $navy;
word-break: break-word;
}
}
.lp-pill {
display: inline-block;
margin: 0;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: #475569;
background: #e2e8f0;
&--yes {
color: #166534;
background: #dcfce7;
}
}
@@ -27,6 +27,7 @@ import { ExternalIdentifierComponent } from '../../../shared/external-identifier
import { MatDivider } from '@angular/material/divider';
import { MatStepperPrevious } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
@@ -73,6 +74,8 @@ export class LoansAccountPreviewStepComponent implements OnChanges {
@Input() loansAccountProductTemplate: any;
/** Loans Account Data */
@Input() loansAccount: any;
/** What the Loan Origination steps filled (an account opened from Loan Origination), listed before the loan details. */
@Input() losSections: { title: string; rows: { label: string; value: string }[] }[] = [];
/** active Client Members in case of GLIM Account */
@Input() activeClientMembers?: any;
@@ -111,6 +114,40 @@ export class LoansAccountPreviewStepComponent implements OnChanges {
constructor() {}
/** Opened from a Loan Origination application: the header strip is shown and the instalment terms are not repeated. */
@Input() losMode = false;
/** An account already created is being viewed: there is nothing to submit. */
@Input() viewOnly = false;
/** One value from a section (the header strip of the Loan Origination preview). */
losHighlight(section: string, label: string): string {
return this.losSections?.find((s) => s.title === section)?.rows.find((r) => r.label === label)?.value ?? '—';
}
/** The sections that list one entry per line (or long text) take the full width. */
isWide(title: string): boolean {
return /^(Security|Guarantors|Related persons|Customer)$/.test(title);
}
sectionIcon(title: string): IconProp {
const icons: Record<string, IconProp> = {
'Loan Origination application': 'file-alt',
Customer: 'user',
'Loan terms': 'coins',
'Account services': 'credit-card',
Limit: 'check-square',
Security: 'user-shield',
'Product terms': 'file-alt',
SMA: 'exclamation-triangle',
NPA: 'exclamation-triangle',
'As sanctioned': 'check',
'SMS Alerts': 'bell',
Guarantors: 'user-shield',
'Related persons': 'university'
};
return icons[title] ?? 'file-alt';
}
ngOnChanges(changes: SimpleChanges): void {
this.productEnableDownPayment = this.loansAccountProductTemplate.product.enableDownPayment;
if (this.activeClientMembers) {
@@ -30,3 +30,27 @@ mat-divider {
.margin-t {
margin-top: 1em;
}
/* Advances opened from Loan Origination: the product's interest, shown and not changed. */
.los-readonly {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 10px 16px;
border: 1px solid #c9d7f2;
border-radius: 8px;
background: #f3f7ff;
padding: 12px 16px;
}
.los-readonly__label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #5b6b8c;
}
.los-readonly__note {
font-size: 13px;
margin: 0;
}
@@ -45,6 +45,10 @@ import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { YesnoPipe } from '../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { HttpClient } from '@angular/common/http';
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
import { LosPreviewSection } from '../los-preview-section';
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
interface DisbursementData {
id?: number;
@@ -86,6 +90,8 @@ interface DisbursementData {
})
export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private catalog = inject(LoanProductCatalogService);
private http = inject(HttpClient);
private settingsService = inject(SettingsService);
private route = inject(ActivatedRoute);
dialog = inject(MatDialog);
@@ -106,6 +112,192 @@ export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
@Input() collateralOptions: any;
// @Input loanPrincipal: Loan Principle
@Input() loanPrincipal: any;
/** The Loan Origination application this account is being opened for; its amount, tenure and rate fill the terms. */
@Input() losApplication: LoanApplication | null = null;
/** An Advances account opened from Loan Origination: no repayment schedule is asked, the interest is the product's, and the
* account services (cheque book, ATM / IMPS) are taken instead. */
@Input() losAdvance = false;
/** What the officer fills for the account's services; kept apart from the loan terms and saved with the loan afterwards. */
readonly losServicesForm = this.formBuilder.group({
chequeBookIssue: [false],
ppsLimit: [null],
atmActive: [false],
impsActive: [false],
impsPrimary: [false],
mmid: [''],
upiActive: [false],
posActive: [false],
atmPerTxn: [null],
atmDay: [null],
posPerTxn: [null],
posDay: [null],
upiPerTxn: [null],
upiDay: [null]
});
/** The settings the product was created with (Product Creation), for the read-only interest block and the cheque book. */
get productExt(): Record<string, any> {
// The full settings are read from the product itself; the catalog list carries only its classification.
return this.losProductExt ?? this.catalog.classification(this.losProductId);
}
/** The id of the product the account is for, whichever way the product template carries it. */
private get losProductId(): any {
return this.loansAccountProductTemplate?.loanProductId ?? this.loansAccountProductTemplate?.product?.id;
}
private losProductExt: Record<string, any> | null = null;
private losProductExtFor: any = null;
private loadLosProductExt(): void {
const productId = this.losProductId;
if (!this.losAdvance || !productId || this.losProductExtFor === productId) {
return;
}
this.losProductExtFor = productId;
this.http.get<any>('/loanproducts/' + productId).subscribe((product) => {
this.losProductExt = product?.advanceExtensions ?? null;
});
}
/** The interest the product was set up with, shown and not changed. */
get losInterestRows(): { label: string; value: string }[] {
const e = this.productExt;
const app = this.losApplication;
const yesNo = (v: any) => (v === true || v === 'true' ? 'Yes' : 'No');
const rows: { label: string; value: string }[] = [];
rows.push({
label: 'Debit interest rate',
value:
(app ? app.interestRate + '% p.a.' : '—') +
(e['interestBasis'] === 'deposit_spread' ? ' (deposit rate + ' + (e['spread'] ?? 0) + '%)' : '')
});
rows.push({
label: 'Interest charged on',
value: e['interestChargedOn'] === 'limit' ? 'Sanctioned limit' : 'Amount used'
});
rows.push({ label: 'Interest calculation', value: e['interestCalcMethod'] ?? '—' });
rows.push({
label: 'Interest posting',
value:
[
e['interestPostingFrequency'],
e['interestPostingDay']
]
.filter(Boolean)
.join(' · ') || '—'
});
rows.push({ label: 'Credit interest on balance', value: yesNo(e['interestOnCreditBalance']) });
rows.push({
label: 'Penal interest',
value: e['penalApplicable']
? (e['penalRate'] ?? 0) +
'% on ' +
(e['penalAppliedOn'] ?? 'overdue') +
(e['penalGraceDays'] ? ', grace ' + e['penalGraceDays'] + ' days' : '')
: 'Not applicable'
});
return rows;
}
/** Everything on this step, for the Preview (an Advances account opened from Loan Origination). */
previewSections(): LosPreviewSection[] {
const app = this.losApplication;
const v = this.losServicesForm.getRawValue();
const yn = (b: any) => (b ? 'Yes' : 'No');
const inr = (x: any) => (x === null || x === '' || x === undefined ? '—' : '₹' + Number(x).toLocaleString('en-IN'));
const services: { label: string; value: string }[] = [];
if (this.chequeBookOffered) {
services.push(
{ label: 'Cheque book issue', value: yn(v.chequeBookIssue) },
{ label: 'Positive Pay limit', value: inr(v.ppsLimit) }
);
}
services.push(
{
label: 'ATM',
value: yn(v.atmActive) + (v.atmActive ? ' · per txn ' + inr(v.atmPerTxn) + ' · per day ' + inr(v.atmDay) : '')
},
{
label: 'IMPS',
value: yn(v.impsActive) + (v.impsActive ? ' · primary ' + yn(v.impsPrimary) + ' · MMID ' + (v.mmid || '—') : '')
},
{
label: 'UPI',
value: yn(v.upiActive) + (v.upiActive ? ' · per txn ' + inr(v.upiPerTxn) + ' · per day ' + inr(v.upiDay) : '')
},
{
label: 'POS',
value: yn(v.posActive) + (v.posActive ? ' · per txn ' + inr(v.posPerTxn) + ' · per day ' + inr(v.posDay) : '')
}
);
return [
{
title: 'Loan terms',
rows: [
{ label: 'Sanctioned amount', value: app ? inr(app.requestedAmount) : '—' },
{ label: 'Tenure', value: app ? app.tenureMonths + ' months' : '—' },
...this.losInterestRows
]
},
{ title: 'Account services', rows: services }
];
}
/** Only a product that has a cheque book asks whether this customer gets one. */
get chequeBookOffered(): boolean {
return this.productExt['chequeBookEnabled'] === true;
}
/** What the product says about Positive Pay on its cheques: the value from which it is asked and from which it is mandatory. */
get chequePpsInfo(): string {
const e = this.productExt;
const money = (x: any) =>
x === undefined || x === null || x === '' ? null : '₹' + Number(x).toLocaleString('en-IN');
return [
money(e['ppsThreshold']) ? 'asked from ' + money(e['ppsThreshold']) : '',
money(e['ppsMandatoryAbove']) ? 'mandatory above ' + money(e['ppsMandatoryAbove']) : ''
]
.filter(Boolean)
.join(', ');
}
get chequeBookInfo(): string {
const e = this.productExt;
return [
e['chequeType'],
e['defaultChequeBookSize'] ? e['defaultChequeBookSize'] + ' leaves' : ''
]
.filter(Boolean)
.join(' · ');
}
/** The services as the loan_account_services datatable stores them. */
get losServices(): Record<string, any> {
const v = this.losServicesForm.getRawValue();
const num = (x: any) => (x === null || x === '' || x === undefined ? undefined : Number(x));
const data: Record<string, any> = {
atm_active: !!v.atmActive,
imps_active: !!v.impsActive,
imps_primary_account: !!v.impsPrimary,
upi_active: !!v.upiActive,
pos_active: !!v.posActive,
mmid: v.mmid || undefined,
atm_per_txn_limit: num(v.atmPerTxn),
atm_day_limit: num(v.atmDay),
pos_per_txn_limit: num(v.posPerTxn),
pos_day_limit: num(v.posDay),
upi_per_txn_limit: num(v.upiPerTxn),
upi_day_limit: num(v.upiDay)
};
if (this.chequeBookOffered) {
data['cheque_book_issue'] = !!v.chequeBookIssue;
// Positive Pay applies to cheques, so it is asked only where the product has a cheque book.
data['pps_limit'] = num(v.ppsLimit);
}
return data;
}
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -313,6 +505,28 @@ export class LoansAccountTermsStepComponent implements OnInit, OnChanges {
new UntypedFormControl(this.loansAccountTermsData?.maxOutstandingLoanBalance ?? null)
);
}
this.applyLosTerms();
this.loadLosProductExt();
}
}
/**
* Opened from Loan Origination: the amount, tenure and interest rate are the ones sanctioned there, not the product's
* defaults. Tenure is in months there, so the number of repayments is that over how often the product repays.
*/
private applyLosTerms(): void {
const app = this.losApplication;
if (!app || !this.loansAccountTermsData) {
return;
}
const repaymentEvery = Number(this.loansAccountTermsForm.get('repaymentEvery')?.value) || 1;
this.loansAccountTermsForm.patchValue({
principalAmount: app.requestedAmount,
numberOfRepayments: Math.max(1, Math.round(app.tenureMonths / repaymentEvery))
});
const rate = this.loansAccountTermsForm.get('interestRatePerPeriod');
if (rate && !rate.disabled) {
rate.setValue(app.interestRate);
}
}
@@ -0,0 +1,13 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** A block of label / value rows each Loan Origination step of the account screen contributes to the Preview. */
export interface LosPreviewSection {
title: string;
rows: { label: string; value: string }[];
}
@@ -405,6 +405,8 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
securityType: e.securityType,
eligibleAmountBasis: e.eligibleAmountBasis,
margin: e.margin,
guarantorsRequired: e.guarantorsRequired ?? 0,
guarantorMustBeMember: e.guarantorMustBeMember ?? true,
lienMarkingStage: e.lienMarkingStage,
eligibleDepositProducts: split(e.eligibleDepositProducts),
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
@@ -1194,6 +1196,8 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
eligibleAmountBasis: sec.eligibleAmountBasis,
margin: num(sec.margin),
ltv: sec.ltv,
guarantorsRequired: num(sec.guarantorsRequired) ?? 0,
guarantorMustBeMember: sec.guarantorMustBeMember,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
@@ -1329,6 +1333,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
return payload;
}
/** True from the moment a new product is sent until the answer comes back. */
private creating = false;
submit() {
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
if (this.isAdvancedPaymentStrategy) {
@@ -1353,7 +1360,15 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
return;
}
// a second click while the first request is still on its way would send the same product twice
if (this.creating) {
return;
}
this.creating = true;
this.productsService.createLoanProduct(loanProduct).subscribe({
error: () => {
this.creating = false;
},
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
@@ -91,6 +91,8 @@
/>
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
} @else if (loanProductDetailsForm.get('shortName').hasError('taken')) {
<mat-error>This Short Name is already used by another product — pick a different one</mat-error>
} @else {
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
@@ -18,6 +18,7 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
import { ProductsService } from 'app/products/products.service';
@Component({
selector: 'mifosx-loan-product-details-step',
@@ -35,6 +36,10 @@ export class LoanProductDetailsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private productsService = inject(ProductsService);
/** Short names the existing products already use, so a new product is not offered (or allowed) one of them. */
private takenShortNames = new Set<string>();
@Input() loanProductsTemplate: any;
@@ -73,8 +78,20 @@ export class LoanProductDetailsStepComponent implements OnInit {
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
this.loanProductDetailsForm.get('shortName').setValidators([
Validators.required,
Validators.pattern(/^[A-Z]{2}$/)
Validators.pattern(/^[A-Z]{2}$/),
(control) =>
this.takenShortNames.has(control.value) ? { taken: true } : null
]);
this.productsService.getLoanProducts().subscribe((products: any[]) => {
this.takenShortNames = new Set((products ?? []).map((p) => p.shortName));
// the code filled in before this list arrived may already be in use
const control = this.loanProductDetailsForm.get('shortName');
if (control.value && this.takenShortNames.has(control.value)) {
control.setValue(this.freeShortName(control.value));
} else {
control.updateValueAndValidity({ emitEvent: false });
}
});
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
@@ -130,6 +147,23 @@ export class LoanProductDetailsStepComponent implements OnInit {
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
}
/**
* The sub type's own code when no product uses it yet; otherwise the same first letter with the next free second
* letter (FD taken -> FA, then FB ...), so a second product of the same kind still gets a short name of its own.
*/
private freeShortName(wanted: string): string {
if (this.isExistingProduct || !this.takenShortNames.has(wanted)) {
return wanted;
}
for (const second of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
const candidate = wanted.charAt(0) + second;
if (!this.takenShortNames.has(candidate)) {
return candidate;
}
}
return wanted;
}
/** True when editing a saved product; see ngOnInit. */
isExistingProduct = false;
@@ -246,7 +280,7 @@ export class LoanProductDetailsStepComponent implements OnInit {
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
this.loanProductDetailsForm.get('shortName').setValue(this.freeShortName(subGroup.shortName));
}
this.applyCategoryForSubType(subGroupId);
});
@@ -182,6 +182,8 @@ export class LoanProductPreviewStepComponent {
),
row('Margin', 'margin', pct),
row('LTV', 'ltv', pct),
row('Guarantors required', 'guarantorsRequired'),
row('Guarantors must be bank members', 'guarantorMustBeMember'),
row('Lien required', 'lienRequired'),
row('Lien marking stage', 'lienMarkingStage'),
row('Eligible deposit products', 'eligibleDepositProducts'),
@@ -15,6 +15,24 @@
}
@if (isAdvance) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-user-plus"></i>
<h3>Guarantors</h3>
<span class="lsl-section__hint">Asked on every application of this product</span>
</div>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Guarantors Required</mat-label>
<input matInput type="number" formControlName="guarantorsRequired" min="0" max="5" />
<mat-hint>0 = none (a deposit or policy is its own security). Otherwise that many are asked for.</mat-hint>
</mat-form-field>
<div class="lsl-checks">
<mat-checkbox formControlName="guarantorMustBeMember">Guarantors must be members of the bank</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-shield-alt"></i>
@@ -240,6 +240,15 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
Validators.max(100)
]
],
// How many guarantors an application on this product has to name (0 = none: a deposit is its own security).
guarantorsRequired: [
0,
[
Validators.min(0),
Validators.max(5)
]
],
guarantorMustBeMember: [true],
lienMarkingStage: [
'disbursement',
Validators.required
@@ -445,6 +454,8 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
eligibleAmountBasis: value.eligibleAmountBasis,
margin: value.margin,
ltv: this.ltv,
guarantorsRequired: value.guarantorsRequired,
guarantorMustBeMember: value.guarantorMustBeMember,
lienMarkingStage: value.lienMarkingStage,
eligibleDepositProducts: value.eligibleDepositProducts,
lienRules: {