Compare commits

..
Author SHA1 Message Date
keshav 8bf74afe24 Add CC accounts tracking, AML rule trial and branch approval fixes 2026-10-19 18:16:22 +05:30
niteshandClaude Opus 5.5 885b5897af feat(api-management): set up each CIC bureau as an API vendor
CIBIL, Experian, Equifax and CRIF are vendors under API Management (active =
whitelisted) and the Credit Bureau dashboard reads them from there; the
separate CIC whitelist and API mapping screens are removed. Mapping tables show
the value each row finds in the sample payload, and bureau cards link to their
set-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:50:27 +05:30
sonali f0b8f7f5f4 Merge branch 'feature-demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into feature-demo 2026-10-19 15:41:08 +05:30
sonaliandClaude Sonnet 5 1bf441114e feat(npa): add Cash Credit (CC) Recovery Management tab
New tab under NPA > Reports > Recovery Management, dedicated to Cash
Credit accounts: Drawing Power vs outstanding, interest due/paid this
cycle, lifetime recovery, and a Days-Overdrawn/Status view (Regular /
Overdrawn / NPA) in place of the generic EMI-style SMA labels, matching
how a CC/OD book is actually monitored in real banks.

Status now checks NPA history first so an account doesn't show
"Regular" just because it's briefly back within DP (RBI upgrade norms
require full clearance, not a one-off snapshot).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 15:38:03 +05:30
niteshandClaude Sonnet 5.5 896e3fd7e8 fix(credit-bureau): keep customer enquiry list stable and add back navigation in alert detail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:33:43 +05:30
anshulandClaude Opus 5.5 f9748036c3 feat(ai): loan eligibility demo screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:19:16 +05:30
niteshandClaude Sonnet 5.5 7eac2dcf3b feat(credit-bureau): CIC enquiry-alert dashboard and generic API Management hub (static UI)
Replace CIBIL fetch screens with a dashboard of our customers' loan enquiries
at other lenders (no lender name), CIC whitelist/API mapping, and a Vendor ->
Work -> API set management UI (IN/OUT, SMS, WhatsApp, CIC) with full HTTP detail
and JSON/cURL/Postman/HAR import.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:13:56 +05:30
niteshandClaude Opus 5.5 1b70fb4534 feat(cic): Credit Bureau menu - CIBIL / CIC enquiry, consent, whitelist and API mapping
Separate top-nav menu with dashboard, new enquiry, enquiry history and report,
consent register, CIC whitelist & configuration, and API mapping with preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:18:32 +05:30
198 changed files with 15013 additions and 26401 deletions
@@ -139,6 +139,13 @@
</td>
</ng-container>
<ng-container matColumnDef="limit">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Sanctioned Limit</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
{{ getCurrencySymbol(row) }} {{ row.overdraftLimit | formatNumber }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
@@ -34,7 +34,7 @@ import { FormatNumberPipe } from '../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AccountsService } from '../accounts.service';
export type AccountType = 'savings' | 'current' | 'fixed-deposits' | 'recurring-deposits';
export type AccountType = 'savings' | 'current' | 'cc' | 'fixed-deposits' | 'recurring-deposits';
interface AccountConfig {
label: string;
@@ -119,6 +119,13 @@ export class AccountsListComponent implements OnInit, OnDestroy {
typeLabel: 'CA',
typeBadgeClass: 'dtype--ca'
},
cc: {
label: 'CC Accounts',
icon: 'hand-holding-usd',
accentClass: 'accent--current',
typeLabel: 'CC',
typeBadgeClass: 'dtype--ca'
},
'fixed-deposits': {
label: 'Fixed Deposit Accounts',
icon: 'lock',
@@ -142,6 +149,24 @@ export class AccountsListComponent implements OnInit, OnDestroy {
ngOnInit() {
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
this.accountType = data['accountType'] as AccountType;
this.displayedColumns = this.accountType === 'cc' ? [
'accountNo',
'depositType',
'clientName',
'productName',
'limit',
'balance',
'status',
'actions'
] : [
'accountNo',
'depositType',
'clientName',
'productName',
'balance',
'status',
'actions'
];
this.pageIndex = 0;
this.searchControl.setValue('', { emitEvent: false });
this.loadAccounts();
@@ -183,9 +208,12 @@ export class AccountsListComponent implements OnInit, OnDestroy {
const toVal = this.toDateControl.value;
const bothDatesSet = !!(fromVal && toVal);
// CC accounts come from the server already narrowed to those that allow an overdraft; nothing else is read.
const isCc = this.accountType === 'cc';
const params: import('../accounts.service').AccountListParams = {
offset: this.pageIndex * this.pageSize,
limit: this.pageSize,
offset: isCc ? 0 : this.pageIndex * this.pageSize,
limit: isCc ? 500 : this.pageSize,
...(isCc && { overdraftOnly: true }),
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
orderBy: 'id',
@@ -199,7 +227,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
const obs$ =
this.accountType === 'savings'
? this.accountsService.getSavingsAccounts(params)
: this.accountType === 'current'
: this.accountType === 'current' || this.accountType === 'cc'
? this.accountsService.getCurrentAccounts(params)
: this.accountType === 'fixed-deposits'
? this.accountsService.getFixedDepositAccounts(params)
@@ -239,7 +267,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
});
}
this.totalRecords = Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
this.totalRecords = isCc || Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
this.dataSource.data = items;
this.isLoading = false;
},
@@ -307,6 +335,13 @@ export class AccountsListComponent implements OnInit, OnDestroy {
id,
'general'
],
cc: [
'/clients',
account.clientId,
'current-accounts',
id,
'general'
],
'fixed-deposits': [
'/clients',
account.clientId,
@@ -36,6 +36,12 @@ const routes: Routes = [
component: AccountsListComponent,
data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' }
},
{
// Cash Credit: current accounts opened on a product that allows an overdraft.
path: 'cc',
component: AccountsListComponent,
data: { accountType: 'cc', title: 'CC Accounts', breadcrumb: 'CC Accounts' }
},
{
path: 'fixed-deposits',
component: AccountsListComponent,
+3
View File
@@ -21,6 +21,8 @@ export interface AccountListParams {
fromDate?: string;
toDate?: string;
asOnDate?: string;
/** Current accounts only: just the Cash Credit (overdraft) accounts, filtered by the server. */
overdraftOnly?: boolean;
}
@Injectable({ providedIn: 'root' })
@@ -55,6 +57,7 @@ export class AccountsService {
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
if (params.overdraftOnly) httpParams = httpParams.set('overdraftOnly', 'true');
return this.http.get('/currentaccounts', { params: httpParams });
}
+10
View File
@@ -158,6 +158,16 @@ export class AmlService {
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
}
/**
* Every credit and debit on one CC account over the last `days` days, with the mode and counterparty each payment
* recorded, totalled by mode and by counterparty.
*/
getCcAccountTrail(accountId: number, days = 30): Observable<any> {
return this.http.get<any>(`/aml/cc-tracking/${accountId}/trail`, {
params: new HttpParams().set('days', String(days))
});
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
+778
View File
@@ -0,0 +1,778 @@
/**
* 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 { ApiMappingRow, KeyValue, kv } from './api-management.service';
/**
* Reads what a vendor's documentation or a Postman / browser / cURL capture gives - in any of these shapes - and turns
* it into the pieces of an API: method, URL, path, query parameters, headers, content type, payload, response status,
* response headers, response body, and a first guess at the CBS field mapping. Nothing is sent anywhere.
*
* Understood:
* - a cURL command
* - a Postman collection or a single Postman request / item (with its saved responses)
* - a HAR capture
* - an "API definition" object: { method, url | path, query, headers, body, status, ... }
* - a bare JSON payload (treated as the request body, or the response body on the response side)
* - form text (a=1&b=2) and other raw text
*
* Credentials found in headers or query strings are never kept: the value is replaced by {{secret}} and the row is
* flagged as secret, so a key pasted from a sample cannot end up stored.
*/
export interface ImportedRequest {
source: string;
method?: string;
/** The full URL when the file had one. */
url?: string;
/** Scheme and host of the URL, when present. */
baseUrl?: string;
path?: string;
queryParams: KeyValue[];
headers: KeyValue[];
contentType?: string;
body?: string;
/** A response found in the same file (Postman saved response, HAR entry, definition object). */
response?: ImportedResponse;
notes: string[];
}
export interface ImportedResponse {
source: string;
status?: number;
headers: KeyValue[];
body?: string;
errorBody?: string;
notes: string[];
}
const SECRET_HEADERS =
/^(authorization|proxy-authorization|x-api-key|api-key|apikey|authkey|x-auth-token|auth-token|token|x-access-token|cookie|set-cookie|x-hub-signature(-256)?)$/i;
const SECRET_QUERY = /^(key|api[-_]?key|apikey|token|access[-_]?token|auth[-_]?key|secret|password|signature)$/i;
const SECRET = '{{secret}}';
// ── small helpers ───────────────────────────────────────────────────────────
function isObject(v: unknown): v is Record<string, any> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function tryJson(text: string): any {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function prettyJsonOrText(text: string): string {
const parsed = tryJson(text);
return parsed !== undefined && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : text;
}
function bodyToText(body: unknown): string | undefined {
if (body === undefined || body === null) return undefined;
if (typeof body === 'string') return prettyJsonOrText(body);
return JSON.stringify(body, null, 2);
}
function toKeyValues(source: unknown, kind: 'header' | 'query' | 'plain'): KeyValue[] {
const out: KeyValue[] = [];
const add = (key: unknown, value: unknown, description = '', disabled = false) => {
const k = String(key ?? '').trim();
if (!k) return;
const secret = (kind === 'header' && SECRET_HEADERS.test(k)) || (kind === 'query' && SECRET_QUERY.test(k));
out.push({
key: k,
value: secret ? SECRET : value === undefined || value === null ? '' : String(value),
description,
enabled: !disabled,
secret
});
};
if (Array.isArray(source)) {
for (const item of source) {
if (isObject(item))
add(item.key ?? item.name, item.value, String(item.description ?? ''), item.disabled === true);
}
} else if (isObject(source)) {
for (const [
k,
v
] of Object.entries(source)) add(k, v);
}
return out;
}
/** Splits a URL into its base, path and query without failing on {{placeholders}} or {path} parameters. */
function splitUrl(raw: string): { baseUrl?: string; path: string; query: KeyValue[] } {
let rest = raw.trim();
let baseUrl: string | undefined;
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]+)(.*)$/.exec(rest);
if (m) {
baseUrl = m[1];
rest = m[2];
}
const hash = rest.indexOf('#');
if (hash >= 0) rest = rest.slice(0, hash);
let queryText = '';
const q = rest.indexOf('?');
if (q >= 0) {
queryText = rest.slice(q + 1);
rest = rest.slice(0, q);
}
const query: KeyValue[] = [];
for (const part of queryText.split('&')) {
if (!part) continue;
const eq = part.indexOf('=');
const key = safeDecode(eq >= 0 ? part.slice(0, eq) : part);
const value = eq >= 0 ? safeDecode(part.slice(eq + 1)) : '';
query.push(...toKeyValues([{ key, value }], 'query'));
}
return { baseUrl, path: rest || '/', query };
}
function safeDecode(s: string): string {
try {
return decodeURIComponent(s.replace(/\+/g, ' '));
} catch {
return s;
}
}
function detectContentType(
headers: KeyValue[],
body: string | undefined
): { contentType?: string; headers: KeyValue[] } {
const ct = headers.find((h) => h.key.toLowerCase() === 'content-type');
const rest = headers.filter((h) => h.key.toLowerCase() !== 'content-type');
if (ct) return { contentType: ct.value.split(';')[0].trim(), headers: rest };
if (!body) return { headers: rest };
if (tryJson(body) !== undefined) return { contentType: 'application/json', headers: rest };
if (/^\s*</.test(body)) return { contentType: 'application/xml', headers: rest };
if (/^[^\s=&]+=[^&]*(&[^\s=&]+=[^&]*)*$/.test(body.trim()))
return { contentType: 'application/x-www-form-urlencoded', headers: rest };
return { contentType: 'text/plain', headers: rest };
}
/** Everything about a request is derived the same way once method / url / headers / body are known. */
function finishRequest(
source: string,
method: string | undefined,
url: string | undefined,
pathOnly: string | undefined,
query: unknown,
headers: KeyValue[],
body: string | undefined,
contentTypeHint: string | undefined,
notes: string[]
): ImportedRequest {
const parts = url ? splitUrl(url) : pathOnly ? splitUrl(pathOnly) : undefined;
const queryParams = [
...(parts?.query ?? []),
...toKeyValues(query, 'query')
];
const detected = detectContentType(headers, body);
if (queryParams.some((q) => q.secret))
notes.push('A credential in the query string was replaced by {{secret}} - set it on the Connection tab.');
if (headers.some((h) => h.secret))
notes.push('Credentials in the headers were replaced by {{secret}} - set them on the Connection tab.');
return {
source,
method: method ? method.toUpperCase() : body ? 'POST' : undefined,
url,
baseUrl: parts?.baseUrl,
path: parts?.path,
queryParams,
headers: detected.headers,
contentType: contentTypeHint ?? detected.contentType,
body,
notes
};
}
// ── cURL ────────────────────────────────────────────────────────────────────
/** Splits a shell command into words, honouring '…' "…" quotes and \ line continuations. */
function shellWords(command: string): string[] {
const words: string[] = [];
let cur = '';
let quote: '"' | "'" | null = null;
let started = false;
const text = command.replace(/\\\r?\n/g, ' ').trim();
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote) {
if (c === quote) quote = null;
else if (c === '\\' && quote === '"' && i + 1 < text.length) cur += text[++i];
else cur += c;
} else if (c === '"' || c === "'") {
quote = c;
started = true;
} else if (/\s/.test(c)) {
if (started || cur) words.push(cur);
cur = '';
started = false;
} else if (c === '\\' && i + 1 < text.length) {
cur += text[++i];
} else {
cur += c;
}
}
if (started || cur) words.push(cur);
return words;
}
function parseCurl(text: string): ImportedRequest {
const words = shellWords(text);
const notes: string[] = [];
let method: string | undefined;
let url: string | undefined;
const headers: KeyValue[] = [];
const data: string[] = [];
let form = false;
let getMode = false;
for (let i = 0; i < words.length; i++) {
const w = words[i];
const next = () => words[++i] ?? '';
if (w === 'curl') continue;
if (w === '-X' || w === '--request') method = next();
else if (w === '-H' || w === '--header') {
const h = next();
const c = h.indexOf(':');
if (c > 0) headers.push(...toKeyValues([{ key: h.slice(0, c), value: h.slice(c + 1).trim() }], 'header'));
} else if ([
'-d',
'--data',
'--data-raw',
'--data-binary',
'--data-ascii'
].includes(w)) data.push(next());
else if (w === '--data-urlencode') {
data.push(next());
form = true;
} else if (w === '-F' || w === '--form') {
data.push(next());
form = true;
notes.push('Multipart form fields were read as name=value pairs.');
} else if (w === '--url') url = next();
else if (w === '-G' || w === '--get') getMode = true;
else if (w === '-u' || w === '--user') {
next();
headers.push({
key: 'Authorization',
value: SECRET,
description: 'Basic authentication from the cURL -u option',
enabled: true,
secret: true
});
} else if (w.startsWith('-')) {
// Flags that take a value we do not need.
if ([
'-o',
'--output',
'-A',
'--user-agent',
'-e',
'--referer',
'--connect-timeout',
'-m',
'--max-time',
'--proxy',
'-x'
].includes(w)) next();
} else if (!url && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(w)) url = w;
else if (!url && /^[\w.-]+(:\d+)?\//.test(w)) url = w;
}
let body = data.length ? data.join('&') : undefined;
if (body && getMode) {
url = url + (url?.includes('?') ? '&' : '?') + body;
body = undefined;
}
return finishRequest(
'cURL command',
method ?? (getMode ? 'GET' : body ? 'POST' : 'GET'),
url,
undefined,
undefined,
headers,
body ? prettyJsonOrText(body) : undefined,
form && !headers.some((h) => h.key.toLowerCase() === 'content-type')
? 'application/x-www-form-urlencoded'
: undefined,
notes
);
}
// ── Postman / HAR / definition object ───────────────────────────────────────
function findPostmanItem(node: any): any {
if (!node) return undefined;
if (node.request) return node;
if (Array.isArray(node.item)) {
for (const child of node.item) {
const found = findPostmanItem(child);
if (found) return found;
}
}
return undefined;
}
function postmanUrl(url: any): string | undefined {
if (!url) return undefined;
if (typeof url === 'string') return url;
if (typeof url.raw === 'string') return url.raw;
const host = Array.isArray(url.host) ? url.host.join('.') : url.host;
const path = Array.isArray(url.path) ? url.path.join('/') : url.path;
return `${url.protocol ? url.protocol + '://' : ''}${host ?? ''}/${path ?? ''}`;
}
function postmanResponse(r: any): ImportedResponse {
return {
source: 'Postman saved response',
status: typeof r?.code === 'number' ? r.code : undefined,
headers: toKeyValues(r?.header, 'header'),
body: bodyToText(r?.body),
notes: []
};
}
function fromPostman(item: any): ImportedRequest {
const req = item.request;
const notes: string[] = [];
let body: string | undefined;
let contentType: string | undefined;
const b = req?.body;
if (b?.mode === 'raw') {
body = bodyToText(b.raw);
const lang = b.options?.raw?.language;
if (lang === 'json') contentType = 'application/json';
} else if (b?.mode === 'urlencoded') {
body = (b.urlencoded ?? [])
.filter((p: any) => !p.disabled)
.map((p: any) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value ?? '')}`)
.join('&');
contentType = 'application/x-www-form-urlencoded';
} else if (b?.mode === 'formdata') {
body = (b.formdata ?? []).map((p: any) => `${p.key}=${p.value ?? ''}`).join('&');
contentType = 'multipart/form-data';
}
const request = finishRequest(
'Postman request',
typeof req?.method === 'string' ? req.method : undefined,
postmanUrl(req?.url),
undefined,
undefined,
toKeyValues(req?.header, 'header'),
body,
contentType,
notes
);
if (Array.isArray(item.response) && item.response.length) request.response = postmanResponse(item.response[0]);
if (req?.auth)
notes.push('The request had an auth section; it was not read. Set authentication on the Connection tab.');
return request;
}
function fromHar(har: any): ImportedRequest {
const entry = har.log.entries[0];
const r = entry.request ?? {};
const body = r.postData?.text ? prettyJsonOrText(r.postData.text) : undefined;
const request = finishRequest(
'HAR capture',
r.method,
r.url,
undefined,
undefined,
toKeyValues(r.headers, 'header'),
body,
typeof r.postData?.mimeType === 'string' ? r.postData.mimeType.split(';')[0] : undefined,
[]
);
const res = entry.response;
if (res) {
request.response = {
source: 'HAR capture',
status: typeof res.status === 'number' ? res.status : undefined,
headers: toKeyValues(res.headers, 'header'),
body: res.content?.text ? prettyJsonOrText(res.content.text) : undefined,
notes: []
};
}
return request;
}
function definitionResponse(def: any): ImportedResponse | undefined {
const r = def.response ?? def.responseDefinition;
const status = def.status ?? def.statusCode ?? def.successCode ?? r?.status ?? r?.statusCode ?? r?.code;
const headers = def.responseHeaders ?? r?.headers;
const body = def.responseBody ?? (isObject(r) ? (r.body ?? r.data) : r);
const errorBody = def.errorBody ?? def.errorResponse ?? r?.errorBody;
if (status === undefined && headers === undefined && body === undefined && errorBody === undefined) return undefined;
return {
source: 'API definition',
status: typeof status === 'number' ? status : status ? Number(status) || undefined : undefined,
headers: toKeyValues(headers, 'header'),
body: bodyToText(body),
errorBody: bodyToText(errorBody),
notes: []
};
}
function fromDefinition(def: any): ImportedRequest {
const body = def.body ?? def.payload ?? def.data ?? def.requestBody ?? def.request;
const bodyText = isObject(body) && ('method' in body || 'url' in body) ? undefined : bodyToText(body);
const url = def.url ?? def.endpoint ?? def.uri;
const request = finishRequest(
'API definition',
def.method ?? def.httpMethod,
typeof url === 'string' ? url : undefined,
typeof def.path === 'string' ? def.path : undefined,
def.query ?? def.queryParams ?? def.params,
toKeyValues(def.headers, 'header'),
bodyText,
typeof def.contentType === 'string' ? def.contentType : undefined,
[]
);
request.response = definitionResponse(def);
return request;
}
// ── public: request side ────────────────────────────────────────────────────
export function parseRequestText(text: string): ImportedRequest {
const t = (text ?? '').trim();
if (!t) throw new Error('Nothing to read - paste the request or choose a file.');
if (/^curl\b/i.test(t)) return parseCurl(t);
const parsed = tryJson(t);
if (parsed === undefined) {
// Not JSON: a cURL command in the middle of text, form data, or other raw text.
const curl = /curl\b[\s\S]*/i.exec(t);
if (curl && /https?:\/\//.test(curl[0])) return parseCurl(curl[0]);
const detected = detectContentType([], t);
return finishRequest('Raw text', undefined, undefined, undefined, undefined, [], t, detected.contentType, [
'The text is not JSON; it was kept as the request payload.'
]);
}
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length)
return fromHar(parsed);
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
const item = findPostmanItem(parsed);
if (!item) throw new Error('The Postman collection has no request in it.');
return fromPostman(item);
}
if (
isObject(parsed) &&
isObject(parsed.request) &&
(parsed.request.url || parsed.request.method || parsed.request.header)
)
return fromPostman(parsed);
if (isObject(parsed) && [
'method',
'httpMethod',
'url',
'endpoint',
'uri',
'headers',
'queryParams',
'requestBody',
'payload'
].some((k) => k in parsed) && !('statuses' in parsed)) {
return fromDefinition(parsed);
}
// A bare JSON payload.
return finishRequest(
'JSON payload',
undefined,
undefined,
undefined,
undefined,
[],
JSON.stringify(parsed, null, 2),
'application/json',
[]
);
}
// ── public: response side ───────────────────────────────────────────────────
export function parseResponseText(text: string): ImportedResponse {
const t = (text ?? '').trim();
if (!t) throw new Error('Nothing to read - paste the response or choose a file.');
const parsed = tryJson(t);
if (parsed === undefined) {
return {
source: 'Raw text',
headers: [],
body: t,
notes: ['The text is not JSON; it was kept as the response body.']
};
}
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length) {
return (
fromHar(parsed).response ?? { source: 'HAR capture', headers: [], notes: ['The HAR entry has no response.'] }
);
}
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
const item = findPostmanItem(parsed);
const res = item ? fromPostman(item).response : undefined;
if (!res) throw new Error('The Postman collection has no saved response.');
return res;
}
if (isObject(parsed) && Array.isArray(parsed.response) && parsed.response.length && (parsed.request || parsed.name)) {
return postmanResponse(parsed.response[0]);
}
if (isObject(parsed) && [
'status',
'statusCode',
'headers',
'responseHeaders',
'responseBody',
'errorBody'
].some(
(k) => k in parsed
) && ('body' in parsed || 'responseBody' in parsed || 'headers' in parsed || 'responseHeaders' in parsed || 'errorBody' in parsed || 'data' in parsed)) {
const r = definitionResponse({
status: parsed.status ?? parsed.statusCode,
responseHeaders: parsed.responseHeaders ?? parsed.headers,
responseBody: parsed.responseBody ?? parsed.body ?? parsed.data,
errorBody: parsed.errorBody
});
if (r) return r;
}
return { source: 'JSON body', headers: [], body: JSON.stringify(parsed, null, 2), notes: [] };
}
// ── JSON → mapping rows ─────────────────────────────────────────────────────
export interface JsonLeaf {
path: string;
value: unknown;
}
/** Every value in a JSON document with its path: `a.b[0].c`. Only the first item of an array is walked. */
export function flatten(json: unknown, prefix = ''): JsonLeaf[] {
const out: JsonLeaf[] = [];
if (Array.isArray(json)) {
if (json.length) out.push(...flatten(json[0], `${prefix}[0]`));
} else if (isObject(json)) {
for (const [
k,
v
] of Object.entries(json)) {
out.push(...flatten(v, prefix ? `${prefix}.${k}` : k));
}
} else if (prefix) {
out.push({ path: prefix, value: json });
}
return out;
}
function lastKey(path: string): string {
const m = /([A-Za-z0-9_$-]+)(\[\d+\])*$/.exec(path);
return m ? m[1] : path;
}
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const REQUEST_GUESS: [RegExp, string, boolean][] = [
[
/^(mobile|mobiles|mobileno|mobilenumber|msisdn|phone|phoneno|phonenumber|to|destination|recipient|recipients|number|contact)$/,
'customer.mobile',
true
],
[
/^(templateid|template|templatename|dlttemplateid|flowid)$/,
'message.templateId',
true
],
[
/^(message|text|body|content|sms|msg|smstext|var1|messagetext)$/,
'message.text',
true
],
[
/^(sender|senderid|from|source|shortcode)$/,
'bank.senderId',
false
],
[
/^(email|emailid|mail|emailaddress)$/,
'customer.email',
false
],
[
/^(customername|fullname|firstname|name)$/,
'customer.name',
false
],
[
/^(otp|code|pin)$/,
'message.otp',
false
],
[
/^(pan|pannumber)$/,
'customer.pan',
false
],
[
/^(aadhaar|aadhar|aadhaarnumber)$/,
'customer.aadhaar',
false
],
[
/^(dob|dateofbirth|birthdate)$/,
'customer.dob',
false
],
[
/^(membercode|memberid|memberreference)$/,
'bank.memberCode',
false
],
[
/^(accountno|accountnumber|acno|account)$/,
'account.number',
false
],
[
/^(cif|customerid|clientid|cifno)$/,
'customer.cif',
false
],
[
/^(amount|amt|txnamount|transactionamount)$/,
'txn.amount',
false
],
[
/^(txnid|transactionid|transactionreference|txnref)$/,
'txn.reference',
false
],
[
/^(txndate|transactiondate)$/,
'txn.date',
false
],
[
/^(fromdate|startdate|datefrom)$/,
'request.fromDate',
false
],
[
/^(todate|enddate|dateto)$/,
'request.toDate',
false
],
[
/^(reference|referenceno|referencenumber|ref|requestid|clientreference|correlationid)$/,
'request.reference',
false
],
[
/^(tenantidentifier|tenantid)$/,
'tenant.identifier',
false
],
[
/^(providerid)$/,
'sms.providerId',
false
]
];
function guessTransform(cbsField: string, value: unknown): string {
if (cbsField === 'customer.mobile') return 'DIGITS_ONLY';
if (cbsField === 'customer.email') return 'LOWER';
if (cbsField === 'bank.senderId') return 'UPPER';
if (cbsField === 'txn.amount') return 'NUMBER';
if (typeof value === 'boolean') return 'BOOLEAN';
if (typeof value === 'number') return 'NUMBER';
if (typeof value === 'string') {
if (/^\d{8}$/.test(value) && cbsField.includes('Date')) return 'DATE_DDMMYYYY';
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'DATE_YYYY_MM_DD';
}
return 'NONE';
}
/**
* A first mapping for a request payload: each value that looks like a known CBS field (mobile, message, template,
* sender ...) becomes "CBS field → path"; every other value becomes a fixed value at its path, so the whole payload is
* reproduced. Review and adjust.
*/
export function guessRequestRows(bodyText: string): ApiMappingRow[] {
const parsed = tryJson(bodyText);
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
const rows: ApiMappingRow[] = [];
const used = new Set<string>();
for (const leaf of flatten(parsed)) {
const key = norm(lastKey(leaf.path));
const hit = REQUEST_GUESS.find(([re]) => re.test(key));
if (hit && !used.has(hit[1])) {
used.add(hit[1]);
rows.push({
from: hit[1],
to: leaf.path,
transform: guessTransform(hit[1], leaf.value),
defaultValue: '',
mandatory: hit[2]
});
} else {
rows.push({
from: '',
to: leaf.path,
transform: guessTransform('', leaf.value),
defaultValue: leaf.value === null ? '' : String(leaf.value),
mandatory: false
});
}
}
return rows.filter((r) => r.from || r.defaultValue);
}
/**
* A first mapping for a response body: every value at its path → a CBS field named after the vendor and the key
* (`sms.messageId`). Ids / references are marked required.
*/
export function guessResponseRows(bodyText: string, vendorCode: string): ApiMappingRow[] {
const parsed = tryJson(bodyText);
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
const prefix = vendorCode.toLowerCase().split('_')[0] || 'vendor';
const rows: ApiMappingRow[] = [];
const used = new Set<string>();
for (const leaf of flatten(parsed)) {
const key = lastKey(leaf.path);
const n = norm(key);
let target: string;
if (/^(messageid|msgid|id|reference|requestid|referenceid|messageid)$/.test(n)) target = 'reference';
else if (/^(status|state|result|type)$/.test(n)) target = 'status';
else if (/^(error|errormessage|errormsg|reason|detail|description)$/.test(n)) target = 'error';
else target = key.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string) => (c ? c.toUpperCase() : ''));
let to = `${prefix}.${target}`;
for (let i = 2; used.has(to); i++) to = `${prefix}.${target}${i}`;
used.add(to);
rows.push({
from: leaf.path,
to,
transform: target === 'status' ? 'UPPER' : guessTransform('', leaf.value),
defaultValue: '',
mandatory: target === 'reference'
});
}
return rows;
}
export function secretNote(rows: KeyValue[]): string | null {
const n = rows.filter((r) => r.secret).length;
return n ? `${n} credential value(s) were hidden.` : null;
}
export { kv };
@@ -0,0 +1,61 @@
<!--
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/.
-->
@if (!rows.length) {
<div class="muted" style="font-size: 12px; margin-bottom: 6px">{{ emptyText }}</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th style="width: 34px">On</th>
<th>{{ keyLabel }}</th>
<th>{{ valueLabel }}</th>
<th>Note</th>
<th style="width: 60px">Secret</th>
@if (!fixedKeys) {
<th style="width: 40px"></th>
}
</tr>
</thead>
<tbody>
@for (r of rows; track $index; let i = $index) {
<tr>
<td style="text-align: center">
<input type="checkbox" [(ngModel)]="r.enabled" style="width: auto" />
</td>
<td>
<input class="cic-mono" [(ngModel)]="r.key" [placeholder]="keyPlaceholder" [readonly]="fixedKeys" />
</td>
<td>
<input
class="cic-mono"
[type]="r.secret ? 'password' : 'text'"
[(ngModel)]="r.value"
[placeholder]="valuePlaceholder"
autocomplete="off"
/>
</td>
<td><input [(ngModel)]="r.description" /></td>
<td style="text-align: center">
<input type="checkbox" [(ngModel)]="r.secret" style="width: auto" title="Show the value masked" />
</td>
@if (!fixedKeys) {
<td>
<button class="cic-btn sm danger" (click)="remove(i)">✕</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
@if (!fixedKeys) {
<button class="cic-btn sm" style="margin-top: 8px" (click)="add()">{{ addLabel }}</button>
}
@@ -0,0 +1,44 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { KeyValue } from '../api-management.service';
/** An editable table of name / value pairs: headers, query parameters, path parameters. Edits the array in place. */
@Component({
selector: 'mifosx-api-key-value-editor',
standalone: true,
imports: [
CommonModule,
FormsModule
],
templateUrl: './api-key-value-editor.component.html',
styleUrls: ['../../credit-bureau/cic-shared.scss']
})
export class ApiKeyValueEditorComponent {
@Input() rows: KeyValue[] = [];
@Input() keyLabel = 'Name';
@Input() valueLabel = 'Value';
@Input() keyPlaceholder = '';
@Input() valuePlaceholder = '';
@Input() addLabel = '+ Add';
/** Path parameters come from the path itself; the names cannot be edited or removed. */
@Input() fixedKeys = false;
@Input() emptyText = 'None.';
add(): void {
this.rows.push({ key: '', value: '', description: '', enabled: true, secret: false });
}
remove(i: number): void {
this.rows.splice(i, 1);
}
}
@@ -0,0 +1,55 @@
<!--
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/.
-->
@if (log) {
<div class="cic-modal-backdrop" (click)="closed.emit()">
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
<h3>
{{ log.vendorName }} · {{ log.apiName }}
<span class="cic-badge" [class.brand]="log.direction === 'IN'" [class.ok]="log.direction === 'OUT'">{{
log.direction
}}</span>
<span class="cic-badge" [class.good]="log.result === 'SUCCESS'" [class.bad]="log.result === 'FAILED'">{{
log.result
}}</span>
</h3>
<dl class="cic-kv" style="grid-template-columns: 120px minmax(0, 1fr); margin-bottom: 12px">
<dt>Reference</dt>
<dd class="cic-mono">{{ log.reference }}</dd>
<dt>Call</dt>
<dd class="cic-mono">{{ log.method }} {{ log.url }}</dd>
<dt>Result</dt>
<dd>HTTP {{ log.httpStatus }} in {{ log.durationMs }} ms · {{ log.time | slice: 0 : 16 }}</dd>
@if (log.error) {
<dt>Error</dt>
<dd class="tone-bad">{{ log.error }}</dd>
}
</dl>
<div class="cic-split">
<div>
<h4 style="margin: 0 0 6px; font-size: 13px">
{{ log.direction === 'OUT' ? 'Request headers' : 'Headers received / sent' }}
<span class="muted" style="font-weight: 400">(credentials masked)</span>
</h4>
<pre class="cic-code" style="max-height: 160px">{{ log.requestHeaders }}</pre>
<h4 style="margin: 12px 0 6px; font-size: 13px">Request payload</h4>
<pre class="cic-code">{{ log.request || '(empty)' }}</pre>
</div>
<div>
<h4 style="margin: 0 0 6px; font-size: 13px">Response headers</h4>
<pre class="cic-code" style="max-height: 160px">{{ log.responseHeaders }}</pre>
<h4 style="margin: 12px 0 6px; font-size: 13px">Response body</h4>
<pre class="cic-code">{{ log.response || '(empty)' }}</pre>
</div>
</div>
<div class="foot">
<button class="cic-btn" (click)="closed.emit()">Close</button>
</div>
</div>
</div>
}
@@ -0,0 +1,25 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { ApiLog } from '../api-management.service';
/** One API call: what was sent and what came back. */
@Component({
selector: 'mifosx-api-log-detail',
standalone: true,
imports: [CommonModule],
templateUrl: './api-log-detail.component.html',
styleUrls: ['../../credit-bureau/cic-shared.scss']
})
export class ApiLogDetailComponent {
@Input() log: ApiLog | null = null;
@Output() closed = new EventEmitter<void>();
}
@@ -0,0 +1,122 @@
<!--
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/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>API Call Logs</h1>
<p>Every API call the CBS made (OUT) or received (IN), across all vendors, with its request and response.</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
</div>
</div>
<div class="cic-card">
<div class="cic-grid cols-4" style="align-items: end">
<div class="cic-field">
<label>Vendor</label>
<select [(ngModel)]="filter.vendorId">
<option value="">All vendors</option>
@for (v of vendors; track v.id) {
<option [value]="v.id">{{ v.name }}</option>
}
</select>
</div>
<div class="cic-field">
<label>Direction</label>
<select [(ngModel)]="filter.direction">
<option value="">IN and OUT</option>
<option value="IN">IN — received</option>
<option value="OUT">OUT — sent</option>
</select>
</div>
<div class="cic-field">
<label>Result</label>
<select [(ngModel)]="filter.result">
<option value="">All</option>
<option value="SUCCESS">Success</option>
<option value="FAILED">Failed</option>
</select>
</div>
<div class="cic-field">
<label>Search</label>
<input [(ngModel)]="filter.search" (keyup.enter)="apply()" placeholder="Reference, API, path" />
</div>
</div>
<div class="cic-actions" style="margin-top: 12px; justify-content: flex-end">
<button class="cic-btn" (click)="reset()">Reset</button>
<button class="cic-btn primary" (click)="apply()">Apply</button>
</div>
</div>
<div class="cic-card">
<h2>
{{ total }} call(s)
<span class="cic-card-sub">
<span class="tone-good">{{ success }} success</span> ·
<span [class.tone-bad]="failed > 0">{{ failed }} failed</span>
</span>
</h2>
@if (loading && !items.length) {
<div class="cic-empty">Loading…</div>
} @else if (!items.length) {
<div class="cic-empty">No call matches the filter.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Time</th>
<th>Vendor</th>
<th>API</th>
<th>Direction</th>
<th>Call</th>
<th>HTTP</th>
<th class="num">ms</th>
<th>Result</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
@for (l of items; track l.id) {
<tr class="clickable" (click)="openLog = l">
<td>{{ l.time | slice: 0 : 16 }}</td>
<td>{{ l.vendorName }}</td>
<td>{{ l.apiName }}</td>
<td>
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
l.direction
}}</span>
</td>
<td class="cic-mono" style="font-size: 12px">{{ l.method }} {{ l.path }}</td>
<td>{{ l.httpStatus }}</td>
<td class="num">{{ l.durationMs }}</td>
<td>
<span class="cic-badge" [class.good]="l.result === 'SUCCESS'" [class.bad]="l.result === 'FAILED'">{{
l.result
}}</span>
</td>
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="cic-pager">
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
<span class="cic-actions">
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
</span>
</div>
}
</div>
</div>
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
@@ -0,0 +1,77 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
import { ApiLog, ApiManagementService, ApiVendor } from '../api-management.service';
/** Every API call, IN and OUT, across all vendors, with its request and response. */
@Component({
selector: 'mifosx-api-logs',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
ApiLogDetailComponent
],
templateUrl: './api-logs.component.html',
styleUrls: ['../../credit-bureau/cic-shared.scss']
})
export class ApiLogsComponent implements OnInit {
private service = inject(ApiManagementService);
private route = inject(ActivatedRoute);
vendors: ApiVendor[] = [];
filter = { vendorId: '', direction: '', result: '', search: '' };
items: ApiLog[] = [];
total = 0;
success = 0;
failed = 0;
offset = 0;
readonly limit = 15;
loading = false;
openLog: ApiLog | null = null;
ngOnInit(): void {
const vendorId = this.route.snapshot.queryParamMap.get('vendorId');
if (vendorId) this.filter.vendorId = vendorId;
this.service.vendors().subscribe((v) => (this.vendors = v));
this.load();
}
load(): void {
this.loading = true;
this.service.logs({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe((r) => {
this.items = r.items;
this.total = r.total;
this.success = r.success;
this.failed = r.failed;
this.loading = false;
});
}
apply(): void {
this.offset = 0;
this.load();
}
reset(): void {
this.filter = { vendorId: '', direction: '', result: '', search: '' };
this.apply();
}
page(delta: number): void {
this.offset = Math.max(0, this.offset + delta * this.limit);
this.load();
}
}
@@ -0,0 +1,58 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
import { ApiLogsComponent } from './api-logs/api-logs.component';
const routes: Routes = [
Route.withShell([
{
path: 'api-management',
data: { title: 'API Management', breadcrumb: 'API Management' },
children: [
{
path: '',
redirectTo: 'vendors',
pathMatch: 'full'
},
{
path: 'vendors',
data: { breadcrumb: 'Vendors' },
children: [
{
path: '',
component: ApiVendorsComponent,
data: { title: 'API Vendors & Setup' }
},
{
path: ':id',
component: ApiVendorSetupComponent,
data: { title: 'Vendor Setup', breadcrumb: 'Setup' }
}
]
},
{
path: 'logs',
component: ApiLogsComponent,
data: { title: 'API Call Logs', breadcrumb: 'Call Logs' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ApiManagementRoutingModule {}
@@ -0,0 +1,29 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { ApiManagementRoutingModule } from './api-management-routing.module';
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
import { ApiLogsComponent } from './api-logs/api-logs.component';
import { ApiLogDetailComponent } from './api-log-detail/api-log-detail.component';
/** API Management: connect any outside API service (CIC, SMS, WhatsApp ...) - vendor, work, API set, IN / OUT. */
@NgModule({
imports: [
SharedModule,
ApiManagementRoutingModule,
ApiVendorsComponent,
ApiVendorSetupComponent,
ApiLogsComponent,
ApiLogDetailComponent
]
})
export class ApiManagementModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,777 @@
<!--
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/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>{{ vendor?.name || 'Vendor setup' }}</h1>
<p>{{ vendor?.work }}</p>
</div>
<div class="cic-actions">
@if (vendor) {
<span class="cic-badge" [class.good]="vendor.active">{{ vendor.active ? 'Active' : 'Switched off' }}</span>
<span
class="cic-badge"
[class.warn]="vendor.environment === 'SIMULATOR'"
[class.good]="vendor.environment === 'PRODUCTION'"
>{{ vendor.environment }}</span
>
}
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
</div>
</div>
@if (error && !editing && !importOpen) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info && !editing) {
<div class="cic-alert success">{{ info }}</div>
}
@if (loading && !vendor) {
<div class="cic-card cic-empty">Loading…</div>
}
@if (vendor) {
@if (vendor.configNote) {
<div class="cic-alert info">{{ vendor.configNote }}</div>
}
@if (vendor.links.length) {
<div class="cic-alert info">
{{ vendor.code }} also has its own screens:
@for (l of vendor.links; track l.route) {
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
}
</div>
}
<div class="cic-tabs">
<button [class.active]="tab === 'connection'" (click)="tab = 'connection'">1 · Connection</button>
<button [class.active]="tab === 'apis'" (click)="tab = 'apis'">2 · API set ({{ vendor.apis.length }})</button>
<button [class.active]="tab === 'test'" (click)="tab = 'test'">3 · Test</button>
<button [class.active]="tab === 'calls'" (click)="tab = 'calls'; loadCalls()">Recent calls</button>
</div>
<!-- CONNECTION -->
@if (tab === 'connection') {
<div class="cic-card">
<h2>Vendor and connection</h2>
<div class="cic-grid cols-2">
<div class="cic-field">
<label>Vendor name</label>
<input [(ngModel)]="form.name" />
</div>
<div class="cic-field">
<label>Environment</label>
<select [(ngModel)]="form.environment">
<option value="SIMULATOR">SIMULATOR — no network call</option>
<option value="UAT">UAT — vendor's test environment</option>
<option value="PRODUCTION">PRODUCTION — live</option>
</select>
</div>
<div class="cic-field" style="grid-column: 1 / -1">
<label>Work — what this vendor is used for</label>
<input [(ngModel)]="form.work" />
</div>
<div class="cic-field" style="grid-column: 1 / -1">
<label>Base URL</label>
<input class="cic-mono" [(ngModel)]="form.baseUrl" placeholder="https://api.vendor.example/v1" />
<span class="hint">Every OUT / pull API path is added to this. PRODUCTION needs https://.</span>
</div>
<div class="cic-field">
<label>Authentication</label>
<select [(ngModel)]="form.authType">
@for (a of authTypes; track a.key) {
<option [value]="a.key">{{ a.label }}</option>
}
</select>
</div>
@if (form.authType === 'BASIC') {
<div class="cic-field">
<label>User id</label>
<input [(ngModel)]="form.username" autocomplete="off" />
</div>
}
@if (form.authType === 'API_KEY') {
<div class="cic-field">
<label>API key header name</label>
<input class="cic-mono" [(ngModel)]="form.apiKeyHeader" />
</div>
}
@if (needsSecret()) {
<div class="cic-field">
<label>{{ secretLabel() }}</label>
<input
type="password"
[(ngModel)]="form.secret"
autocomplete="new-password"
[placeholder]="vendor.hasSecret ? '•••••• stored — leave blank to keep' : 'Enter to store (encrypted)'"
/>
@if (vendor.hasSecret) {
<label style="display: flex; gap: 6px; align-items: center; font-weight: 500; font-size: 12px">
<input type="checkbox" [(ngModel)]="form.clearSecret" style="width: auto" /> Remove the stored
credential
</label>
}
</div>
}
<div class="cic-field">
<label>Sender / member id</label>
<input [(ngModel)]="form.senderId" />
<span class="hint"
>The bank's id at this vendor (SMS sender id, WhatsApp number id, CIC member code …).</span
>
</div>
<div class="cic-field">
<label>Timeout (ms)</label>
<input type="number" min="1000" max="300000" [(ngModel)]="form.timeoutMs" />
</div>
<div class="cic-field">
<label>Retries on failure</label>
<input type="number" min="0" max="5" [(ngModel)]="form.retries" />
</div>
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px">
<input type="checkbox" [(ngModel)]="form.active" /> Vendor active
</label>
</div>
<h4 style="margin: 18px 0 6px; font-size: 13px">
Default headers
<span class="muted" style="font-weight: 400"
>— sent on every API of this vendor (an API can override one)</span
>
</h4>
<mifosx-api-key-value-editor
[rows]="form.defaultHeaders"
keyLabel="Header"
keyPlaceholder="Accept"
addLabel="+ Add header"
emptyText="No default header."
></mifosx-api-key-value-editor>
<div class="cic-actions" style="margin-top: 16px">
<button class="cic-btn primary" (click)="saveConnection()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save connection' }}
</button>
<button class="cic-btn" (click)="tab = 'apis'">Next: API set →</button>
</div>
</div>
}
<!-- API SET -->
@if (tab === 'apis') {
<div class="cic-card">
<h2>
API set
<span class="cic-actions">
<button class="cic-btn sm" (click)="newApi('OUT')">+ OUT API (CBS calls vendor)</button>
<button class="cic-btn sm" (click)="newApi('IN')">+ IN API (data comes into CBS)</button>
</span>
</h2>
@if (!vendor.apis.length) {
<div class="cic-empty">
No API yet. Add one, then fill it in by hand or read it from a JSON / cURL / Postman file.
</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>API</th>
<th>Direction</th>
<th>Call</th>
<th>Used for</th>
<th>Headers / query</th>
<th>Mapping</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
@for (a of vendor.apis; track a.id) {
<tr>
<td>
<b>{{ a.name }}</b>
</td>
<td>
<span class="cic-badge" [class.brand]="a.direction === 'IN'" [class.ok]="a.direction === 'OUT'">{{
a.direction
}}</span>
@if (a.mode) {
<span class="muted" style="font-size: 11px"> {{ a.mode === 'PULL' ? 'pull' : 'push' }}</span>
}
</td>
<td class="cic-mono" style="font-size: 12px">{{ a.method }} {{ a.path }}</td>
<td style="max-width: 260px; white-space: normal">{{ a.usedFor }}</td>
<td>{{ a.headers.length }} / {{ a.queryParams.length }}</td>
<td>{{ a.requestRows.length }} / {{ a.responseRows.length }}</td>
<td>
<span class="cic-badge" [class.good]="a.active">{{ a.active ? 'Yes' : 'Off' }}</span>
</td>
<td style="white-space: nowrap">
<button class="cic-btn sm" (click)="editApi(a)">Edit</button>
<button class="cic-btn sm danger" (click)="deleteApi(a)">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- TEST -->
@if (tab === 'test') {
<div class="cic-card">
<h2>
Test an API
<span class="cic-card-sub">simulated — nothing is sent to the vendor; the result is added to the logs</span>
</h2>
@if (!vendor.apis.length) {
<div class="cic-empty">Add an API first.</div>
} @else {
<div class="cic-field" style="max-width: 420px">
<label>API</label>
<select [(ngModel)]="testApiId" (ngModelChange)="fillTestBody()">
@for (a of vendor.apis; track a.id) {
<option [ngValue]="a.id">{{ a.direction }} · {{ a.name }}</option>
}
</select>
</div>
@if (testPreview; as p) {
<h4 style="margin: 14px 0 6px; font-size: 13px">The request</h4>
<pre class="cic-code" style="max-height: 200px">{{ p.method }} {{ p.url }}
@for (h of p.headers; track h.key) {
{{ h.key }}: {{ h.value }}
}</pre>
}
<div class="cic-field" style="margin-top: 12px">
<label>Request body</label>
<textarea class="cic-mono" rows="9" [(ngModel)]="testBody"></textarea>
</div>
<button class="cic-btn primary" style="margin-top: 12px" (click)="runTest()" [disabled]="testing">
{{ testing ? 'Sending…' : 'Send test' }}
</button>
}
</div>
@if (testResult) {
<div class="cic-alert success">
{{ testResult.reference }} — HTTP {{ testResult.httpStatus }} in {{ testResult.durationMs }} ms
</div>
<div class="cic-split">
<div class="cic-card">
<h2>Response headers</h2>
<pre class="cic-code">{{ testResult.responseHeaders }}</pre>
</div>
<div class="cic-card">
<h2>Response body</h2>
<pre class="cic-code">{{ testResult.response || '(empty)' }}</pre>
</div>
</div>
}
}
<!-- RECENT CALLS -->
@if (tab === 'calls') {
<div class="cic-card">
<h2>
Recent calls
<a class="cic-btn sm" routerLink="/api-management/logs" [queryParams]="{ vendorId: vendor.id }">All logs</a>
</h2>
@if (!calls.length) {
<div class="cic-empty">No call yet.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Time</th>
<th>API</th>
<th>Direction</th>
<th>HTTP</th>
<th class="num">ms</th>
<th>Result</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
@for (l of calls; track l.id) {
<tr class="clickable" (click)="openLog = l">
<td>{{ l.time | slice: 0 : 16 }}</td>
<td>{{ l.apiName }}</td>
<td>
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
l.direction
}}</span>
</td>
<td>{{ l.httpStatus }}</td>
<td class="num">{{ l.durationMs }}</td>
<td>
<span
class="cic-badge"
[class.good]="l.result === 'SUCCESS'"
[class.bad]="l.result === 'FAILED'"
>{{ l.result }}</span
>
</td>
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
<!-- API editor -->
@if (editing) {
<div class="cic-modal-backdrop" (click)="editing = null">
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
<h3 style="margin: 0">
{{ isNewApi ? 'New' : 'Edit' }} API
<span class="cic-badge" [class.brand]="editing.direction === 'IN'" [class.ok]="editing.direction === 'OUT'">{{
editing.direction
}}</span>
@if (editing.mode) {
<span class="muted" style="font-size: 12px; font-weight: 500">
{{ editing.mode === 'PULL' ? 'pull' : 'push' }}</span
>
}
</h3>
<button class="cic-btn sm primary" (click)="openImport()">⬆ Import from JSON / cURL / Postman</button>
</div>
<p class="muted" style="font-size: 12px; margin: 6px 0 10px">
@if (editing.direction === 'OUT') {
OUT — the CBS calls the vendor: fill the request it sends and the response it expects.
} @else if (editing.mode === 'PULL') {
IN (pull) — the CBS asks the vendor for data on a schedule: fill the request it sends and the response it
reads.
} @else {
IN (push) — the vendor calls a CBS path: fill what the vendor sends us and what we answer.
}
Type everything in, or read it from a file with the import button — then check each tab.
</p>
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
<div class="cic-tabs">
<button [class.active]="editorTab === 'basics'" (click)="editorTab = 'basics'">1 · Basics</button>
<button [class.active]="editorTab === 'request'" (click)="editorTab = 'request'">
2 · {{ outbound ? 'Request' : 'What the vendor sends' }}
</button>
<button [class.active]="editorTab === 'response'" (click)="editorTab = 'response'">
3 · {{ outbound ? 'Response' : 'Our answer' }}
</button>
</div>
<!-- BASICS -->
@if (editorTab === 'basics') {
<div class="cic-grid cols-4">
<div class="cic-field" style="grid-column: span 2">
<label>API name</label>
<input [(ngModel)]="editing.name" placeholder="e.g. Send SMS" />
</div>
<div class="cic-field">
<label>Direction</label>
<select [(ngModel)]="editing.direction" (ngModelChange)="directionChanged()">
<option value="OUT">OUT — CBS calls vendor</option>
<option value="IN">IN — data comes into CBS</option>
</select>
</div>
@if (editing.direction === 'IN') {
<div class="cic-field">
<label>How data arrives</label>
<select [(ngModel)]="editing.mode">
<option value="PULL">PULL — CBS asks on a schedule</option>
<option value="PUSH">PUSH — vendor calls a CBS path</option>
</select>
</div>
} @else {
<div></div>
}
<div class="cic-field">
<label>HTTP method</label>
<select [(ngModel)]="editing.method">
@for (m of methods; track m) {
<option [value]="m">{{ m }}</option>
}
</select>
</div>
<div class="cic-field" style="grid-column: span 3">
<label>{{ outbound ? "Vendor's API path (after the base URL)" : 'CBS path the vendor calls' }}</label>
<input
class="cic-mono"
[(ngModel)]="editing.path"
(ngModelChange)="pathChanged()"
placeholder="/messages/send"
/>
<span class="hint"
>Use {{ '{' }}name{{ '}' }} for a value that changes, e.g. /v21.0/{{ '{' }}senderId{{
'}'
}}/messages.</span
>
</div>
<div class="cic-field" style="grid-column: span 3">
<label>When is it used</label>
<input [(ngModel)]="editing.usedFor" placeholder="e.g. OTP and transaction alerts" />
</div>
<div class="cic-field">
<label>Timeout for this API (ms)</label>
<input type="number" min="1000" max="300000" [(ngModel)]="editing.timeoutMs" placeholder="vendor's" />
</div>
<div class="cic-field">
<label>Retries for this API</label>
<input type="number" min="0" max="5" [(ngModel)]="editing.retries" placeholder="vendor's" />
</div>
</div>
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px; margin: 12px 0">
<input type="checkbox" [(ngModel)]="editing.active" /> API active
</label>
@if (preview(editing); as p) {
<h4 style="margin: 14px 0 6px; font-size: 13px">
{{ outbound ? 'The call as it will go out' : 'The call the vendor will make to us' }}
<span class="muted" style="font-weight: 400"> (credentials masked)</span>
</h4>
<pre class="cic-code" style="max-height: 220px">{{ p.method }} {{ p.url }}
@for (h of p.headers; track h.key) {
{{ h.key }}: {{ h.value }}
}
{{ p.body }}</pre>
}
}
<!-- REQUEST -->
@if (editorTab === 'request') {
<h4 style="margin: 4px 0 6px; font-size: 13px">Path parameters</h4>
<mifosx-api-key-value-editor
[rows]="editing.pathParams"
keyLabel="Parameter"
valueLabel="Value (a CBS placeholder or fixed value)"
[fixedKeys]="true"
emptyText="The path has no {name} placeholder."
></mifosx-api-key-value-editor>
<h4 style="margin: 16px 0 6px; font-size: 13px">Query parameters</h4>
<mifosx-api-key-value-editor
[rows]="editing.queryParams"
keyLabel="Parameter"
keyPlaceholder="version"
addLabel="+ Add query parameter"
emptyText="No query parameter."
></mifosx-api-key-value-editor>
<h4 style="margin: 16px 0 6px; font-size: 13px">
Headers
<span class="muted" style="font-weight: 400"
>— this API's own; the vendor's default headers and its authentication are added automatically</span
>
</h4>
<mifosx-api-key-value-editor
[rows]="editing.headers"
keyLabel="Header"
keyPlaceholder="X-Request-Id"
valuePlaceholder="{{ '{{' }}request.reference{{ '}}' }}"
addLabel="+ Add header"
emptyText="No header of its own."
></mifosx-api-key-value-editor>
<div class="cic-grid cols-4" style="margin-top: 16px">
<div class="cic-field" style="grid-column: span 2">
<label>Content type</label>
<select [(ngModel)]="editing.contentType">
@for (c of contentTypes; track c.key) {
<option [value]="c.key">{{ c.label }}</option>
}
</select>
</div>
</div>
<div class="cic-field" style="margin-top: 12px">
<label>{{ outbound ? 'Request payload' : 'Payload the vendor sends us' }}</label>
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.requestBody"></textarea>
<span class="hint">
{{
outbound
? 'The constant parts of the payload; the mapping below puts CBS values into it.'
: 'A sample of what arrives; the mapping reads values out of it.'
}}
</span>
</div>
<h4 style="margin: 16px 0 6px; font-size: 13px">
{{ mappingTitle('request') }}
<span class="muted" style="font-weight: 400; font-size: 12px">
· {{ matchedCount('request') }} of {{ editing.requestRows.length }} rows found in the sample
{{ 'request' === 'request' ? 'payload' : 'body' }}
</span>
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('request')">
Build rows from the payload
</button>
</h4>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>{{ fromLabel('request') }}</th>
<th>{{ toLabel('request') }}</th>
<th>Transform</th>
<th>Fixed / default value</th>
<th>In the sample payload</th>
<th>Required</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
@let requestMatch = sampleMatches('request');
@for (r of editing.requestRows; track $index; let i = $index) {
<tr>
<td>
@if (fromIsCbs('request')) {
<select [(ngModel)]="r.from">
<option value="">— fixed value (set it on the right) —</option>
@for (f of cbsFields; track f.key) {
<option [value]="f.key">{{ f.label }}</option>
}
</select>
} @else {
<input class="cic-mono" [(ngModel)]="r.from" />
}
</td>
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
<td>
<select [(ngModel)]="r.transform">
@for (t of transforms; track t.key) {
<option [value]="t.key">{{ t.label }}</option>
}
</select>
</td>
<td><input [(ngModel)]="r.defaultValue" /></td>
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
@if (requestMatch[i].found) {
<span class="tone-good">✓ {{ requestMatch[i].value }}</span>
} @else if (r.defaultValue) {
<span class="muted">fixed: {{ r.defaultValue }}</span>
} @else {
<span class="tone-bad">✗ {{ requestMatch[i].value || 'no path' }}</span>
}
</td>
<td style="text-align: center">
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
</td>
<td><button class="cic-btn sm danger" (click)="removeRow('request', i)">✕</button></td>
</tr>
}
</tbody>
</table>
</div>
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('request')">+ Add field</button>
}
<!-- RESPONSE -->
@if (editorTab === 'response') {
<div class="cic-grid cols-4">
<div class="cic-field">
<label>Success status code(s)</label>
<input class="cic-mono" [(ngModel)]="editing.successCodes" placeholder="200, 202" />
<span class="hint">{{
outbound ? 'Codes the vendor answers with when it worked.' : 'Codes the CBS answers with.'
}}</span>
</div>
</div>
<h4 style="margin: 16px 0 6px; font-size: 13px">
{{ outbound ? 'Response headers' : 'Headers of our answer' }}
</h4>
<mifosx-api-key-value-editor
[rows]="editing.responseHeaders"
keyLabel="Header"
keyPlaceholder="Content-Type"
addLabel="+ Add header"
emptyText="No response header noted."
></mifosx-api-key-value-editor>
<div class="cic-grid cols-2" style="margin-top: 16px">
<div class="cic-field">
<label>{{ outbound ? 'Success response body' : 'Our answer body' }}</label>
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.responseBody"></textarea>
</div>
<div class="cic-field">
<label>Error response body</label>
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.errorBody"></textarea>
<span class="hint"
>What comes back when it fails (wrong key, rate limit …) — shown in the logs for a failed call.</span
>
</div>
</div>
<h4 style="margin: 16px 0 6px; font-size: 13px">
{{ mappingTitle('response') }}
<span class="muted" style="font-weight: 400; font-size: 12px">
· {{ matchedCount('response') }} of {{ editing.responseRows.length }} rows found in the sample
{{ 'response' === 'request' ? 'payload' : 'body' }}
</span>
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('response')">
Build rows from the body
</button>
</h4>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>{{ fromLabel('response') }}</th>
<th>{{ toLabel('response') }}</th>
<th>Transform</th>
<th>Fixed / default value</th>
<th>In the sample payload</th>
<th>Required</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
@let responseMatch = sampleMatches('response');
@for (r of editing.responseRows; track $index; let i = $index) {
<tr>
<td>
@if (fromIsCbs('response')) {
<select [(ngModel)]="r.from">
<option value="">— fixed value (set it on the right) —</option>
@for (f of cbsFields; track f.key) {
<option [value]="f.key">{{ f.label }}</option>
}
</select>
} @else {
<input class="cic-mono" [(ngModel)]="r.from" />
}
</td>
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
<td>
<select [(ngModel)]="r.transform">
@for (t of transforms; track t.key) {
<option [value]="t.key">{{ t.label }}</option>
}
</select>
</td>
<td><input [(ngModel)]="r.defaultValue" /></td>
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
@if (responseMatch[i].found) {
<span class="tone-good">✓ {{ responseMatch[i].value }}</span>
} @else if (r.defaultValue) {
<span class="muted">fixed: {{ r.defaultValue }}</span>
} @else {
<span class="tone-bad">✗ {{ responseMatch[i].value || 'no path' }}</span>
}
</td>
<td style="text-align: center">
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
</td>
<td><button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button></td>
</tr>
}
</tbody>
</table>
</div>
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('response')">+ Add field</button>
}
<div class="foot">
<button class="cic-btn" (click)="editing = null">Cancel</button>
<button class="cic-btn primary" (click)="saveApi()" [disabled]="savingApi">
{{ savingApi ? 'Saving…' : 'Save API' }}
</button>
</div>
</div>
</div>
}
<!-- Import from JSON / cURL / Postman / HAR -->
@if (importOpen && editing) {
<div class="cic-modal-backdrop" style="z-index: 1100" (click)="importOpen = false">
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
<h3>Import from JSON / cURL / Postman</h3>
<p class="muted" style="font-size: 12px; margin: 0 0 10px">
Paste it or choose a file —
{{ outbound ? 'the request and the response' : 'what the vendor sends us and what we answer' }}. Any of these
works: a <b>cURL</b> command, a <b>Postman</b> collection or request, a <b>HAR</b> capture, an API definition
(<code>{{ '{' }} method, url, headers, query, body, status … {{ '}' }}</code
>), or just the <b>JSON payload</b>. The method, URL / path, query parameters, headers, content type, payload,
status code, response headers, bodies and a first CBS mapping are filled in for you. Passwords, keys and tokens
are never kept.
</p>
@if (importError) {
<div class="cic-alert error">{{ importError }}</div>
}
<div class="cic-grid cols-2">
<div class="cic-field">
<label
>{{ outbound ? 'Request' : 'What the vendor sends us' }} — cURL, Postman, HAR, definition or payload</label
>
<textarea
class="cic-mono"
rows="12"
[(ngModel)]="importRequestText"
placeholder="curl -X POST 'https://api.vendor.in/v1/send' -H 'Content-Type: application/json' -d '{ ... }'"
></textarea>
<div style="display: flex; gap: 8px; align-items: center">
<input
type="file"
accept=".json,.txt,.curl,.har,.sh,application/json,text/plain"
(change)="onFile($event, 'request')"
/>
@if (importRequestFile) {
<span class="muted" style="font-size: 12px">{{ importRequestFile }}</span>
}
</div>
</div>
<div class="cic-field">
<label>{{ outbound ? 'Response' : 'Our answer' }} — JSON body, or with status and headers (optional)</label>
<textarea
class="cic-mono"
rows="12"
[(ngModel)]="importResponseText"
placeholder='{ "status": 200, "headers": { "Content-Type": "application/json" }, "body": { ... } }'
></textarea>
<div style="display: flex; gap: 8px; align-items: center">
<input
type="file"
accept=".json,.txt,.har,application/json,text/plain"
(change)="onFile($event, 'response')"
/>
@if (importResponseFile) {
<span class="muted" style="font-size: 12px">{{ importResponseFile }}</span>
}
</div>
</div>
</div>
<div style="display: flex; gap: 20px; flex-wrap: wrap; margin-top: 12px; font-size: 13px">
<label style="display: flex; gap: 6px; align-items: center">
<input type="checkbox" [(ngModel)]="importReplace" /> Replace what is there now (headers, query, mapping rows)
— untick to add to it
</label>
<label style="display: flex; gap: 6px; align-items: center">
<input type="checkbox" [(ngModel)]="importGenerate" /> Build the CBS field mapping from the payloads
</label>
</div>
<div class="foot">
<button class="cic-btn" (click)="importOpen = false">Cancel</button>
<button class="cic-btn primary" (click)="applyImport()">Read and fill in</button>
</div>
</div>
</div>
}
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
@@ -0,0 +1,600 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ApiKeyValueEditorComponent } from '../api-key-value-editor/api-key-value-editor.component';
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
import {
guessRequestRows,
guessResponseRows,
ImportedRequest,
ImportedResponse,
parseRequestText,
parseResponseText
} from '../api-import.util';
import {
API_AUTH_TYPES,
API_CBS_FIELDS,
API_CONTENT_TYPES,
API_METHODS,
API_TRANSFORMS,
ApiLog,
ApiManagementService,
ApiMappingRow,
ApiVendor,
blankApi,
buildRequestPreview,
callsVendor,
KeyValue,
pathParamNames,
RequestPreview,
VendorApi
} from '../api-management.service';
/**
* One vendor's setup: its connection (URL, authentication, default headers, credentials), its API set - each API IN or
* OUT with the full HTTP detail and the field mapping, typed in or read from a JSON / cURL / Postman / HAR file - a
* test call, and its recent calls.
*/
@Component({
selector: 'mifosx-api-vendor-setup',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
ApiLogDetailComponent,
ApiKeyValueEditorComponent
],
templateUrl: './api-vendor-setup.component.html',
styleUrls: ['../../credit-bureau/cic-shared.scss']
})
export class ApiVendorSetupComponent implements OnInit {
private service = inject(ApiManagementService);
private route = inject(ActivatedRoute);
vendor: ApiVendor | null = null;
tab: 'connection' | 'apis' | 'test' | 'calls' = 'connection';
loading = false;
error: string | null = null;
info: string | null = null;
// connection
form: any = {};
saving = false;
// API set
editing: VendorApi | null = null;
isNewApi = false;
savingApi = false;
editorTab: 'basics' | 'request' | 'response' = 'basics';
// import from JSON / cURL / Postman / HAR
importOpen = false;
importRequestText = '';
importResponseText = '';
importRequestFile = '';
importResponseFile = '';
importReplace = true;
importGenerate = true;
importError: string | null = null;
// test
testApiId: number | null = null;
testBody = '';
testing = false;
testResult: ApiLog | null = null;
// calls
calls: ApiLog[] = [];
openLog: ApiLog | null = null;
readonly authTypes = Object.entries(API_AUTH_TYPES).map(
([
key,
label
]) => ({ key, label })
);
readonly contentTypes = Object.entries(API_CONTENT_TYPES).map(
([
key,
label
]) => ({ key, label })
);
readonly transforms = Object.entries(API_TRANSFORMS).map(
([
key,
label
]) => ({ key, label })
);
readonly cbsFields = Object.entries(API_CBS_FIELDS).map(
([
key,
label
]) => ({ key, label })
);
readonly methods = API_METHODS;
ngOnInit(): void {
this.load(Number(this.route.snapshot.paramMap.get('id')));
}
load(id: number): void {
this.loading = true;
this.service.vendor(id).subscribe({
next: (v) => {
this.vendor = v;
this.loading = false;
this.resetForm(v);
if (!this.testApiId || !v.apis.some((a) => a.id === this.testApiId)) this.testApiId = v.apis[0]?.id ?? null;
this.fillTestBody();
this.loadCalls();
},
error: (e) => {
this.loading = false;
this.error = e?.error?.defaultUserMessage ?? 'Could not load the vendor.';
}
});
}
private resetForm(v: ApiVendor): void {
this.form = {
name: v.name,
work: v.work,
environment: v.environment,
baseUrl: v.baseUrl,
authType: v.authType,
username: v.username,
apiKeyHeader: v.apiKeyHeader,
secret: '',
clearSecret: false,
senderId: v.senderId,
timeoutMs: v.timeoutMs,
retries: v.retries,
defaultHeaders: JSON.parse(JSON.stringify(v.defaultHeaders)),
active: v.active
};
}
loadCalls(): void {
if (!this.vendor) return;
this.service.logs({ vendorId: String(this.vendor.id), limit: 12 }).subscribe((r) => (this.calls = r.items));
}
// ── connection ──
needsSecret(): boolean {
return this.form.authType !== 'NONE';
}
secretLabel(): string {
const a = this.form.authType;
return a === 'BASIC'
? 'Password'
: a === 'API_KEY'
? 'API key'
: a === 'BEARER_TOKEN'
? 'Bearer token'
: 'Client secret';
}
saveConnection(): void {
if (!this.vendor) return;
this.saving = true;
this.error = null;
this.info = null;
const body = { ...this.form };
if (!body.secret) delete body.secret;
this.service.saveVendor(this.vendor.id, body).subscribe({
next: (v) => {
this.saving = false;
this.vendor = v;
this.resetForm(v);
this.info = `${v.name} connection saved.`;
},
error: (e) => {
this.saving = false;
this.error = e?.error?.defaultUserMessage ?? 'Could not save the connection.';
}
});
}
// ── API set ──
newApi(direction: 'IN' | 'OUT'): void {
this.isNewApi = true;
this.error = null;
this.info = null;
this.editorTab = 'basics';
this.editing = blankApi(direction);
}
editApi(a: VendorApi): void {
this.isNewApi = false;
this.error = null;
this.info = null;
this.editorTab = 'basics';
this.editing = JSON.parse(JSON.stringify(a));
}
/** True when the CBS makes the HTTP call (OUT, or IN pull); false for an IN push webhook. */
get outbound(): boolean {
return !!this.editing && callsVendor(this.editing);
}
directionChanged(): void {
if (!this.editing) return;
this.editing.mode = this.editing.direction === 'IN' ? (this.editing.mode ?? 'PUSH') : undefined;
}
/** Keeps the path-parameter table in step with the {names} typed in the path. */
pathChanged(): void {
if (!this.editing) return;
const names = pathParamNames(this.editing.path);
const kept = this.editing.pathParams.filter((p) => names.includes(p.key));
for (const n of names) {
if (!kept.some((p) => p.key === n))
kept.push({ key: n, value: '', description: '', enabled: true, secret: false });
}
this.editing.pathParams = kept;
}
rowsOf(side: 'request' | 'response'): ApiMappingRow[] {
if (!this.editing) return [];
return side === 'request' ? this.editing.requestRows : this.editing.responseRows;
}
/** Whether the left column of a mapping table holds a CBS value (a pick list) or a path in the vendor's JSON. */
fromIsCbs(side: 'request' | 'response'): boolean {
return this.outbound ? side === 'request' : side === 'response';
}
mappingTitle(side: 'request' | 'response'): string {
if (this.outbound) return side === 'request' ? 'Request mapping (CBS → vendor)' : 'Response mapping (vendor → CBS)';
return side === 'request' ? 'Incoming data mapping (vendor → CBS)' : 'Answer mapping (CBS → vendor)';
}
fromLabel(side: 'request' | 'response'): string {
return this.fromIsCbs(side) ? 'CBS value' : "Vendor's JSON path";
}
toLabel(side: 'request' | 'response'): string {
if (this.fromIsCbs(side)) return side === 'request' ? "Vendor's request path" : 'Answer path';
return 'CBS field';
}
private parsedCache = new Map<string, unknown>();
private parsed(text: string): unknown {
if (!this.parsedCache.has(text)) {
let v: unknown = undefined;
try {
v = JSON.parse(text);
} catch {
v = undefined;
}
if (this.parsedCache.size > 20) this.parsedCache.clear();
this.parsedCache.set(text, v);
}
return this.parsedCache.get(text);
}
/** Reads a.b[0].c out of a parsed payload; undefined when it is not there. */
private readPath(root: unknown, path: string): unknown {
let cur: any = root;
for (const part of path.match(/[^.[\]]+/g) ?? []) {
if (cur === null || cur === undefined) return undefined;
cur = cur[/^\d+$/.test(part) ? Number(part) : part];
}
return cur;
}
/**
* How a mapping row matches the sample payload on its side: the value found at the row's JSON path. Rows after a
* list (e.g. data.alerts) are read from the first item of that list, as the CBS does for each item.
*/
sampleMatches(side: 'request' | 'response'): { found: boolean; value: string }[] {
if (!this.editing) return [];
const body = this.parsed(side === 'request' ? this.editing.requestBody : this.editing.responseBody);
const pathIsTo = this.fromIsCbs(side);
let item: unknown = undefined;
return this.rowsOf(side).map((r) => {
const path = (pathIsTo ? r.to : r.from).trim();
if (!path || body === undefined) return { found: false, value: body === undefined ? 'payload is not JSON' : '' };
let v = this.readPath(body, path);
if (v === undefined && item !== undefined) v = this.readPath(item, path);
if (Array.isArray(v)) {
if (item === undefined && v.length) item = v[0];
return { found: true, value: `list of ${v.length}` };
}
if (v === undefined) return { found: false, value: 'not in the sample payload' };
const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
return { found: true, value: text.length > 40 ? text.slice(0, 40) + '…' : text };
});
}
matchedCount(side: 'request' | 'response'): number {
return this.sampleMatches(side).filter((m) => m.found).length;
}
addRow(side: 'request' | 'response'): void {
this.rowsOf(side).push({ from: '', to: '', transform: 'NONE', defaultValue: '', mandatory: false });
}
removeRow(side: 'request' | 'response', i: number): void {
this.rowsOf(side).splice(i, 1);
}
/** Builds mapping rows from the payload typed or imported for that side. */
generateRows(side: 'request' | 'response'): void {
if (!this.editing || !this.vendor) return;
const body = side === 'request' ? this.editing.requestBody : this.editing.responseBody;
// Reading values OUT of a payload (vendor → CBS) vs putting values INTO one (CBS → vendor).
const reading = !this.fromIsCbs(side);
const rows = reading ? guessResponseRows(body, this.vendor.code) : guessRequestRows(body);
if (!rows.length) {
this.error = 'That payload has no JSON values to build a mapping from.';
return;
}
this.error = null;
const target = this.rowsOf(side);
const have = new Set(target.map((r) => r.to));
target.push(...rows.filter((r) => !have.has(r.to)));
this.info = `${rows.length} mapping row(s) read from the payload. Review the CBS fields and adjust.`;
}
saveApi(): void {
if (!this.vendor || !this.editing) return;
this.savingApi = true;
this.error = null;
this.service.saveApi(this.vendor.id, this.editing).subscribe({
next: (a) => {
this.savingApi = false;
this.info = `${a.name} saved.`;
this.editing = null;
this.load(this.vendor!.id);
},
error: (e) => {
this.savingApi = false;
this.error = e?.error?.defaultUserMessage ?? 'Could not save the API.';
}
});
}
deleteApi(a: VendorApi): void {
if (!this.vendor || !confirm(`Delete the API "${a.name}"?`)) return;
this.service.deleteApi(this.vendor.id, a.id).subscribe(() => {
this.editing = null;
this.info = `${a.name} deleted.`;
this.load(this.vendor!.id);
});
}
/** The request this API would send (or receive), as the Test tab and the editor show it. */
preview(a: VendorApi | null): RequestPreview | null {
return this.vendor && a ? buildRequestPreview(this.vendor, a) : null;
}
// ── import ──
openImport(): void {
this.importOpen = true;
this.importError = null;
this.importReplace = this.isNewApi;
this.importRequestText = '';
this.importResponseText = '';
this.importRequestFile = '';
this.importResponseFile = '';
}
onFile(event: Event, which: 'request' | 'response'): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
this.importError = 'The file is larger than 5 MB.';
return;
}
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? '');
if (which === 'request') {
this.importRequestText = text;
this.importRequestFile = file.name;
} else {
this.importResponseText = text;
this.importResponseFile = file.name;
}
};
reader.readAsText(file);
input.value = '';
}
applyImport(): void {
if (!this.editing || !this.vendor) return;
this.importError = null;
let req: ImportedRequest | null = null;
let res: ImportedResponse | null = null;
try {
if (this.importRequestText.trim()) req = parseRequestText(this.importRequestText);
if (this.importResponseText.trim()) res = parseResponseText(this.importResponseText);
else if (req?.response) res = req.response;
} catch (e: any) {
this.importError = e?.message ?? 'Could not read the file.';
return;
}
if (!req && !res) {
this.importError = 'Paste or choose a request, a response, or both.';
return;
}
const a = this.editing;
const done: string[] = [];
const notes: string[] = [
...(req?.notes ?? []),
...(res?.notes ?? [])
];
const replace = this.importReplace;
const mergePairs = (target: KeyValue[], incoming: KeyValue[]): KeyValue[] => {
if (replace) return incoming;
const out = [...target];
for (const p of incoming) {
const i = out.findIndex((x) => x.key.toLowerCase() === p.key.toLowerCase());
if (i >= 0) out[i] = p;
else out.push(p);
}
return out;
};
if (req) {
if (req.method && this.methods.includes(req.method)) {
a.method = req.method as VendorApi['method'];
done.push(`method ${req.method}`);
}
if (req.path && req.path !== '/') {
a.path = this.stripVendorBasePath(req.path);
done.push(`path ${a.path}`);
}
if (req.baseUrl) {
if (!this.form.baseUrl) {
this.form.baseUrl = req.baseUrl;
notes.push(`Base URL ${req.baseUrl} was put on the Connection tab - save it there.`);
} else if (this.form.baseUrl.replace(/\/+$/, '') !== req.baseUrl) {
notes.push(
`The file's host (${req.baseUrl}) is not this vendor's base URL (${this.form.baseUrl}); only the path was used.`
);
}
}
if (req.queryParams.length) {
a.queryParams = mergePairs(a.queryParams, req.queryParams);
done.push(`${req.queryParams.length} query parameter(s)`);
}
if (req.headers.length) {
a.headers = mergePairs(a.headers, req.headers);
done.push(`${req.headers.length} header(s)`);
}
if (req.contentType) {
a.contentType = Object.keys(API_CONTENT_TYPES).includes(req.contentType) ? req.contentType : a.contentType;
done.push(`content type ${a.contentType}`);
}
if (req.body !== undefined) {
a.requestBody = req.body;
done.push('request payload');
}
if (!a.name.trim() && a.path) {
const last =
a.path
.split('/')
.filter((s) => s && !s.includes('{'))
.pop() ?? 'API';
a.name = last.charAt(0).toUpperCase() + last.slice(1).replace(/[-_]+/g, ' ');
}
this.pathChanged();
}
if (res) {
const failure = res.status !== undefined && (res.status < 200 || res.status > 299);
if (res.status !== undefined && !failure) {
a.successCodes = String(res.status);
done.push(`success code ${res.status}`);
}
if (res.headers.length) {
a.responseHeaders = mergePairs(a.responseHeaders, res.headers);
done.push(`${res.headers.length} response header(s)`);
}
if (res.body !== undefined) {
if (failure) {
a.errorBody = res.body;
done.push(`error body (HTTP ${res.status})`);
} else {
a.responseBody = res.body;
done.push('response body');
}
}
if (res.errorBody !== undefined) {
a.errorBody = res.errorBody;
done.push('error body');
}
}
if (this.importGenerate) {
const addRows = (side: 'request' | 'response', body: string) => {
const reading = !this.fromIsCbs(side);
const rows = reading ? guessResponseRows(body, this.vendor!.code) : guessRequestRows(body);
if (!rows.length) return;
const target = replace ? [] : this.rowsOf(side);
const have = new Set(target.map((r) => r.to));
const merged = [
...target,
...rows.filter((r) => !have.has(r.to))
];
if (side === 'request') a.requestRows = merged;
else a.responseRows = merged;
done.push(`${rows.length} ${side === 'request' ? 'request' : 'response'} mapping row(s)`);
};
if (req?.body !== undefined) addRows('request', a.requestBody);
if (res?.body !== undefined && !(res.status !== undefined && (res.status < 200 || res.status > 299)))
addRows('response', a.responseBody);
}
this.importOpen = false;
this.error = null;
this.info = `Read from ${[
req?.source,
res?.source
]
.filter(Boolean)
.join(' + ')}: ${done.join(', ') || 'nothing new'}. ${notes.join(' ')} Review each tab, then Save API.`;
}
/** A path read from a full URL may repeat the path already in the vendor's base URL. */
private stripVendorBasePath(path: string): string {
const base = String(this.form.baseUrl ?? '');
const m = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(\/.+)$/.exec(base.replace(/\/+$/, ''));
if (m && path.startsWith(m[1] + '/')) return path.slice(m[1].length);
return path;
}
// ── test ──
get testApi(): VendorApi | null {
return this.vendor?.apis.find((x) => x.id === this.testApiId) ?? null;
}
fillTestBody(): void {
this.testBody = this.testApi?.requestBody ?? '{}';
this.testResult = null;
}
get testPreview(): RequestPreview | null {
const a = this.testApi;
return this.vendor && a ? buildRequestPreview(this.vendor, { ...a, requestBody: this.testBody }) : null;
}
runTest(): void {
if (!this.vendor || !this.testApiId) return;
this.testing = true;
this.error = null;
this.service.testApi(this.vendor.id, this.testApiId, this.testBody).subscribe({
next: (log) => {
this.testing = false;
this.testResult = log;
this.loadCalls();
},
error: (e) => {
this.testing = false;
this.error = e?.error?.defaultUserMessage ?? 'Test failed.';
}
});
}
}
@@ -0,0 +1,172 @@
<!--
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/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>API Management</h1>
<p>
Connect any API service to the CBS the same way: <b>Vendor → Work → API set</b>. <b>IN</b> = data comes into the
CBS from the vendor (CIC alerts, delivery reports, replies). <b>OUT</b> = the CBS calls the vendor (send an SMS
or WhatsApp message).
</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/api-management/logs">API call logs</a>
<button class="cic-btn primary" (click)="startAdd()">+ Add vendor</button>
</div>
</div>
@if (error && !adding) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
<div class="cic-grid cols-4" style="margin-bottom: 16px">
<div class="cic-stat">
<div class="k">Vendors</div>
<div class="v">{{ vendors.length }}</div>
<div class="s">{{ activeVendors }} active</div>
</div>
<div class="cic-stat">
<div class="k">APIs</div>
<div class="v">{{ totalIn + totalOut }}</div>
<div class="s">{{ totalIn }} IN · {{ totalOut }} OUT</div>
</div>
<div class="cic-stat">
<div class="k">Calls in 24 hours</div>
<div class="v">{{ summary.callsToday }}</div>
<div class="s">both directions</div>
</div>
<div class="cic-stat">
<div class="k">Failed in 24 hours</div>
<div class="v" [class.tone-bad]="summary.failedToday > 0">{{ summary.failedToday }}</div>
<div class="s">see the call logs</div>
</div>
</div>
<div class="cic-card">
<h2>Vendors</h2>
@if (loading && !vendors.length) {
<div class="cic-empty">Loading…</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Vendor</th>
<th>Work</th>
<th>APIs</th>
<th>Environment</th>
<th>24 h calls</th>
<th>Last call</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
@for (v of vendors; track v.id) {
<tr>
<td>
<b>{{ v.name }}</b>
<div class="muted cic-mono" style="font-size: 11px">{{ v.code }}</div>
</td>
<td style="max-width: 340px; white-space: normal">{{ v.work }}</td>
<td>
@if (count(v, 'IN')) {
<span class="cic-badge brand">IN × {{ count(v, 'IN') }}</span>
}
@if (count(v, 'OUT')) {
<span class="cic-badge ok">OUT × {{ count(v, 'OUT') }}</span>
}
@if (!v.apis.length) {
<span class="muted">none yet</span>
}
</td>
<td>
<span
class="cic-badge"
[class.warn]="v.environment === 'SIMULATOR'"
[class.good]="v.environment === 'PRODUCTION'"
>{{ v.environment }}</span
>
</td>
<td>
{{ calls(v).calls }}
@if (calls(v).failed) {
<span class="tone-bad" style="font-size: 12px"> · {{ calls(v).failed }} failed</span>
}
</td>
<td>{{ v.lastCallOn ? (v.lastCallOn | slice: 0 : 16) : '—' }}</td>
<td>
<span class="cic-switch">
<input type="checkbox" [checked]="v.active" (change)="toggle(v, $event)" />
<span></span>
</span>
</td>
<td style="white-space: nowrap">
<a class="cic-btn sm primary" [routerLink]="['/api-management/vendors', v.id]">Set up</a>
</td>
</tr>
@if (v.links.length) {
<tr>
<td colspan="8" style="background: var(--cic-soft); padding: 6px 12px">
<span class="muted" style="font-size: 12px">{{ v.code }} also has:</span>
@for (l of v.links; track l.route) {
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
</div>
@if (adding) {
<div class="cic-modal-backdrop" (click)="adding = false">
<div class="cic-modal" (click)="$event.stopPropagation()">
<h3>Add an API vendor</h3>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
<div class="cic-grid cols-1" style="gap: 12px">
<div class="cic-field">
<label>Vendor name</label>
<input [(ngModel)]="form.name" placeholder="e.g. Aadhaar eKYC, Payment gateway, E-mail" />
</div>
<div class="cic-field">
<label>Work — what is this vendor used for?</label>
<textarea rows="3" [(ngModel)]="form.work" placeholder="e.g. Verify customer Aadhaar during KYC"></textarea>
</div>
<div class="cic-field">
<label>Environment</label>
<select [(ngModel)]="form.environment">
<option value="SIMULATOR">SIMULATOR — no network call</option>
<option value="UAT">UAT — vendor's test environment</option>
<option value="PRODUCTION">PRODUCTION — live</option>
</select>
</div>
</div>
<p class="muted" style="font-size: 12px; margin: 12px 0 0">
Next you set up the connection and add its APIs, each marked IN or OUT.
</p>
<div class="foot">
<button class="cic-btn" (click)="adding = false">Cancel</button>
<button class="cic-btn primary" (click)="saveNew()" [disabled]="saving">
{{ saving ? 'Adding…' : 'Add and set up' }}
</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,121 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { ApiManagementService, ApiVendor } from '../api-management.service';
/** Every outside API service the CBS uses: vendor, its work, its APIs (IN / OUT) and whether it is switched on. */
@Component({
selector: 'mifosx-api-vendors',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
],
templateUrl: './api-vendors.component.html',
styleUrls: ['../../credit-bureau/cic-shared.scss']
})
export class ApiVendorsComponent implements OnInit {
private service = inject(ApiManagementService);
private router = inject(Router);
vendors: ApiVendor[] = [];
summary: { callsToday: number; failedToday: number; perVendor: Record<number, { calls: number; failed: number }> } = {
callsToday: 0,
failedToday: 0,
perVendor: {}
};
loading = false;
error: string | null = null;
info: string | null = null;
adding = false;
saving = false;
form: { name: string; work: string; environment: ApiVendor['environment'] } = this.blankForm();
ngOnInit(): void {
this.load();
}
private blankForm() {
return { name: '', work: '', environment: 'SIMULATOR' as ApiVendor['environment'] };
}
load(): void {
this.loading = true;
this.service.vendors().subscribe((v) => {
this.vendors = v;
this.loading = false;
});
this.service.summary().subscribe((s) => (this.summary = s));
}
count(v: ApiVendor, direction: 'IN' | 'OUT'): number {
return v.apis.filter((a) => a.direction === direction).length;
}
get totalIn(): number {
return this.vendors.reduce((s, v) => s + this.count(v, 'IN'), 0);
}
get totalOut(): number {
return this.vendors.reduce((s, v) => s + this.count(v, 'OUT'), 0);
}
get activeVendors(): number {
return this.vendors.filter((v) => v.active).length;
}
calls(v: ApiVendor): { calls: number; failed: number } {
return this.summary.perVendor[v.id] ?? { calls: 0, failed: 0 };
}
toggle(v: ApiVendor, event: Event): void {
const want = (event.target as HTMLInputElement).checked;
(event.target as HTMLInputElement).checked = v.active;
this.error = null;
this.info = null;
this.service.saveVendor(v.id, { active: want }).subscribe({
next: (u) => {
v.active = u.active;
this.info = `${u.name} is now ${u.active ? 'active' : 'switched off'}.`;
},
error: (e) => (this.error = e?.error?.defaultUserMessage ?? 'Could not change the vendor.')
});
}
startAdd(): void {
this.adding = true;
this.form = this.blankForm();
this.error = null;
}
saveNew(): void {
this.saving = true;
this.error = null;
this.service.addVendor(this.form).subscribe({
next: (v) => {
this.saving = false;
this.adding = false;
this.router.navigate([
'/api-management/vendors',
v.id
]);
},
error: (e) => {
this.saving = false;
this.error = e?.error?.defaultUserMessage ?? 'Could not add the vendor.';
}
});
}
}
+6
View File
@@ -73,10 +73,13 @@ import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
import { CreditBureauModule } from './credit-bureau/credit-bureau.module';
import { ApiManagementModule } from './api-management/api-management.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
import { ElectionModule } from './election/election.module';
import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module';
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
import { PortalModule } from '@angular/cdk/portal';
@@ -189,10 +192,13 @@ export function HttpLoaderFactory(http: HttpClient) {
NpaModule,
ReconciliationModule,
LoanOriginationModule,
CreditBureauModule,
ApiManagementModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
ElectionModule,
LoanEligibilityModule,
SponsorDDModule,
SponsorDDReconciliationModule,
AppRoutingModule,
@@ -132,8 +132,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branchSelectOptions.length <= 1"
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branchSelectOptions; track b.id) {
@@ -1626,8 +1626,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branchSelectOptions.length <= 1"
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branchSelectOptions; track b.id) {
@@ -1880,6 +1880,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.loadBranches();
this.addRow();
this.loadBranchOptions();
this.loadDenominations();
this.loadOpeningPosition();
this.loadDayStatus();
@@ -2711,11 +2712,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
branchNameFor(branchId: number): string {
return (
this.seenBranches.find((b) => b.id === branchId)?.name ??
this.branches.find((b) => b.id === branchId)?.label ??
`Branch ${branchId}`
);
const seen = this.seenBranches.find((b) => b.id === branchId);
if (seen) return seen.name;
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
}
/**
@@ -4065,11 +4064,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (token !== this.searchToken) return; // stale result — newer search started
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
const allItems =
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
@@ -4099,18 +4095,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
this.loadingAccount = false;
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
const otherKind =
cfg.depositTypeId != null
? found.find(
(x: any) =>
x?.depositType?.id !== cfg.depositTypeId &&
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
)
: null;
this.accountError = otherKind
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
: `${acTyp} account not found for: ${acNo}`;
this.accountError = 'Account not found for: ' + acNo;
});
}
@@ -4174,10 +4159,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
* for a transaction; a Header can never receive a posting, so it should never be offered. */
extraListQueryParams?: string;
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
depositTypeId?: number;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -4258,7 +4239,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/fixeddepositaccounts',
depositTypeId: 200,
nominee: 'FD',
search: 'fixeddeposit',
siAccountType: 2,
@@ -4276,7 +4256,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/recurringdepositaccounts',
depositTypeId: 300,
nominee: 'RD',
search: 'recurringdeposit',
siAccountType: 2,
@@ -4294,7 +4273,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/savingsaccounts',
depositTypeId: 100,
nominee: 'SB',
search: 'saving',
siAccountType: 2,
@@ -4982,11 +4960,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return Number(mine) === Number(accountOffice);
}
/** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */
/** The bank's branches, for each row's BRANCH selector. Empty until /offices answers. */
branchOptions: { id: number; name: string }[] = [];
/** What the BRANCH selector offers: the real branches, or the old fixed list while they have not loaded. */
get branchSelectOptions(): { id: number; name: string }[] {
const merged = this.branches.map(
(branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label }
);
const base = this.branchOptions.length ? this.branchOptions : TransactionEntryComponent.LEGACY_BRANCHES;
const merged = base.map((branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? branch);
for (const seen of this.seenBranches) {
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
}
@@ -4996,6 +4976,27 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
private seenBranches: { id: number; name: string }[] = [];
private static readonly LEGACY_BRANCHES = [
{ id: 1, name: '001 · Main' },
{ id: 2, name: '002 · M.G. Road' },
{ id: 14, name: '014 · Station Rd' },
{ id: 27, name: '027 · Industrial' }
];
/**
* The bank's branches for the BRANCH selector. Listing offices needs the READ offices permission, which a cashier or
* clerk may not hold, so a refusal must not raise an error popup: the selector then shows the branches this screen has
* learned from the accounts on it (and the old fixed list until then).
*/
private loadBranchOptions(): void {
this.http
.get<any[]>('/offices', { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
.pipe(catchError(() => of([])))
.subscribe((offices) => {
this.branchOptions = (offices ?? []).map((o: any) => ({ id: o.id, name: o.name }));
});
}
/**
* The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
@@ -356,6 +356,15 @@
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
{{ 'labels.buttons.New Current Account' | translate }}
</button>
<!-- A Cash Credit account is a current account on a product that allows an overdraft against a sanctioned limit. -->
<button
mat-menu-item
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
[routerLink]="['current-accounts', 'create']"
[queryParams]="{ cc: 1 }"
>
New CC Account
</button>
</mat-menu>
<mat-menu #Actions="matMenu">
+9 -59
View File
@@ -846,6 +846,11 @@ export class ClientsService {
return this.http.post('/kyc/risk-rules', rule);
}
/** Ends the trial of a newly added rule; it stays in the catalog as a normal rule. */
markClientRiskRuleNormal(ruleId: number): Observable<any> {
return this.http.post(`/kyc/risk-rules/${ruleId}/mark-normal`, {});
}
/** Fetch code values by code ID */
getCodeValuesById(codeId: number): Observable<any[]> {
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
@@ -1141,8 +1146,8 @@ export class ClientsService {
notes?: string;
officerName?: string;
sendAt?: string;
}): Observable<{ queued: number; skipped: number; unreachable: number }> {
return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body);
}): Observable<{ queued: number; skipped: number }> {
return this.http.post<{ queued: number; skipped: number }>('/kyc/notice-queue', body);
}
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
@@ -1156,63 +1161,9 @@ export class ClientsService {
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
}
/** What each of these customers has been sent so far: the latest notice, how many, and which channels are done. */
getKycContactSummary(clientIds: number[]): Observable<
{
clientId: number;
count: number;
lastAt: string;
lastChannel: string;
lastStatus: string;
doneChannels: string[];
balance?: number;
hasMobile?: boolean;
hasEmail?: boolean;
mobileMasked?: string | null;
emailMasked?: string | null;
}[]
> {
return this.http.get<
{
clientId: number;
count: number;
lastAt: string;
lastChannel: string;
lastStatus: string;
doneChannels: string[];
hasMobile?: boolean;
hasEmail?: boolean;
mobileMasked?: string | null;
emailMasked?: string | null;
}[]
>('/kyc/contact-audit/clients', {
params: new HttpParams().set('ids', clientIds.join(',')),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/** Notices per customer (counts, outcomes, the latest), most recently contacted first, a page at a time. */
getKycContactAuditCustomers(
filter: {
from?: string;
to?: string;
channel?: string;
status?: string;
search?: string;
real?: boolean;
offset?: number;
limit?: number;
} = {}
): Observable<{ total: number; rows: any[] }> {
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit/customers', {
params: this.auditParams(filter),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/** Notices per day (customers, notices, outcomes), newest day first. */
getKycContactAuditDays(
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {}
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string } = {}
): Observable<any[]> {
return this.http.get<any[]>('/kyc/contact-audit/days', {
params: this.auditParams(filter),
@@ -1228,7 +1179,6 @@ export class ClientsService {
channel?: string;
status?: string;
search?: string;
real?: boolean;
offset?: number;
limit?: number;
} = {}
@@ -1239,7 +1189,7 @@ export class ClientsService {
});
}
private auditParams(filter: Record<string, string | number | boolean | undefined>): HttpParams {
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
let params = new HttpParams();
Object.entries(filter).forEach(
([
+12 -19
View File
@@ -59,10 +59,7 @@ export class CacheInterceptor implements HttpInterceptor {
'/notes',
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
'/almreturn',
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
'/approvals/counts'
'/almreturn'
];
private isCacheable(url: string): boolean {
@@ -96,22 +93,18 @@ export class CacheInterceptor implements HttpInterceptor {
// Create new response to avoid side-effects
subscriber.next(new HttpResponse(cachedData as Object));
subscriber.complete();
return undefined;
} else {
next.handle(request).subscribe(
(event) => {
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event);
}
subscriber.next(event);
},
(error) => subscriber.error(error),
() => subscriber.complete()
);
}
const inner = next.handle(request).subscribe(
(event) => {
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event);
}
subscriber.next(event);
},
(error) => subscriber.error(error),
() => subscriber.complete()
);
// The request is cancelled when its caller walks away (a page is left, a screen is closed). The inner request has to
// be cancelled with it: left to run, it kept its connection open. A browser holds about six per site, so a few
// streams that were let go but never closed filled them and every later request waited in the queue.
return () => inner.unsubscribe();
});
}
}
@@ -1,16 +0,0 @@
/**
* 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 { HttpContextToken } from '@angular/common/http';
/**
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
* otherwise keep the bar running or make it flash on a screen that has not changed.
*/
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
@@ -16,7 +16,6 @@ import { finalize } from 'rxjs/operators';
/** Custom Services */
import { ProgressBarService } from './progress-bar.service';
import { NO_PROGRESS_BAR } from './no-progress.token';
/**
* Http Request interceptor to start/stop loading the progress bar.
@@ -30,9 +29,6 @@ export class ProgressInterceptor implements HttpInterceptor {
* and stop when a response or error is received.
*/
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.context.get(NO_PROGRESS_BAR)) {
return next.handle(request);
}
this.progressBarService.increase();
return next.handle(request).pipe(
finalize(() => {
+1 -17
View File
@@ -9,7 +9,6 @@
/** Angular Imports */
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
import { Injectable } from '@angular/core';
import { ShellComponent } from '../shell/shell.component';
/**
* A route strategy allowing for explicit route reuse.
@@ -32,22 +31,7 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
return null;
}
/**
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
*/
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return (
future.routeConfig === curr.routeConfig ||
future.data.reuse ||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
);
}
private static isShell(route: ActivatedRouteSnapshot): boolean {
return route.routeConfig?.component === ShellComponent;
return future.routeConfig === curr.routeConfig || future.data.reuse;
}
}
+22 -5
View File
@@ -407,6 +407,15 @@
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
routerLink="/credit-bureau/dashboard"
routerLinkActive="active"
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
>
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Credit Bureau
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -465,11 +474,6 @@
*mifosxHasPermission="'MENU_TASKS'"
>
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
@if (approvalTotal > 0) {
<span class="ni-badge" title="Requests waiting for approval">{{
approvalTotal > 99 ? '99+' : approvalTotal
}}</span>
}
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
@@ -513,6 +517,16 @@
><span class="ni-short">Loans</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
routerLink="/credit-bureau/dashboard"
routerLinkActive="active"
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
>
<fa-icon icon="id-card" class="ni-ico"></fa-icon><span class="ni-full">Credit Bureau</span
><span class="ni-short">CIC</span>
</a>
<a
class="ni"
tabindex="0"
@@ -1232,6 +1246,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/cc']">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> CC
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
</button>
-15
View File
@@ -1082,18 +1082,3 @@ $gray-700: #334155;
width: $bar;
}
}
// How many requests are waiting under Approvals.
.ni-badge {
min-width: 16px;
height: 16px;
margin-left: 4px;
padding: 0 5px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
+2 -18
View File
@@ -17,7 +17,6 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
import { map, filter } from 'rxjs/operators';
/** Custom Services */
@@ -69,11 +68,6 @@ export class ShellComponent implements OnInit, OnDestroy {
private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService);
private tenantMenuAccessService = inject(TenantMenuAccessService);
private approvalCounts = inject(ApprovalCountsService);
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
approvalTotal = 0;
private approvalTotalSub?: Subscription;
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
@@ -81,9 +75,8 @@ export class ShellComponent implements OnInit, OnDestroy {
.pipe(map((result) => result.matches));
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
* 'none', so an empty start left it running until some request happened to finish. */
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
/** Progress bar mode. */
progressBarMode: string;
/** Subscription to progress bar. */
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */
@@ -151,13 +144,6 @@ export class ShellComponent implements OnInit, OnDestroy {
* Subscribes to progress bar to update its mode.
*/
ngOnInit() {
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
this.approvalTotal = total;
this.cdr.markForCheck();
});
this.approvalCounts.start();
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
this.progressBarMode = mode;
this.cdr.detectChanges();
@@ -401,8 +387,6 @@ export class ShellComponent implements OnInit, OnDestroy {
* Unsubscribes from progress bar.
*/
ngOnDestroy() {
this.approvalTotalSub?.unsubscribe();
this.approvalCounts.stop();
if (this.progressBar$) {
this.progressBar$.unsubscribe();
}
@@ -154,7 +154,7 @@
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
<span>Lending &amp; Risk</span>
</div>
<button mat-menu-item class="ai-card" tabindex="0">
<button mat-menu-item class="ai-card" routerLink="/ai/loan-eligibility" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="hand-holding-usd"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">Loan Eligibility</span>
@@ -239,6 +239,27 @@
</span>
</button>
</div>
<div class="ai-col ai-col--blue">
<div class="ai-col-h">
<fa-icon icon="sitemap" class="ai-col-h-ic"></fa-icon>
<span>API Management</span>
</div>
<button mat-menu-item class="ai-card" routerLink="/api-management/vendors" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="cogs"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">API Vendors &amp; Setup</span>
<span class="ai-card-d">Vendor, work and API set — IN / OUT (CIC bureaus, SMS, WhatsApp …)</span>
</span>
</button>
<button mat-menu-item class="ai-card" routerLink="/api-management/logs" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="history"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">API Call Logs</span>
<span class="ai-card-d">Every IN and OUT call with request and response</span>
</span>
</button>
</div>
</div>
<div class="ai-menu-footer">
@@ -373,7 +373,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
// ===== AI Mega Menu Panel =====
::ng-deep .ai-menu {
&.mat-mdc-menu-panel {
min-width: 880px !important;
min-width: 1080px !important;
max-width: 96vw !important;
max-height: 80vh;
border-radius: 14px !important;
@@ -520,7 +520,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
// ===== Mega Layout — 4 colour-coded columns =====
.ai-menu-cols {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 0;
padding: 14px 14px 6px;
background: #f8fafc;
@@ -567,6 +567,12 @@ $icon-hover-border: rgb(255 255 255 / 28%);
--c-hover: #be185d;
}
.ai-col--blue {
--c-color: #0369a1;
--c-bg: #e0f2fe;
--c-hover: #0369a1;
}
.ai-col-h {
display: flex;
align-items: center;
@@ -702,7 +708,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
}
// Responsive collapse
@media (width <= 1100px) {
@media (width <= 1300px) {
::ng-deep .ai-menu.mat-mdc-menu-panel {
min-width: 680px !important;
}
@@ -0,0 +1,252 @@
<!--
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/.
-->
<div class="cic-modal-backdrop" (click)="closed.emit()">
<div class="cic-modal" style="width: min(980px, 100%)" (click)="$event.stopPropagation()">
@if (loading && !alert) {
<div class="cic-empty">Loading…</div>
}
@if (alert) {
@if (history.length) {
<div class="cic-alert info" style="margin-bottom: 12px">
You are viewing another enquiry of this customer.
<button class="cic-btn sm" style="margin-left: 8px" (click)="back()">← Back to previous enquiry</button>
</div>
}
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
<div>
<h3 style="margin-bottom: 4px">
{{ alert.clientName || alert.customerName || 'Unknown customer' }} applied for
{{ alert.loanType || 'a loan' }} elsewhere
</h3>
<div class="muted" style="font-size: 12px">
{{ alert.bureauCode }} alert · {{ alert.alertKey }} · received {{ alert.receivedOn | slice: 0 : 16 }} via
{{ alert.receivedVia }}
</div>
</div>
<div class="cic-actions">
<span class="cic-badge" [class]="'cic-badge ' + statusTone(alert.status)">{{ alert.status }}</span>
<button class="cic-btn sm" (click)="closed.emit()">Close</button>
</div>
</div>
@if (error) {
<div class="cic-alert error" style="margin-top: 12px">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success" style="margin-top: 12px">{{ info }}</div>
}
<div class="cic-grid cols-3" style="margin-top: 14px">
<div class="cic-stat">
<div class="k">Loan applied for</div>
<div class="v" style="font-size: 17px">{{ alert.loanType || '—' }}</div>
</div>
<div class="cic-stat">
<div class="k">Amount</div>
<div class="v" style="font-size: 20px">
{{ alert.amount !== null && alert.amount !== undefined ? '₹' + (alert.amount | number: '1.0-0') : '—' }}
</div>
</div>
<div class="cic-stat">
<div class="k">Enquiry date</div>
<div class="v" style="font-size: 17px">{{ alert.enquiryDate | date: 'dd MMM yyyy' }}</div>
</div>
</div>
<div class="cic-split" style="margin-top: 14px">
<div class="cic-card" style="margin-bottom: 0">
<h2>
Enquiry elsewhere
<span class="cic-card-sub">the CIC does not name the lender</span>
</h2>
<dl class="cic-kv" style="grid-template-columns: 140px minmax(0, 1fr)">
<dt>Loan type</dt>
<dd>{{ alert.loanType || '—' }}</dd>
<dt>Amount</dt>
<dd>
{{ alert.amount !== null && alert.amount !== undefined ? '₹' + (alert.amount | number: '1.0-2') : '—' }}
</dd>
<dt>Purpose</dt>
<dd>{{ alert.purpose || '—' }}</dd>
<dt>Place</dt>
<dd>{{ alert.location || '—' }}</dd>
<dt>Enquiry date</dt>
<dd>{{ alert.enquiryDate | date: 'dd MMM yyyy' }}</dd>
</dl>
</div>
<div class="cic-card" style="margin-bottom: 0">
<h2>
Our customer
@if (!alert.clientId) {
<button
class="cic-btn sm primary"
(click)="linkCustomer()"
[disabled]="saving"
*mifosxHasPermission="'UPDATE_CICALERT'"
>
Link to customer
</button>
}
</h2>
<dl class="cic-kv" style="grid-template-columns: 140px minmax(0, 1fr)">
<dt>Customer</dt>
<dd>
@if (alert.clientId) {
<a [routerLink]="['/clients', alert.clientId, 'general']" (click)="closed.emit()">{{
alert.clientName
}}</a>
<span class="muted"> · {{ alert.clientAccountNo }}</span>
} @else {
<span class="tone-bad">Not matched to any customer</span>
}
</dd>
<dt>Branch</dt>
<dd>{{ alert.officeName || '—' }}</dd>
<dt>Matched by</dt>
<dd>{{ matchLabels[alert.matchType] || alert.matchType }}</dd>
<dt>Name at bureau</dt>
<dd>{{ alert.customerName || '—' }}</dd>
<dt>PAN / Aadhaar</dt>
<dd class="cic-mono">{{ alert.panMasked || '—' }} / {{ alert.aadhaarMasked || '—' }}</dd>
@if (alert.customerRef) {
<dt>Reference</dt>
<dd class="cic-mono">{{ alert.customerRef }}</dd>
}
</dl>
</div>
</div>
@if (alert.clientId) {
<div class="cic-card" style="margin-top: 14px; margin-bottom: 0">
<h2>
Customer's loans with us
<span class="cic-card-sub">outstanding ₹{{ exposure | number: '1.0-0' }}</span>
</h2>
@if (alert.clientLoans?.length) {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Account</th>
<th>Product</th>
<th>Status</th>
<th class="num">Principal</th>
<th class="num">Outstanding</th>
<th>Disbursed</th>
</tr>
</thead>
<tbody>
@for (l of alert.clientLoans; track l.id) {
<tr>
<td class="cic-mono">{{ l.accountNo }}</td>
<td>{{ l.productName }}</td>
<td>{{ l.status }}</td>
<td class="num">{{ l.principal | number: '1.0-0' }}</td>
<td class="num">{{ l.outstanding | number: '1.0-0' }}</td>
<td>{{ l.disbursedOn | date: 'dd MMM yyyy' }}</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="muted" style="font-size: 13px">
No loan with us. A customer with deposits only who is borrowing elsewhere may be a cross-sell opportunity.
</div>
}
</div>
}
@if (alert.otherAlertsOfCustomer?.length) {
<div class="cic-card" style="margin-top: 14px; margin-bottom: 0">
<h2>
All enquiries on this customer
<span class="cic-card-sub">{{ customerEnquiries.length }} in total</span>
</h2>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Date</th>
<th>Loan type</th>
<th class="num">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (o of customerEnquiries; track o.id) {
<tr
class="clickable"
[style.background]="o.id === alert.id ? '#eef4ff' : null"
(click)="o.id !== alert.id && openOther(o.id)"
>
<td>{{ o.enquiryDate | date: 'dd MMM yyyy' }}</td>
<td>
{{ o.loanType }}
@if (o.id === alert.id) {
<b>(shown above)</b>
}
</td>
<td class="num">{{ o.amount | number: '1.0-0' }}</td>
<td>
<span class="cic-badge" [class]="'cic-badge ' + statusTone(o.status)">{{ o.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (alert.remarks || alert.actionedByName) {
<div class="cic-alert info" style="margin-top: 14px">
<b>{{ alert.status }}</b> by {{ alert.actionedByName }} on {{ alert.actionedOn | slice: 0 : 16 }}
@if (alert.remarks) {
— {{ alert.remarks }}
}
</div>
}
<ng-container *mifosxHasPermission="'UPDATE_CICALERT'">
@if (action) {
<div class="cic-field" style="margin-top: 14px">
<label>{{ action === 'ACTIONED' ? 'What was done' : 'Reason for dismissing' }}</label>
<textarea rows="3" [(ngModel)]="remarks" maxlength="1000"></textarea>
</div>
}
<div class="foot">
@if (action) {
<button class="cic-btn" (click)="action = null">Cancel</button>
<button class="cic-btn primary" (click)="confirmAction()" [disabled]="saving">
{{ action === 'ACTIONED' ? 'Save as actioned' : 'Dismiss alert' }}
</button>
} @else {
@if (alert.status === 'ACTIONED' || alert.status === 'DISMISSED') {
<button class="cic-btn" (click)="setStatus('SEEN', null)" [disabled]="saving">Reopen</button>
}
@if (alert.status !== 'DISMISSED') {
<button class="cic-btn danger" (click)="action = 'DISMISSED'">Dismiss</button>
}
@if (alert.status !== 'ACTIONED') {
<button class="cic-btn primary" (click)="action = 'ACTIONED'">Mark actioned</button>
}
}
</div>
</ng-container>
<div style="margin-top: 12px">
<button class="cic-btn sm" (click)="showRaw = !showRaw">{{ showRaw ? 'Hide' : 'Show' }} bureau data</button>
@if (showRaw) {
<pre class="cic-code" style="margin-top: 8px">{{ rawText() }}</pre>
}
</div>
}
</div>
</div>
@@ -0,0 +1,181 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { CIC_MATCH_LABELS, CicAlert, CicService, cicError, cicStatusTone } from '../cic.service';
/**
* One enquiry alert: which of our customers applied elsewhere (the CIC does not name the lender), for what loan and
* how much; the customer's
* loans with us and earlier alerts; and the follow-up (seen / actioned / dismissed, or linking an unmatched alert).
*/
@Component({
selector: 'mifosx-cic-alert-detail',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatDialogModule,
HasPermissionDirective
],
templateUrl: './cic-alert-detail.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicAlertDetailComponent implements OnChanges {
private cic = inject(CicService);
private dialog = inject(MatDialog);
@Input() alertId: number | null = null;
@Output() closed = new EventEmitter<void>();
@Output() changed = new EventEmitter<CicAlert>();
alert: CicAlert | null = null;
loading = false;
saving = false;
error: string | null = null;
info: string | null = null;
showRaw = false;
/** Alerts visited before the current one, so the user can go back after opening another enquiry of the customer. */
history: number[] = [];
private autoSeen = true;
action: 'ACTIONED' | 'DISMISSED' | null = null;
remarks = '';
readonly matchLabels = CIC_MATCH_LABELS;
readonly statusTone = cicStatusTone;
ngOnChanges(): void {
this.history = [];
this.autoSeen = true;
if (this.alertId) this.load();
}
openOther(id: number): void {
if (this.alertId) this.history.push(this.alertId);
this.alertId = id;
// Only the alert opened from the dashboard counts as "seen"; browsing the customer's other enquiries changes nothing.
this.autoSeen = false;
this.load();
}
back(): void {
const id = this.history.pop();
if (!id) return;
this.alertId = id;
this.autoSeen = false;
this.load();
}
load(): void {
if (!this.alertId) return;
this.loading = true;
this.error = null;
this.info = null;
this.action = null;
this.remarks = '';
this.cic.alert(this.alertId).subscribe({
next: (a) => {
this.alert = a;
this.loading = false;
setTimeout(() => document.querySelector('.cic-modal')?.scrollTo({ top: 0 }));
// Opening a new alert is seeing it.
if (this.autoSeen && a.status === 'NEW' && a.clientId) this.setStatus('SEEN', null, true);
},
error: (e) => {
this.loading = false;
this.error = cicError(e, 'Could not load the alert.');
}
});
}
setStatus(status: string, remarks: string | null, quiet = false): void {
if (!this.alert) return;
this.saving = true;
this.error = null;
this.cic.updateStatus(this.alert.id, status, remarks).subscribe({
next: (a) => {
this.saving = false;
this.alert = a;
this.action = null;
this.remarks = '';
if (!quiet) this.info = `Alert marked ${status.toLowerCase()}.`;
this.changed.emit(a);
},
error: (e) => {
this.saving = false;
// A user without UPDATE_CICALERT can still read; the automatic "seen" is then simply skipped.
if (!quiet) this.error = cicError(e, 'Could not update the alert.');
}
});
}
confirmAction(): void {
if (!this.action) return;
if (!this.remarks.trim()) {
this.error =
this.action === 'ACTIONED'
? 'Write what was done (customer contacted, retention offer made, limit reviewed ...).'
: 'Write why the alert is dismissed.';
return;
}
this.setStatus(this.action, this.remarks.trim());
}
linkCustomer(): void {
if (!this.alert) return;
this.dialog
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((r: CustomerSearchResult | undefined) => {
if (!r?.clientId || !this.alert) return;
this.saving = true;
this.cic.linkClient(this.alert.id, r.clientId).subscribe({
next: (a) => {
this.saving = false;
this.alert = a;
this.info = `Alert linked to ${a.clientName}.`;
this.changed.emit(a);
},
error: (e) => {
this.saving = false;
this.error = cicError(e, 'Could not link the customer.');
}
});
});
}
/** All of the customer's enquiries, this one included, in a fixed order so the list does not reshuffle when one is opened. */
get customerEnquiries(): CicAlert[] {
if (!this.alert) return [];
return [
this.alert,
...(this.alert.otherAlertsOfCustomer ?? [])
].sort((x, y) => (y.enquiryDate ?? '').localeCompare(x.enquiryDate ?? '') || y.id - x.id);
}
get exposure(): number {
return (this.alert?.clientLoans ?? []).reduce((s, l) => s + Number(l.outstanding ?? 0), 0);
}
rawText(): string {
return JSON.stringify(this.alert?.raw ?? {}, null, 2);
}
}
@@ -0,0 +1,262 @@
<!--
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/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>Credit Bureau — Customer Enquiry Alerts</h1>
<p>
Our customers who applied for a loan at another bank or NBFC, as reported by the CIC (CIBIL, Experian, Equifax,
CRIF): what loan and how much. The CIC does not tell which bank or lender it was.
</p>
</div>
<div class="cic-actions">
<select [(ngModel)]="days" (ngModelChange)="load()" class="cic-btn" style="padding-right: 8px">
<option [ngValue]="7">Last 7 days</option>
<option [ngValue]="30">Last 30 days</option>
<option [ngValue]="90">Last 90 days</option>
<option [ngValue]="180">Last 180 days</option>
</select>
<a class="cic-btn" routerLink="/credit-bureau/fetch-log">Fetch log</a>
@if (whitelisted.length) {
<button
class="cic-btn primary"
(click)="fetchNow('ALL')"
[disabled]="fetching !== null"
*mifosxHasPermission="'FETCH_CICALERT'"
>
{{ fetching === 'ALL' ? 'Fetching…' : 'Fetch alerts now' }}
</button>
}
</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
@if (data && !whitelisted.length) {
<div class="cic-alert warn">
No CIC is whitelisted yet, so no alerts are being received. Switch a bureau on (CIBIL, Experian, Equifax or CRIF)
under AI / Intelligence Modules → API Management → API Vendors &amp; Setup.
</div>
}
@if (loading && !data) {
<div class="cic-card cic-empty">Loading…</div>
}
@if (data) {
<div class="cic-grid cols-4" style="margin-bottom: 16px">
<div class="cic-stat">
<div class="k">New alerts</div>
<div class="v" [class.tone-bad]="data.newAlerts > 0">{{ data.newAlerts }}</div>
<div class="s">not yet looked at</div>
</div>
<div class="cic-stat">
<div class="k">Customers enquired elsewhere</div>
<div class="v">{{ data.customersInPeriod }}</div>
<div class="s">{{ data.alertsInPeriod }} enquiries · last {{ days }} days</div>
</div>
<div class="cic-stat">
<div class="k">Amount applied elsewhere</div>
<div class="v">₹{{ data.amountInPeriod | number: '1.0-0' }}</div>
<div class="s">in total · last {{ days }} days</div>
</div>
<div class="cic-stat">
<div class="k">Unmatched alerts</div>
<div class="v" [class.tone-warn]="data.unmatchedOpen > 0">{{ data.unmatchedOpen }}</div>
<div class="s">bureau sent, customer not found · {{ data.receivedToday }} received today</div>
</div>
</div>
<!-- Recent -->
<div class="cic-card">
<h2>Latest enquiries on our customers</h2>
@if (data.recent?.length) {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Enquiry date</th>
<th>Our customer</th>
<th>Branch</th>
<th>Loan type</th>
<th class="num">Amount (₹)</th>
<th>Purpose</th>
<th>Bureau</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (a of data.recent; track a.id) {
<tr class="clickable" (click)="openAlertId = a.id">
<td>{{ a.enquiryDate | date: 'dd MMM yyyy' }}</td>
<td>
<b>{{ a.clientName }}</b>
<div class="muted" style="font-size: 11px">{{ a.clientAccountNo }}</div>
</td>
<td>{{ a.officeName }}</td>
<td>{{ a.loanType }}</td>
<td class="num">{{ a.amount | number: '1.0-0' }}</td>
<td>{{ a.purpose }}</td>
<td>{{ a.bureauCode }}</td>
<td>
<span class="cic-badge" [class]="'cic-badge ' + statusTone(a.status)">{{ a.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="cic-empty">
No enquiry alerts yet. They appear here as soon as a CIC reports one of our customers.
</div>
}
</div>
<div class="cic-split">
<!-- Repeat customers -->
<div class="cic-card">
<h2>
Customers applying elsewhere repeatedly
<span class="cic-card-sub">2+ enquiries · last {{ days }} days</span>
</h2>
@if (data.repeatCustomers?.length) {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Customer</th>
<th class="num">Enquiries</th>
<th class="num">Total applied (₹)</th>
<th>Last</th>
</tr>
</thead>
<tbody>
@for (c of data.repeatCustomers; track c.clientId) {
<tr>
<td>
<b>{{ c.clientName }}</b>
<div class="muted" style="font-size: 11px">{{ c.accountNo }}</div>
</td>
<td class="num tone-bad">
<b>{{ c.alerts }}</b>
</td>
<td class="num">{{ c.amount | number: '1.0-0' }}</td>
<td>{{ c.lastEnquiryDate | date: 'dd MMM' }}</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="cic-empty">No customer has more than one enquiry in this period.</div>
}
</div>
<!-- Trend -->
<div class="cic-card">
<h2>Enquiries per day <span class="cic-card-sub">last 14 days</span></h2>
<div class="cic-trend">
@for (p of data.trend14Days; track p.date) {
<div
class="col"
[title]="(p.date | date: 'dd MMM') + ': ' + p.count + ' enquiries, ₹' + (p.amount | number: '1.0-0')"
>
<span class="n">{{ p.count || '' }}</span>
<div class="bar" [style.height.%]="pct(p.count, data.trend14Days, 'count')"></div>
<span class="d">{{ p.date | date: 'dd' }}</span>
</div>
}
</div>
</div>
</div>
<div>
<div class="cic-card" style="margin-bottom: 0">
<h2>By loan type</h2>
@for (g of data.byLoanType; track g.label) {
<div style="margin-bottom: 10px">
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px">
<span>{{ g.label }}</span>
<span class="muted">{{ g.count }} · ₹{{ g.amount | number: '1.0-0' }}</span>
</div>
<div class="cic-bar"><div [style.width.%]="pct(g.count, data.byLoanType, 'count')"></div></div>
</div>
} @empty {
<div class="cic-empty">No data.</div>
}
</div>
</div>
<!-- Bureau fetch status -->
<div class="cic-card" style="margin-top: 16px">
<h2>
Alert feed from each CIC
<a class="cic-btn sm" routerLink="/credit-bureau/fetch-log">Fetch log</a>
</h2>
<div class="cic-grid cols-4">
@for (b of data.bureaus; track b.id) {
<div class="cic-stat">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 6px">
<b>{{ b.code }}</b>
<span class="cic-badge" [class.good]="b.whitelisted">{{ b.whitelisted ? 'Whitelisted' : 'Off' }}</span>
</div>
<div class="s" style="margin-top: 6px">
{{ b.environment }} · {{ b.autoFetch ? 'auto every ' + b.intervalMin + ' min' : 'manual fetch only' }}
</div>
<div class="s">
Last fetch:
@if (b.lastFetchOn) {
<span class="cic-badge" [class]="'cic-badge ' + fetchTone(b.lastFetchStatus)">{{
b.lastFetchStatus
}}</span>
{{ b.lastFetchOn | slice: 0 : 16 }}
} @else {
never
}
</div>
@if (b.lastFetchMessage) {
<div class="s" style="font-size: 11px">{{ b.lastFetchMessage }}</div>
}
<div class="s" style="font-size: 11px; margin-top: 4px">
@if (b.environment === 'SIMULATOR') {
Simulator: no call goes to {{ b.code }}; the alerts are made up by the CBS for testing.
} @else {
Calls {{ b.code }} {{ b.environment }} with the API set up under API Management.
}
</div>
@if (b.whitelisted) {
<button
class="cic-btn sm"
style="margin-top: 8px"
(click)="fetchNow(b.id)"
[disabled]="fetching !== null"
*mifosxHasPermission="'FETCH_CICALERT'"
>
{{ fetching === b.id ? 'Fetching…' : 'Fetch now' }}
</button>
}
<a class="cic-btn sm" style="margin-top: 8px; margin-left: 4px" [routerLink]="setupRoute(b.code)">Set up</a>
</div>
}
</div>
</div>
}
</div>
@if (openAlertId) {
<mifosx-cic-alert-detail
[alertId]="openAlertId"
(closed)="openAlertId = null"
(changed)="onChanged()"
></mifosx-cic-alert-detail>
}
@@ -0,0 +1,130 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { ApiManagementService } from 'app/api-management/api-management.service';
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
import { CicAlertDetailComponent } from '../cic-alert-detail/cic-alert-detail.component';
import { CicService, cicError, cicStatusTone } from '../cic.service';
/**
* Credit Bureau dashboard: our customers who applied for a loan at another bank / NBFC, as reported by the CICs -
* who, what loan and how much (the CIC does not name the lender) - with the customers applying elsewhere repeatedly,
* the loan types, the trend, and the fetch status of each bureau. Each bureau is set up under API Management → API
* Vendors & Setup.
*/
@Component({
selector: 'mifosx-cic-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
HasPermissionDirective,
CicAlertDetailComponent
],
templateUrl: './cic-dashboard.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicDashboardComponent implements OnInit {
private cic = inject(CicService);
private apis = inject(ApiManagementService);
/** Each bureau is set up as a vendor under API Management (connection, whitelist, request / response mapping). */
setupRoute(code: string): any[] {
const v = this.apis.vendorByCode(code);
return v ? [
'/api-management/vendors',
v.id
] : ['/api-management/vendors'];
}
days = 30;
loading = false;
error: string | null = null;
info: string | null = null;
data: any = null;
fetching: number | 'ALL' | null = null;
openAlertId: number | null = null;
readonly statusTone = cicStatusTone;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = null;
this.cic.dashboard(this.days).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
},
error: (e) => {
this.error = cicError(e, 'Could not load the credit bureau dashboard.');
this.loading = false;
}
});
}
get whitelisted(): any[] {
return (this.data?.bureaus ?? []).filter((b: any) => b.whitelisted);
}
/** Fetch now from one bureau, or from every whitelisted bureau in turn. */
fetchNow(bureauId: number | 'ALL'): void {
const ids = bureauId === 'ALL' ? this.whitelisted.map((b) => b.id as number) : [bureauId];
if (!ids.length) return;
this.fetching = bureauId;
this.error = null;
this.info = null;
const messages: string[] = [];
const next = (i: number) => {
if (i >= ids.length) {
this.fetching = null;
this.info = messages.join(' · ');
this.load();
return;
}
this.cic.fetch(ids[i]).subscribe({
next: (run) => {
messages.push(
`${run.bureauCode}: ${run.status === 'SUCCESS' ? run.newAlerts + ' new alert(s)' : 'failed - ' + run.message}`
);
next(i + 1);
},
error: (e) => {
messages.push(cicError(e, 'Fetch failed.'));
next(i + 1);
}
});
};
next(0);
}
max(list: any[] | undefined, key: string): number {
return Math.max(1, ...(list ?? []).map((x) => Number(x[key] ?? 0)));
}
pct(value: any, list: any[] | undefined, key: string): number {
return Math.round((Number(value ?? 0) / this.max(list, key)) * 100);
}
onChanged(): void {
this.load();
}
fetchTone(status?: string): string {
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : '';
}
}
@@ -0,0 +1,126 @@
<!--
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/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>Fetch Log</h1>
<p>Every time the CBS asked a CIC for enquiry alerts: when, how many came, how many were our customers.</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/credit-bureau/dashboard">← Dashboard</a>
</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
<div class="cic-card">
<h2>
Fetches
<span class="cic-actions">
<select [(ngModel)]="bureauId" (ngModelChange)="filterChanged()" class="cic-btn sm">
<option value="">All bureaus</option>
@for (b of bureaus; track b.id) {
<option [value]="b.id">{{ b.code }}</option>
}
</select>
<button class="cic-btn sm" (click)="load()">Refresh</button>
</span>
</h2>
@if (loading && !runs.length) {
<div class="cic-empty">Loading…</div>
} @else if (!runs.length) {
<div class="cic-empty">No fetch has run yet.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Run</th>
<th>Bureau</th>
<th>How</th>
<th>Window</th>
<th>Status</th>
<th class="num">Received</th>
<th class="num">New</th>
<th class="num">Matched</th>
<th class="num">Unmatched</th>
<th class="num">Already had</th>
<th class="num">Own bank</th>
<th>By</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (r of runs; track r.id) {
<tr class="clickable" (click)="view(r)">
<td class="cic-mono">{{ r.runRef }}</td>
<td>{{ r.bureauCode }}</td>
<td>{{ r.trigger }}</td>
<td>{{ r.fromDate | date: 'dd MMM' }} – {{ r.toDate | date: 'dd MMM yy' }}</td>
<td>
<span class="cic-badge" [class]="'cic-badge ' + tone(r.status)">{{ r.status }}</span>
</td>
<td class="num">{{ r.received }}</td>
<td class="num">
<b>{{ r.newAlerts }}</b>
</td>
<td class="num">{{ r.matched }}</td>
<td class="num">{{ r.unmatched }}</td>
<td class="num">{{ r.duplicates }}</td>
<td class="num">{{ r.ownBank }}</td>
<td>{{ r.triggeredByName }}</td>
<td>{{ r.startedOn | slice: 0 : 16 }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="cic-pager">
<span>{{ offset + 1 }}–{{ offset + runs.length }} of {{ total }}</span>
<span class="cic-actions">
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
</span>
</div>
}
</div>
</div>
@if (viewing) {
<div class="cic-modal-backdrop" (click)="viewing = null">
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
<h3>{{ viewing.runRef }} · {{ viewing.bureauCode }} · {{ viewing.status }}</h3>
<div
class="cic-alert"
[class.success]="viewing.status === 'SUCCESS'"
[class.error]="viewing.status !== 'SUCCESS'"
>
{{ viewing.message }}
@if (viewing.httpStatus) {
(HTTP {{ viewing.httpStatus }}, {{ viewing.durationMs }} ms)
}
</div>
<div class="cic-split">
<div>
<h4 style="margin: 0 0 6px; font-size: 13px">Request</h4>
<pre class="cic-code">{{ pretty(viewing.request) || '—' }}</pre>
</div>
<div>
<h4 style="margin: 0 0 6px; font-size: 13px">Response</h4>
<pre class="cic-code">{{ pretty(viewing.response) || '—' }}</pre>
</div>
</div>
<div class="foot">
<button class="cic-btn" (click)="viewing = null">Close</button>
</div>
</div>
</div>
}
@@ -0,0 +1,91 @@
/**
* 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 { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { CicBureau, CicFetchRun, CicService, cicError } from '../cic.service';
/** Log of every alert fetch from the CICs: when, how many alerts, how many matched, and the request / response. */
@Component({
selector: 'mifosx-cic-fetch-log',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
],
templateUrl: './cic-fetch-log.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicFetchLogComponent implements OnInit {
private cic = inject(CicService);
bureaus: CicBureau[] = [];
runs: CicFetchRun[] = [];
total = 0;
offset = 0;
readonly limit = 20;
bureauId = '';
viewing: CicFetchRun | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
this.cic.bureaus().subscribe({ next: (b) => (this.bureaus = b), error: () => (this.bureaus = []) });
this.load();
}
load(): void {
this.loading = true;
this.error = null;
this.cic.fetchRuns({ bureauId: this.bureauId, offset: this.offset, limit: this.limit }).subscribe({
next: (p) => {
this.runs = p.pageItems;
this.total = p.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = cicError(e, 'Could not load the fetch log.');
this.loading = false;
}
});
}
filterChanged(): void {
this.offset = 0;
this.load();
}
page(delta: number): void {
this.offset = Math.max(0, this.offset + delta * this.limit);
this.load();
}
view(r: CicFetchRun): void {
this.cic.fetchRun(r.id).subscribe({
next: (full) => (this.viewing = full),
error: (e) => (this.error = cicError(e))
});
}
pretty(text?: string | null): string {
if (!text) return '';
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
tone(status: string): string {
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : 'warn';
}
}
+627
View File
@@ -0,0 +1,627 @@
/**
* 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/.
*/
/* Shared look for the Credit Bureau (CIC) screens. */
:host {
--cic-ink: #0f172a;
--cic-muted: #64748b;
--cic-line: #e2e8f0;
--cic-soft: #f8fafc;
--cic-brand: #1d4ed8;
--cic-brand-soft: #eff6ff;
--cic-good: #047857;
--cic-good-soft: #ecfdf5;
--cic-ok: #0369a1;
--cic-ok-soft: #f0f9ff;
--cic-warn: #b45309;
--cic-warn-soft: #fffbeb;
--cic-bad: #b91c1c;
--cic-bad-soft: #fef2f2;
display: block;
color: var(--cic-ink);
}
.cic-page {
padding: 20px 24px 40px;
max-width: 1440px;
margin: 0 auto;
}
.cic-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 4px 0 0;
color: var(--cic-muted);
font-size: 13px;
}
}
.cic-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.cic-card {
background: #fff;
border: 1px solid var(--cic-line);
border-radius: 12px;
padding: 18px 20px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
> h2,
.cic-card-title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.cic-card-sub {
color: var(--cic-muted);
font-size: 12px;
font-weight: 500;
}
}
.cic-grid {
display: grid;
gap: 14px;
&.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (width <= 960px) {
&.cols-3,
&.cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 640px) {
&.cols-2,
&.cols-3,
&.cols-4 {
grid-template-columns: 1fr;
}
}
}
.cic-split {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 16px;
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
.cic-stat {
background: #fff;
border: 1px solid var(--cic-line);
border-radius: 12px;
padding: 14px 16px;
.k {
font-size: 12px;
color: var(--cic-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.v {
font-size: 24px;
font-weight: 700;
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.s {
font-size: 12px;
color: var(--cic-muted);
margin-top: 2px;
}
}
.cic-field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
label {
font-size: 12px;
font-weight: 600;
color: #334155;
}
.hint {
font-size: 11px;
color: var(--cic-muted);
}
input,
select,
textarea {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: var(--cic-ink);
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--cic-brand);
box-shadow: 0 0 0 3px rgb(29 78 216 / 12%);
}
&:disabled {
background: var(--cic-soft);
color: var(--cic-muted);
}
}
textarea {
resize: vertical;
min-height: 90px;
}
}
.cic-mono,
textarea.cic-mono {
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace !important;
font-size: 12px !important;
}
.cic-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--cic-line);
background: #fff;
color: var(--cic-ink);
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
&:hover:not(:disabled) {
background: var(--cic-soft);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&.primary {
background: var(--cic-brand);
border-color: var(--cic-brand);
color: #fff;
&:hover:not(:disabled) {
background: #1e40af;
}
}
&.danger {
color: var(--cic-bad);
border-color: #fecaca;
&:hover:not(:disabled) {
background: var(--cic-bad-soft);
}
}
&.sm {
padding: 5px 10px;
font-size: 12px;
}
}
.cic-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
background: var(--cic-soft);
color: var(--cic-muted);
border: 1px solid var(--cic-line);
white-space: nowrap;
&.good {
background: var(--cic-good-soft);
color: var(--cic-good);
border-color: #a7f3d0;
}
&.ok {
background: var(--cic-ok-soft);
color: var(--cic-ok);
border-color: #bae6fd;
}
&.warn {
background: var(--cic-warn-soft);
color: var(--cic-warn);
border-color: #fde68a;
}
&.bad {
background: var(--cic-bad-soft);
color: var(--cic-bad);
border-color: #fecaca;
}
&.brand {
background: var(--cic-brand-soft);
color: var(--cic-brand);
border-color: #bfdbfe;
}
}
.cic-alert {
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
margin-bottom: 14px;
border: 1px solid;
&.error {
background: var(--cic-bad-soft);
border-color: #fecaca;
color: var(--cic-bad);
}
&.success {
background: var(--cic-good-soft);
border-color: #a7f3d0;
color: var(--cic-good);
}
&.info {
background: var(--cic-brand-soft);
border-color: #bfdbfe;
color: #1e3a8a;
}
&.warn {
background: var(--cic-warn-soft);
border-color: #fde68a;
color: var(--cic-warn);
}
ul {
margin: 6px 0 0;
padding-left: 18px;
}
}
.cic-table-wrap {
overflow-x: auto;
border: 1px solid var(--cic-line);
border-radius: 10px;
}
.cic-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--cic-muted);
font-weight: 700;
background: var(--cic-soft);
padding: 9px 12px;
border-bottom: 1px solid var(--cic-line);
white-space: nowrap;
}
td {
padding: 9px 12px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
tr.clickable {
cursor: pointer;
&:hover td {
background: #f8fafc;
}
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
td input,
td select {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 5px 7px;
font-size: 12px;
box-sizing: border-box;
background: #fff;
}
}
.cic-empty {
text-align: center;
color: var(--cic-muted);
padding: 28px 12px;
font-size: 13px;
}
.cic-pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
font-size: 12px;
color: var(--cic-muted);
}
.cic-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex: none;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background: #cbd5e1;
border-radius: 999px;
transition: background 0.15s;
cursor: pointer;
&::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
top: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.15s;
box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
}
}
input:checked + span {
background: var(--cic-good);
}
input:checked + span::before {
transform: translateX(18px);
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
.cic-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--cic-line);
margin-bottom: 14px;
flex-wrap: wrap;
button {
border: none;
background: none;
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
color: var(--cic-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
&.active {
color: var(--cic-brand);
border-bottom-color: var(--cic-brand);
}
}
}
.cic-kv {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 7px 12px;
font-size: 13px;
dt {
color: var(--cic-muted);
}
dd {
margin: 0;
font-weight: 600;
word-break: break-word;
}
}
.cic-code {
background: #0f172a;
color: #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
max-height: 420px;
overflow: auto;
margin: 0;
}
.cic-trend {
display: flex;
align-items: flex-end;
gap: 6px;
height: 180px;
padding-top: 6px;
.col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
min-width: 0;
}
.bar {
width: 100%;
max-width: 28px;
min-height: 2px;
background: var(--cic-brand);
border-radius: 4px 4px 0 0;
}
.n {
font-size: 10px;
color: var(--cic-muted);
margin-bottom: 2px;
}
.d {
font-size: 10px;
color: var(--cic-muted);
margin-top: 4px;
}
}
.cic-modal-backdrop {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 45%);
z-index: 1000;
display: grid;
place-items: center;
padding: 16px;
}
.cic-modal {
background: #fff;
border-radius: 14px;
width: min(640px, 100%);
max-height: 90vh;
overflow: auto;
padding: 20px 22px;
box-shadow: 0 20px 50px rgb(15 23 42 / 25%);
h3 {
margin: 0 0 12px;
font-size: 16px;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
}
.cic-bar {
height: 8px;
border-radius: 999px;
background: #f1f5f9;
overflow: hidden;
> div {
height: 100%;
background: var(--cic-brand);
border-radius: 999px;
}
}
.muted {
color: var(--cic-muted);
}
.tone-good {
color: var(--cic-good);
}
.tone-ok {
color: var(--cic-ok);
}
.tone-warn {
color: var(--cic-warn);
}
.tone-bad {
color: var(--cic-bad);
}
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
/**
* 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 { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
/** A credit bureau (CIC) as configured for the bank. Never carries the secret itself. */
export interface CicBureau {
id: number;
code: string;
name: string;
description?: string;
whitelisted: boolean;
isDefault: boolean;
environment: 'SIMULATOR' | 'UAT' | 'PRODUCTION';
baseUrl?: string;
memberCode?: string;
authType: string;
username?: string;
apiKeyHeader?: string;
hasSecret: boolean;
tokenUrl?: string;
clientId?: string;
timeoutMs: number;
alertAutoFetch: boolean;
alertFetchIntervalMin: number;
alertLookbackDays: number;
lastFetchOn?: string;
lastFetchStatus?: string;
lastFetchMessage?: string;
lastTestStatus?: string;
lastTestMessage?: string;
lastTestedOn?: string;
updatedOn?: string;
activeEndpoints: number;
alertsLast30Days: number;
newAlerts: number;
}
export interface CicMapping {
id?: number;
sourceField: string | null;
targetField: string;
transform: string;
defaultValue: string | null;
mandatory: boolean;
sortOrder?: number;
}
export interface CicEndpoint {
bureauId: number;
endpointType: string;
exists: boolean;
id?: number;
httpMethod: string;
path?: string;
contentType: string;
headersJson: string;
baseBody: string;
isActive: boolean;
requestMappings: CicMapping[];
responseMappings: CicMapping[];
}
export interface CicCatalog {
environments: string[];
authTypes: string[];
endpointTypes: string[];
httpMethods: string[];
cbsFields: Record<string, string>;
resultFields: Record<string, string>;
rootFields: string[];
transforms: Record<string, string>;
alertStatuses: string[];
matchTypes: string[];
}
/** One enquiry made on our customer at another lender, as reported by the CIC. */
export interface CicAlert {
id: number;
bureauId: number;
bureauCode: string;
alertKey: string;
clientId?: number;
clientName?: string;
clientAccountNo?: string;
officeId?: number;
officeName?: string;
matchType: 'PAN' | 'AADHAAR' | 'CUSTOMER_REF' | 'MANUAL' | 'NONE';
customerName?: string;
panMasked?: string;
aadhaarMasked?: string;
customerRef?: string;
enquiryDate?: string;
loanType?: string;
amount?: number;
purpose?: string;
location?: string;
status: 'NEW' | 'SEEN' | 'ACTIONED' | 'DISMISSED';
remarks?: string;
actionedByName?: string;
actionedOn?: string;
receivedVia?: string;
receivedOn?: string;
raw?: any;
fetchRunId?: number;
otherAlertsOfCustomer?: CicAlert[];
clientLoans?: {
id: number;
accountNo: string;
productName: string;
status: string;
principal: number;
outstanding: number;
disbursedOn?: string;
}[];
}
export interface CicFetchRun {
id: number;
runRef: string;
bureauId: number;
bureauCode: string;
trigger: 'MANUAL' | 'SCHEDULED';
environment?: string;
fromDate?: string;
toDate?: string;
status: 'RUNNING' | 'SUCCESS' | 'FAILED';
httpStatus?: number;
received: number;
newAlerts: number;
matched: number;
unmatched: number;
duplicates: number;
ownBank: number;
message?: string;
durationMs?: number;
triggeredByName?: string;
startedOn: string;
request?: string;
response?: string;
}
export interface CicPage<T> {
totalFilteredRecords: number;
pageItems: T[];
}
export const CIC_MATCH_LABELS: Record<string, string> = {
PAN: 'PAN',
AADHAAR: 'Aadhaar',
CUSTOMER_REF: 'Customer ref',
MANUAL: 'Linked manually',
NONE: 'Not matched'
};
export function cicStatusTone(status: string): string {
return status === 'NEW' ? 'bad' : status === 'SEEN' ? 'warn' : status === 'ACTIONED' ? 'good' : '';
}
/** The server's own message for a failed call. */
export function cicError(err: any, fallback = 'Something went wrong. Please try again.'): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.message ??
err?.message ??
fallback
);
}
@Injectable({ providedIn: 'root' })
export class CicService {
private http = inject(HttpClient);
private base = '/cic';
/** Errors are shown inline on these screens, so the global alert is suppressed. */
private silent = { context: new HttpContext().set(SILENT_HTTP_ERROR, true) };
catalog(): Observable<CicCatalog> {
return this.http.get<CicCatalog>(`${this.base}/catalog`, this.silent);
}
bureaus(whitelistedOnly = false): Observable<CicBureau[]> {
return this.http.get<CicBureau[]>(`${this.base}/bureaus`, {
...this.silent,
params: new HttpParams().set('whitelistedOnly', String(whitelistedOnly))
});
}
updateBureau(id: number, body: any): Observable<CicBureau> {
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}`, body, this.silent);
}
setWhitelisted(id: number, whitelisted: boolean): Observable<CicBureau> {
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}/whitelist`, { whitelisted }, this.silent);
}
testConnection(id: number): Observable<{ status: string; message: string }> {
return this.http.post<{ status: string; message: string }>(
`${this.base}/bureaus/${id}/test-connection`,
{},
this.silent
);
}
endpoint(bureauId: number, type: string): Observable<CicEndpoint> {
return this.http.get<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, this.silent);
}
saveEndpoint(bureauId: number, type: string, body: any): Observable<CicEndpoint> {
return this.http.put<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, body, this.silent);
}
preview(bureauId: number, body: { fromDate?: string; toDate?: string }): Observable<any> {
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/preview`, body, this.silent);
}
parseTest(bureauId: number, content: string | null): Observable<any> {
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/parse-test`, { content }, this.silent);
}
fetch(bureauId: number, body: { fromDate?: string; toDate?: string } = {}): Observable<CicFetchRun> {
return this.http.post<CicFetchRun>(`${this.base}/bureaus/${bureauId}/fetch`, body, this.silent);
}
fetchRuns(filter: Record<string, any>): Observable<CicPage<CicFetchRun>> {
return this.http.get<CicPage<CicFetchRun>>(`${this.base}/fetch-runs`, {
...this.silent,
params: this.params(filter)
});
}
fetchRun(id: number): Observable<CicFetchRun> {
return this.http.get<CicFetchRun>(`${this.base}/fetch-runs/${id}`, this.silent);
}
dashboard(days = 30): Observable<any> {
return this.http.get<any>(`${this.base}/dashboard`, {
...this.silent,
params: new HttpParams().set('days', String(days))
});
}
alert(id: number): Observable<CicAlert> {
return this.http.get<CicAlert>(`${this.base}/alerts/${id}`, this.silent);
}
updateStatus(id: number, status: string, remarks?: string | null): Observable<CicAlert> {
return this.http.put<CicAlert>(`${this.base}/alerts/${id}/status`, { status, remarks }, this.silent);
}
linkClient(id: number, clientId: number): Observable<CicAlert> {
return this.http.put<CicAlert>(`${this.base}/alerts/${id}/client`, { clientId }, this.silent);
}
private params(filter: Record<string, any>): HttpParams {
let params = new HttpParams();
Object.entries(filter).forEach(
([
k,
v
]) => {
if (v !== null && v !== undefined && v !== '') params = params.set(k, String(v));
}
);
return params;
}
}
@@ -0,0 +1,49 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
const routes: Routes = [
Route.withShell([
{
path: 'credit-bureau',
data: { title: 'Credit Bureau', breadcrumb: 'Credit Bureau' },
children: [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
component: CicDashboardComponent,
data: { title: 'Credit Bureau Dashboard', breadcrumb: 'Dashboard' }
},
{
path: 'fetch-log',
component: CicFetchLogComponent,
data: { title: 'Fetch Log', breadcrumb: 'Fetch Log' }
},
// The bureaus are set up, whitelisted and mapped under API Management like every other vendor.
{ path: 'bureaus', redirectTo: '/api-management/vendors', pathMatch: 'full' },
{ path: 'api-mapping', redirectTo: '/api-management/vendors', pathMatch: 'full' }
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CreditBureauRoutingModule {}
@@ -0,0 +1,36 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { CreditBureauRoutingModule } from './credit-bureau-routing.module';
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
import { CicAlertDetailComponent } from './cic-alert-detail/cic-alert-detail.component';
import { CicService } from './cic.service';
import { CicStaticService } from './cic-static.service';
/**
* CIC / credit bureau (CIBIL, Experian, Equifax, CRIF) - its own top-level menu: enquiry alerts on our customers from
* other lenders and the fetch log. Each bureau's connection, whitelist and API mapping live under API Management.
*/
@NgModule({
imports: [
SharedModule,
CreditBureauRoutingModule,
CicDashboardComponent,
CicFetchLogComponent,
CicAlertDetailComponent
],
providers: [
// Static demo data, no backend call. Remove this line to use the real /v1/cic API again.
{ provide: CicService, useClass: CicStaticService }
]
})
export class CreditBureauModule {}
@@ -13,6 +13,18 @@
<i class="fas fa-info-circle"></i>
<h3>Account Details</h3>
</div>
@if (isCcRequest) {
<p class="sad-cc-note">
@if (ccProductsMissing) {
<strong>No CC product yet.</strong> A Cash Credit account is opened on a current product that allows an
overdraft against a sanctioned limit. Create or edit a current product with “Is Overdraft Allowed” switched
on, then come back here.
} @else {
Opening a <strong>Cash Credit (CC)</strong> account: only products that allow an overdraft are listed. The
sanctioned limit comes from the product.
}
</p>
}
<div class="sad-grid">
<mat-form-field appearance="outline" class="sad-field">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
@@ -1735,3 +1735,16 @@ $ni-bg: #f9fafb;
margin-top: 2px;
}
}
/* Shown when the screen was opened from "New CC Account". */
.sad-cc-note {
margin: 0 0 14px;
padding: 10px 14px;
border: 1px solid #bfdbfe;
border-left: 4px solid #1d4ed8;
border-radius: 8px;
background: #eff6ff;
color: #1e3a8a;
font-size: 13px;
line-height: 1.5;
}
@@ -31,6 +31,8 @@ import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add
import { AlertService } from 'app/core/alert/alert.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ActivatedRoute } from '@angular/router';
import { ProductsService } from 'app/products/products.service';
/**
* Current Account Details Step
@@ -58,6 +60,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
private alertService = inject(AlertService);
private dateUtils = inject(Dates);
private cdr = inject(ChangeDetectorRef);
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
/** Opened from "New CC Account": only products that allow an overdraft are offered. */
isCcRequest = false;
/** A CC account was asked for but no current product allows an overdraft yet. */
ccProductsMissing = false;
@Input() currentAccountTemplate: any;
@Input() hideButtons = false;
@@ -216,8 +225,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
});
}
this.isCcRequest = this.route.snapshot.queryParamMap.get('cc') === '1';
if (this.currentAccountTemplate) {
this.productData = this.currentAccountTemplate.productOptions;
if (this.isCcRequest && !this.currentAccountTemplate.accountNo) {
this.limitProductsToCc();
}
const existingProductId =
this.currentAccountTemplate.currentAccountProductId ?? this.currentAccountTemplate.productId;
// Edit mode: account response always has accountNo; pure template has previewAccountNo only
@@ -255,6 +269,24 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
}
}
/**
* A Cash Credit account takes its overdraft and limit from the product it is opened on, so a CC account is opened on a
* product that allows an overdraft. The template lists every current product without saying which allow one, so the
* product list is read to tell them apart.
*/
private limitProductsToCc(): void {
this.productsService
.getCurrentProducts()
.pipe(catchError(() => of([] as any[])))
.subscribe((res: any) => {
const products: any[] = Array.isArray(res) ? res : (res?.pageItems ?? []);
const overdraftIds = new Set(products.filter((p) => p.allowOverdraft).map((p) => p.id));
this.productData = (this.productData || []).filter((p: any) => overdraftIds.has(p.id));
this.ccProductsMissing = this.productData.length === 0;
this.cdr.detectChanges();
});
}
createCurrentAccountDetailsForm() {
this.currentAccountDetailsForm = this.formBuilder.group({
productId: [
@@ -331,14 +331,12 @@
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">{{
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
}}</span>
<span class="fdrc-maturity-row__label">Interest Earned</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
</div>
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
<span class="fdrc-maturity-row__label">Monthly Payout</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
</span>
@@ -353,64 +351,13 @@
}
</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
</span>
</div>
@if (isSpecial && specialPreview) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Maturity Date</span>
<span class="fdrc-maturity-row__value">{{
specialPreview.maturityDate | date: 'dd MMM yyyy'
}}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly payouts</span>
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
<span class="fdrc-maturity-row__value"
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
>
</div>
}
</div>
</div>
@if (isSpecial && specialPreview) {
<div class="fdrc-schedule">
<div class="fdrc-schedule__head">
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
<button
mat-stroked-button
type="button"
class="fdrc-schedule__toggle"
(click)="openSpecialSchedule()"
>
View payout schedule ({{ specialPreview.numberOfPayouts }})
</button>
</div>
<div class="fdrc-schedule__note">
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
product.
</div>
</div>
}
}
@if (specialPreviewLoading) {
<div class="fdrc-payout-hint">
<i class="fas fa-spinner fa-spin"></i>
Working out the maturity from the product…
</div>
}
@if (specialPreviewError) {
<div class="fdrc-payout-hint fdrc-payout-hint--error">
<i class="fas fa-exclamation-triangle"></i>
{{ specialPreviewError }}
</div>
}
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
@if (!matchResult.maturityAmount) {
<div class="fdrc-payout-hint">
<i class="fas fa-lightbulb"></i>
Enter a deposit amount above to see the maturity calculation.
@@ -1229,72 +1229,3 @@ $red-light: #fee2e2;
font-weight: 700;
}
}
/* FD Special: payout schedule from the backend preview */
.fdrc-schedule {
margin-top: 12px;
border: 1px solid #bbf7d0;
border-radius: 10px;
background: #fff;
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 14px;
background: #f0fdf4;
font-size: 12px;
font-weight: 700;
color: #166534;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__note {
padding: 6px 14px;
font-size: 12px;
color: #475569;
}
&__wrap {
max-height: 360px;
overflow: auto;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 6px 14px;
border-top: 1px solid #e2e8f0;
text-align: left;
}
th {
position: sticky;
top: 0;
background: #f8fafc;
font-size: 11px;
text-transform: uppercase;
color: #64748b;
}
.num {
text-align: right;
}
tfoot td {
font-weight: 700;
background: #f0fdf4;
}
}
}
.fdrc-payout-hint--error {
color: #b91c1c;
}
@@ -36,39 +36,12 @@ import { FormsModule } from '@angular/forms';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { MatDialog } from '@angular/material/dialog';
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
/**
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
*/
function localIsoDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
export interface SpecialPayoutPreview {
fdType: string;
depositAmount: number;
annualInterestRate: number;
seniorCitizen: boolean;
startDate: string;
maturityDate: string;
numberOfPayouts: number;
fullMonthPayout: number;
totalInterest: number;
maturityAmount: number;
totalValueAtMaturity: number;
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
}
export interface SlabMatchResult {
slab: any;
slabIndex: number;
@@ -114,7 +87,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private cdr = inject(ChangeDetectorRef);
private dialog = inject(MatDialog);
@Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any;
@@ -191,10 +163,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
}
matchResult: SlabMatchResult | null = null;
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
specialPreview: SpecialPayoutPreview | null = null;
specialPreviewLoading = false;
specialPreviewError: string | null = null;
validationWarning: string | null = null;
// ── FD Type labels ──────────────────────────────────────────────────────
@@ -302,8 +270,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
calculateRate() {
this.validationWarning = null;
this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
if (!this.tenureValue || this.tenureValue <= 0) {
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
@@ -368,6 +334,30 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else {
// Sent as days, whatever the count. Converting to whole months silently dropped the
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
// screen still said "200 Days" above the figure. The backend takes days directly and
// works the calendar out itself.
tenureInDays = enteredDays;
}
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
const compMap: Record<number, number> = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 };
const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4;
const compMonths = compMap[compId] ?? 1;
// Map posting period type id → months
const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4;
const postMonths = compMap[postId] ?? 1;
const emitResult = () => {
if (frequencyId !== null) {
this.tenureSelected.emit({
@@ -385,7 +375,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
// Get submittedOnDate — Input takes priority over template data
let submittedOnDateStr: string;
if (this.submittedOnDate) {
submittedOnDateStr = localIsoDate(this.submittedOnDate);
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
} else {
const submittedOnDateRaw =
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
@@ -394,18 +384,63 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const d = Array.isArray(submittedOnDateRaw)
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
: new Date(submittedOnDateRaw);
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
submittedOnDateStr = !isNaN(d.getTime())
? d.toISOString().split('T')[0]
: new Date().toISOString().split('T')[0];
} else {
submittedOnDateStr = localIsoDate(new Date());
submittedOnDateStr = new Date().toISOString().split('T')[0];
}
}
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
// schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
if (frequencyId !== null) {
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
}
const daysInYearVal =
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
// Always use backend API for accurate calculation (including daily compounding with 0)
this.fixedDepositsService
.calculateFixedDepositInterest({
principalAmount: this.principalAmount,
annualInterestRate: rate,
tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr,
daysInYear: daysInYearVal
})
.subscribe({
next: (apiResult: any) => {
result.maturityAmount = apiResult.maturityAmount;
result.interestEarned =
apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
emitResult();
},
error: () => {
// Fallback if API fails: frontend formula
const r = rate / 100;
if (compMonths === 0) {
// Daily compounding fallback
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else {
const n = 12 / compMonths;
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const rPerCompounding = rate / (n * 100);
// Whole compounding periods, then simple interest on the days left over. Flooring the
// periods and stopping there dropped the remainder entirely: a 200-day deposit
// compounding monthly quoted only six months of interest and lost the other twenty
// days, which is the depositor's money.
const wholePeriods = Math.floor(effectiveMonths / compMonths);
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
const leftoverInterest =
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
}
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
emitResult();
}
});
} else {
this.matchResult = result;
if (frequencyId !== null) {
@@ -422,96 +457,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
}
}
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
openSpecialSchedule(): void {
if (!this.specialPreview) return;
this.dialog.open(InterestScheduleDialogComponent, {
width: '760px',
data: {
preview: true,
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
note:
'Worked out by the account engine from the product, starting ' +
this.specialPreview.startDate +
' (the day the account is opened) and ending the day before maturity on ' +
this.specialPreview.maturityDate +
'. The first and last periods are part-months; each pays for the days it covers.',
schedule: this.specialPreview.periods.map((p) => ({
fromDate: p.fromDate,
toDate: p.toDate,
days: p.days,
interestAmount: Number(p.interestAmount),
paid: false
}))
}
});
}
/**
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
* amount and interest shown are the backend's, so they match the account once it is created.
*/
private loadAccountPreview(
result: SlabMatchResult,
rate: number,
submittedOnDateStr: string,
frequencyId: number,
emitResult: () => void
): void {
const pt = this.fixedDepositsAccountProductTemplate ?? {};
const body: any = {
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
submittedOnDate: submittedOnDateStr,
dateFormat: 'yyyy-MM-dd',
locale: 'en',
depositAmount: this.principalAmount,
depositPeriod: this.tenureValue,
depositPeriodFrequencyId: frequencyId
};
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
if (pt.interestCompoundingPeriodType?.id != null)
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
if (pt.interestCalculationDaysInYearType?.id != null)
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
if (this.isSeniorCitizen && rate > 0) {
body.isSeniorCitizen = true;
body.seniorCitizenInterestRate = rate;
body.nominalAnnualInterestRate = rate;
}
this.specialPreview = null;
this.specialPreviewError = null;
this.specialPreviewLoading = true;
this.fixedDepositsService.accountPreview(body).subscribe({
next: (preview: SpecialPayoutPreview) => {
this.specialPreviewLoading = false;
this.specialPreview = preview;
result.appliedRate = Number(preview.annualInterestRate);
result.interestEarned = Number(preview.totalInterest);
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
result.maturityAmount = Number(preview.maturityAmount);
this.matchResult = { ...result };
emitResult();
this.cdr.markForCheck();
},
error: (e: any) => {
this.specialPreviewLoading = false;
this.specialPreviewError =
e?.error?.errors?.[0]?.defaultUserMessage ||
e?.error?.defaultUserMessage ||
'Could not work out the maturity. Check the deposit details and try again.';
result.maturityAmount = undefined;
result.interestEarned = undefined;
this.matchResult = { ...result };
this.cdr.markForCheck();
}
});
}
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
const unitLower = unit.toLowerCase();
@@ -526,16 +471,12 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
this.tenureValue = null;
this.principalAmount = null;
this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
this.validationWarning = null;
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
}
onPeriodUnitChange() {
this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
this.validationWarning = null;
}
@@ -583,8 +524,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
this.calculateRate();
} else {
this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
}
}
@@ -326,7 +326,7 @@
<div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Inactive</span>
<span class="preview-dorm-step__days">
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
} @else {
Not configured
@@ -340,7 +340,7 @@
<div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Dormant</span>
<span class="preview-dorm-step__days">
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
} @else {
Not configured
@@ -354,7 +354,7 @@
<div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Escheat</span>
<span class="preview-dorm-step__days">
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
} @else {
Not configured
@@ -458,15 +458,13 @@
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
</div>
<div class="preview-field">
<span class="preview-label">{{
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
}}</span>
<span class="preview-label">Estimated Interest Earned</span>
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
</span>
</div>
<div class="preview-field" style="grid-column: span 2">
<span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
<span class="preview-label">Projected Maturity Amount</span>
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
</span>
@@ -145,11 +145,6 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
SPECIAL: 'FD Special (Monthly Interest Payout)'
};
/** FD Special pays its interest out monthly; the principal alone comes back at maturity. */
get isSpecial(): boolean {
return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL';
}
getFdTypeLabel(fdType: string): string {
return this.fdTypeLabels[fdType] || fdType;
}
@@ -11,25 +11,15 @@
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
<span class="isd-header__text">
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
<span class="isd-header__sub">
@if (isPreview) {
{{ schedule.length }} monthly payouts
} @else {
{{ paidCount }} of {{ schedule.length }} periods paid so far
}
</span>
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
</span>
</h2>
<div mat-dialog-content class="isd-content">
<p class="isd-note">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
@if (data.note) {
{{ data.note }}
} @else {
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can
vary slightly with the number of days in that month.
}
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary
slightly with the number of days in that month.
</p>
<div class="isd-scroll">
@@ -38,13 +28,8 @@
<tr>
<th>{{ 'labels.inputs.From Date' | translate }}</th>
<th>{{ 'labels.inputs.To Date' | translate }}</th>
@if (isPreview) {
<th class="isd-table__amount-col">Days</th>
}
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
@if (!isPreview) {
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
}
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
</tr>
</thead>
<tbody>
@@ -52,35 +37,28 @@
<tr [class.isd-table__row--paid]="period.paid">
<td>{{ period.fromDate | dateFormat }}</td>
<td>{{ period.toDate | dateFormat }}</td>
@if (isPreview) {
<td class="isd-table__amount-col">{{ period.days }}</td>
}
<td class="isd-table__amount-col">
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</td>
@if (!isPreview) {
<td class="isd-table__status-col">
@if (period.paid) {
<span class="isd-badge isd-badge--paid">
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
</span>
} @else {
<span class="isd-badge isd-badge--pending">Pending</span>
}
</td>
}
<td class="isd-table__status-col">
@if (period.paid) {
<span class="isd-badge isd-badge--paid">
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
</span>
} @else {
<span class="isd-badge isd-badge--pending">Pending</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="isPreview ? 3 : 2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
<td class="isd-table__amount-col">
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</td>
@if (!isPreview) {
<td></td>
}
<td></td>
</tr>
</tfoot>
</table>
@@ -26,9 +26,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
* the engine projects it to be.
*
* Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special
* not opened yet, so there is no paid / pending status, and each period shows its number of days.
*/
@Component({
selector: 'mifosx-interest-schedule-dialog',
@@ -49,22 +46,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class InterestScheduleDialogComponent {
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
data: {
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
currencyCode: string;
/** Account-opening quote: no paid / pending status. */
preview?: boolean;
/** Shown above the table instead of the account note. */
note?: string;
} = inject(MAT_DIALOG_DATA);
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
return this.data.schedule ?? [];
}
get isPreview(): boolean {
return !!this.data.preview;
}
get currencyCode(): string {
return this.data.currencyCode;
}
@@ -154,16 +154,6 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
}
/**
* FD quote while opening the account, for every FD type: the same body as create (clientId, productId,
* submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The
* backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity
* date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule).
*/
accountPreview(body: any): Observable<any> {
return this.http.post(`/fixeddepositaccounts/account-preview`, body);
}
/**
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
* configured: backdated to the original maturity date within the grace window, or from today
@@ -110,8 +110,8 @@ export class CreateRecurringDepositsAccountComponent {
*/
setTemplate($event: any) {
this.recurringDepositsAccountProductTemplate = $event;
this.productsService.getRdBaseInterestRates().subscribe({
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
this.productsService.getFdBaseInterestRates().subscribe({
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
error: () => (this.cbsBaseRates = [])
});
}
@@ -198,8 +198,7 @@ export class CreateRecurringDepositsAccountComponent {
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message:
'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
@@ -144,8 +144,8 @@ export class EditRecurringDepositAccountComponent {
setTemplate($event: any) {
this.recurringDepositsAccountProductTemplate = $event;
this.productsService.getRdBaseInterestRates().subscribe({
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
this.productsService.getFdBaseInterestRates().subscribe({
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
error: () => (this.cbsBaseRates = [])
});
}
@@ -127,8 +127,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}>();
get activeBase(): any {
// The recurring deposit's own CBS base chart; its active version carries the senior citizen rate of each slab.
return (this.cbsBaseRates || []).find((r) => r.isActive) ?? (this.cbsBaseRates || [])[0];
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
}
/**
@@ -174,10 +173,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return baseSlabs;
}
// Products made before RD charts moved to months still hold the base chart's own year slabs
const candidates = [
...baseSlabs,
...this.normalizeCbsSlabs(cbsSlabs)
];
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
return productSlabs.map((slab: any) => {
if (slab.seniorCitizenRate != null) {
return slab;
@@ -50,34 +50,6 @@
<span class="nx-row__label">{{ 'labels.inputs.Maturity Date' | translate }}</span>
<span class="nx-row__value">{{ recurringDepositsAccountData.maturityDate | dateFormat }}</span>
</div>
@if (installmentPosition) {
<div class="nx-row">
<span class="nx-row__label">Next Installment Due</span>
<span class="nx-row__value">
@if (installmentPosition.nextDueDate) {
{{ installmentPosition.nextDueDate | dateFormat }} &middot;
{{ installmentPosition.installmentAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
} @else {
<span class="nx-muted">All installments paid</span>
}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">Installments Paid</span>
<span class="nx-row__value"
>{{ installmentPosition.paidInstallments }} of {{ installmentPosition.totalInstallments }}</span
>
</div>
@if (installmentPosition.overdueInstallments > 0) {
<div class="nx-row">
<span class="nx-row__label">Overdue</span>
<span class="nx-row__value" style="color: var(--mat-sys-error, #d32f2f)">
{{ installmentPosition.overdueInstallments }} installment(s) &middot;
{{ installmentPosition.overdueAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
}
@if (
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
) {
@@ -46,21 +46,11 @@ export class GeneralTabComponent {
totalLienAmount = 0;
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
/** When the next installment is expected and how much, plus anything already overdue; null once fully paid. */
installmentPosition: {
nextDueDate: string | null;
installmentAmount: number;
paidInstallments: number;
totalInstallments: number;
overdueInstallments: number;
overdueAmount: number;
} | null = null;
constructor() {
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
this.loadLiens(this.recurringDepositsAccountData?.id);
this.loadInstallmentPosition(this.recurringDepositsAccountData?.id);
this.currency = this.recurringDepositsAccountData.currency;
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
if (this.router.url.includes('clients')) {
@@ -73,27 +63,6 @@ export class GeneralTabComponent {
});
}
private loadInstallmentPosition(accountId: any) {
this.installmentPosition = null;
if (!accountId) {
return;
}
this.recurringDepositsService.getRecurringDepositInstallmentPosition(accountId).subscribe((response: any) => {
const row = (response?.accounts ?? []).find((a: any) => Number(a.id) === Number(accountId));
if (!row) {
return;
}
this.installmentPosition = {
nextDueDate: row.nextDueDate || null,
installmentAmount: Number(row.installmentAmount) || 0,
paidInstallments: Number(row.paidInstallments) || 0,
totalInstallments: Number(row.totalInstallments) || 0,
overdueInstallments: Number(row.overdueInstallments) || 0,
overdueAmount: Number(row.overdueAmount) || 0
};
});
}
private loadLiens(accountId: any) {
this.liens = [];
this.totalLienAmount = 0;
@@ -39,16 +39,6 @@ export class RecurringDepositsService {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
}
/**
* This deposit's installment position as of the business date (next due date, overdue count/amount), from the
* RD Dashboard endpoint - the same paid/overdue rule the auto-debit and penalty jobs use. Older backends ignore
* `accountId` and return every RD, so callers pick the row by id.
*/
getRecurringDepositInstallmentPosition(recurringDepositAccountId: any): Observable<any> {
const httpParams = new HttpParams().set('accountId', recurringDepositAccountId);
return this.http.get('/recurringdepositaccounts/dashboard', { params: httpParams });
}
/**
* @param clientId Client Id
* @param clientName Client Name
@@ -719,8 +719,7 @@
align-items: center;
gap: 6px;
/* stylelint-disable-next-line selector-no-qualifying-type */
input[type='number'] {
[type='number'] {
width: 64px;
}
@@ -760,8 +759,7 @@
}
/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */
/* stylelint-disable-next-line selector-no-qualifying-type */
:where(input[type='text'], input[type='number'], input[type='date'], select, textarea) {
:where([type='text'], [type='number'], [type='date'], select, textarea) {
font: inherit;
font-size: 12.5px;
padding: 6px 8px;
@@ -773,13 +771,11 @@
box-sizing: border-box;
}
/* stylelint-disable-next-line selector-no-qualifying-type */
:where(input[type='text'], select) {
:where([type='text'], select) {
width: 100%;
}
/* stylelint-disable-next-line selector-no-qualifying-type */
:where(input[type='checkbox']) {
:where([type='checkbox']) {
width: auto;
}
@@ -869,8 +865,7 @@
gap: 4px;
align-items: center;
/* stylelint-disable-next-line selector-no-qualifying-type */
input[type='number'] {
[type='number'] {
width: 60px;
}
@@ -1180,8 +1175,7 @@
gap: 8px;
max-width: 720px;
/* stylelint-disable-next-line selector-no-qualifying-type */
input[type='text'] {
[type='text'] {
flex: 1;
padding: 9px 11px;
font-size: 13px;
@@ -61,6 +61,7 @@ export interface ReserveTransferData {
export class ReserveTransferDialogComponent {
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
private rbi = inject(RbiCircularsService);
readonly data: ReserveTransferData = inject(MAT_DIALOG_DATA);
/** The account money leaves. Fixed — the dialog is opened from its row. */
readonly from: TransferAccount;
@@ -75,8 +76,6 @@ export class ReserveTransferDialogComponent {
submitting = false;
error = '';
readonly data = inject<ReserveTransferData>(MAT_DIALOG_DATA);
constructor() {
this.from = this.data.from;
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
/**
* 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 { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
Route.withShell([
{
path: 'ai/loan-eligibility',
loadComponent: () => import('./loan-eligibility.component').then((m) => m.LoanEligibilityComponent),
data: { title: 'Loan Eligibility', breadcrumb: 'Loan Eligibility' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoanEligibilityRoutingModule {}
@@ -0,0 +1,482 @@
<!--
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/.
-->
<div class="pg">
@if (!loading) {
<header class="pg-head">
<div>
<div class="pg-eyebrow">AI / INTELLIGENCE · LENDING &amp; RISK</div>
<h1 class="pg-title">Loan Eligibility</h1>
<p class="pg-sub">
How our loans are running (RBI asset classification) and which loan products the good borrowers are eligible
for, using the eligibility rules set on each loan product.
@if (summary) {
As on {{ summary.asOnDate }}.
}
</p>
</div>
</header>
}
@if (error) {
<p class="pg-sub le-error">{{ error }}</p>
}
@if (loading) {
<div class="le-loader">
<div class="le-spinner"></div>
<div class="le-loader-title">Loading loan book…</div>
<ul class="le-loader-steps">
@for (st of loadingSteps; track $index) {
<li [class.le-done]="$index < loadingStep" [class.le-now]="$index === loadingStep">
{{ $index < loadingStep ? '✓' : $index === loadingStep ? '…' : '' }} {{ st }}
</li>
}
</ul>
<div class="le-progress"><span [style.width.%]="((loadingStep + 1) / loadingSteps.length) * 100"></span></div>
</div>
}
@if (summary; as s) {
<div class="le-kpis">
<div class="le-kpi">
<span class="le-kpi-l">Active loans</span>
<span class="le-kpi-v">{{ s.activeLoans }}</span>
<span class="le-kpi-s">₹{{ s.activeOutstanding | number: '1.0-0' }} outstanding</span>
</div>
<div class="le-kpi le-kpi--good">
<span class="le-kpi-l">Running well (Standard + SMA)</span>
<span class="le-kpi-v">{{ s.performingLoans }}</span>
<span class="le-kpi-s">₹{{ s.performingOutstanding | number: '1.0-0' }}</span>
</div>
<div class="le-kpi le-kpi--bad">
<span class="le-kpi-l">Gone bad (NPA)</span>
<span class="le-kpi-v">{{ s.npaLoans }}</span>
<span class="le-kpi-s">₹{{ s.npaOutstanding | number: '1.0-0' }} · Gross NPA {{ s.grossNpaPct }}%</span>
</div>
<div class="le-kpi">
<span class="le-kpi-l">Written off / Closed</span>
<span class="le-kpi-v">{{ s.writtenOffLoans }} / {{ s.closedLoans }}</span>
<span class="le-kpi-s">₹{{ s.writtenOffAmount | number: '1.0-0' }} written off</span>
</div>
</div>
<div class="cctr-tabs le-tabs le-views">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="view === 'BOOK'"
(click)="view = 'BOOK'"
>
Loan book (RBI classes &amp; products)
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="view === 'BORROWERS'"
(click)="view = 'BORROWERS'"
>
Borrowers &amp; eligibility (Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad
{{ s.badBorrowers }})
</button>
</div>
@if (view === 'BOOK') {
<div class="pg-card">
<div class="pos-section-label">Loans by RBI asset classification (days past due)</div>
<table class="pg-table">
<thead>
<tr>
<th>Class</th>
<th>Days past due</th>
<th class="num">Loans</th>
<th class="num">Outstanding</th>
<th class="num">Overdue</th>
<th>Share of book</th>
</tr>
</thead>
<tbody>
@for (st of performingStages; track st.stage) {
<tr>
<td>
<span
class="le-dot"
[class.le-dot--good]="st.stage === 'STANDARD'"
[class.le-dot--warn]="st.stage !== 'STANDARD'"
></span>
{{ st.label }}
</td>
<td>{{ st.dpdRange }}</td>
<td class="num">{{ st.loans }}</td>
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
<td>
<span class="le-bar"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
</td>
</tr>
}
@for (st of npaStages; track st.stage) {
<tr>
<td><span class="le-dot le-dot--bad"></span> {{ st.label }} (NPA)</td>
<td>{{ st.dpdRange }}</td>
<td class="num">{{ st.loans }}</td>
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
<td>
<span class="le-bar le-bar--bad"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
</td>
</tr>
}
@for (st of closedStages; track st.stage) {
<tr class="le-muted">
<td>{{ st.label }}</td>
<td>{{ st.dpdRange }}</td>
<td class="num">{{ st.loans }}</td>
<td class="num">–</td>
<td class="num">–</td>
<td></td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Active loans by product</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th class="num">Active</th>
<th class="num">Running well</th>
<th class="num">NPA</th>
<th class="num">Outstanding</th>
<th class="num">NPA outstanding</th>
</tr>
</thead>
<tbody>
@for (p of s.products; track p.productId) {
<tr>
<td>{{ p.productName }}</td>
<td class="num">{{ p.activeLoans }}</td>
<td class="num">{{ p.performingLoans }}</td>
<td class="num">{{ p.npaLoans }}</td>
<td class="num">₹{{ p.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ p.npaOutstanding | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No active loans.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Share linking to borrowing (5% unsecured · 2.5% secured)</div>
<div class="le-facts">
<span>Shares required on running loans: ₹{{ s.shareRequired | number: '1.0-0' }}</span>
<span>Shares held by these members: ₹{{ s.shareHeld | number: '1.0-0' }}</span>
<span [class.le-late]="s.shareShortMembers > 0"
>Members short: {{ s.shareShortMembers }} (₹{{ s.shareShortfall | number: '1.0-0' }} to collect)</span
>
</div>
</div>
}
@if (view === 'BORROWERS') {
<div class="pg-card">
<div class="pos-section-label">
Borrowers — Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad {{ s.badBorrowers }}
</div>
<div class="cctr-tabs le-tabs">
@for (c of categories; track c.value) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="category === c.value"
(click)="setCategory(c.value)"
>
{{ c.label }}
</button>
}
<input
type="text"
class="le-search"
placeholder="Search name / account no"
[(ngModel)]="search"
(keyup.enter)="loadBorrowers()"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="loadBorrowers()">Search</button>
</div>
<p class="pg-sub le-help">
Good: every running loan is regular (0 days past due) and nothing was ever written off. Watch: a loan is
overdue up to 90 days (SMA). Bad: a loan is NPA or written off. Eligibility is worked out for good borrowers
only; a fresh loan also needs a clean EMI record (at most 2 EMIs ever paid late).
</p>
<table class="pg-table le-compact">
<thead>
<tr>
<th>Borrower</th>
<th>Category</th>
<th class="num">Running / Closed</th>
<th>EMI record</th>
<th class="num">Outstanding</th>
<th class="num">Max days overdue</th>
<th>Worst class</th>
<th>More loan?</th>
<th></th>
</tr>
</thead>
<tbody>
@if (borrowersLoading) {
<tr>
<td colspan="9" class="pg-empty">
<span class="le-spinner le-spinner--sm"></span> Fetching borrowers…
</td>
</tr>
}
@for (b of borrowersLoading ? [] : pagedBorrowers; track b.clientId) {
<tr [class.le-selected]="selected?.clientId === b.clientId">
<td>
{{ b.clientName }} <small>({{ b.clientAccountNo }})</small>
</td>
<td>
<span class="le-chip" [ngClass]="'le-chip--' + b.category.toLowerCase()">{{
categoryLabel(b.category)
}}</span>
</td>
<td class="num">{{ b.activeLoans }} / {{ b.closedLoans }}</td>
<td>
{{ b.emisPaid - b.emisLate }} / {{ b.emisPaid }} on time
@if (b.emisLate) {
<small class="le-late">({{ b.emisLate }} late)</small>
}
</td>
<td class="num">₹{{ b.outstanding | number: '1.2-2' }}</td>
<td class="num">{{ b.maxDpd }}</td>
<td>{{ b.worstStage || '–' }}</td>
<td>
@if (b.eligibleProducts > 0) {
<span class="le-chip le-chip--good">Yes · {{ b.eligibleProducts }} products</span>
} @else {
<span class="le-chip le-chip--bad">No</span>
}
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="openingId !== null" (click)="open(b)">
{{ openingId === b.clientId ? 'Loading…' : 'View' }}
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No borrowers in this category.</td>
</tr>
}
</tbody>
</table>
@if (borrowers.length > pageSize) {
<div class="le-pager">
<span>Showing {{ page * pageSize + 1 }}–{{ pageEnd }} of {{ borrowers.length }}</span>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="page === 0" (click)="page = page - 1">
‹ Prev
</button>
@for (p of pages; track p) {
<button type="button" class="pg-btn pg-btn--ghost" [class.pg-btn--active]="p === page" (click)="page = p">
{{ p + 1 }}
</button>
}
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="page >= pages.length - 1"
(click)="page = page + 1"
>
Next ›
</button>
</div>
}
</div>
}
@if (selected; as b) {
<div class="le-overlay" (click)="close()">
<div class="le-modal" role="dialog" aria-modal="true" (click)="$event.stopPropagation()">
<div class="le-modal-head">
<div class="pos-section-label">
{{ b.clientName }} ({{ b.clientAccountNo }}) — {{ categoryLabel(b.category) }} borrower
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="close()">Close</button>
</div>
<div class="le-facts">
<span>Age: {{ b.age ?? 'not recorded' }}</span>
<span
>Monthly income: {{ b.monthlyIncome ? '₹' + (b.monthlyIncome | number: '1.0-0') : 'not recorded' }}</span
>
<span>Current EMIs: ₹{{ b.currentEmi | number: '1.0-0' }}</span>
<span>EMI record: {{ b.emisPaid - b.emisLate }} of {{ b.emisPaid }} paid on time</span>
<span>Deposits (savings / FD / RD): ₹{{ b.depositBalance | number: '1.0-0' }}</span>
<span [class.le-late]="b.shareHeld < b.shareRequired"
>Shares held: ₹{{ b.shareHeld | number: '1.0-0' }} (needed ₹{{ b.shareRequired | number: '1.0-0' }} incl.
membership)</span
>
</div>
@for (a of b.accounts ?? []; track a.accountNo) {
<div class="pos-section-label">
{{ a.type }} account {{ a.accountNo }} — last 6 months (money in, expenses, balance)
</div>
<table class="pg-table">
<thead>
<tr>
<th>Month</th>
<th class="num">Credits (in)</th>
<th class="num">Debits (expenses + EMIs)</th>
<th class="num">Left over</th>
<th class="num">Average balance</th>
<th class="num">Cheque / ECS returns</th>
</tr>
</thead>
<tbody>
@for (m of a.months; track m.month) {
<tr>
<td>{{ m.month }}</td>
<td class="num">₹{{ m.credits | number: '1.0-0' }}</td>
<td class="num">₹{{ m.debits | number: '1.0-0' }}</td>
<td class="num" [class.le-late]="m.credits < m.debits">
₹{{ m.credits - m.debits | number: '1.0-0' }}
</td>
<td class="num">₹{{ m.avgBalance | number: '1.0-0' }}</td>
<td class="num" [class.le-late]="m.returns > 0">{{ m.returns }}</td>
</tr>
}
<tr class="le-total">
<td>Monthly average</td>
<td class="num">₹{{ b.avgCredits | number: '1.0-0' }}</td>
<td class="num">₹{{ b.avgDebits | number: '1.0-0' }}</td>
<td class="num" [class.le-late]="b.avgCredits < b.avgDebits">
₹{{ b.avgCredits - b.avgDebits | number: '1.0-0' }}
</td>
<td class="num">
@if (b.avgCredits > 0) {
Spends {{ (b.avgDebits / b.avgCredits) * 100 | number: '1.0-0' }}% of income
}
</td>
<td class="num">{{ b.returns }}</td>
</tr>
</tbody>
</table>
}
<div class="pos-section-label">Loans</div>
<table class="pg-table">
<thead>
<tr>
<th>Loan</th>
<th>Product</th>
<th>Status</th>
<th>Class</th>
<th class="num">Days overdue</th>
<th class="num">EMIs on time</th>
<th class="num">Disbursed</th>
<th class="num">Outstanding</th>
<th class="num">Overdue</th>
<th class="num">Shares needed</th>
</tr>
</thead>
<tbody>
@for (l of b.loans ?? []; track l.loanId) {
<tr>
<td>{{ l.accountNo }}</td>
<td>{{ l.productName }}</td>
<td>{{ l.status }}</td>
<td>{{ l.stage }}</td>
<td class="num">{{ l.dpd }}</td>
<td class="num">{{ l.emisPaid - l.emisLate }} / {{ l.emisPaid }}</td>
<td class="num">₹{{ l.disbursed | number: '1.2-2' }}</td>
<td class="num">₹{{ l.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ l.overdue | number: '1.2-2' }}</td>
<td class="num">
@if (l.shareRequired) {
₹{{ l.shareRequired | number: '1.0-0' }} <small>({{ l.sharePct }}%)</small>
} @else {
–
}
</td>
</tr>
}
</tbody>
</table>
@if (b.category === 'GOOD') {
<div class="pos-section-label">Eligible loan products</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Eligible</th>
<th class="num">Up to</th>
<th class="num">Shares to take</th>
<th class="num">In hand</th>
<th>Why</th>
</tr>
</thead>
<tbody>
@for (e of b.eligibility ?? []; track e.productId) {
<tr>
<td>{{ e.productName }}</td>
<td>
<span class="le-chip" [ngClass]="e.eligible ? 'le-chip--good' : 'le-chip--bad'">{{
e.eligible ? 'Yes' : 'No'
}}</span>
</td>
<td class="num">{{ e.eligibleAmount ? '₹' + (e.eligibleAmount | number: '1.0-0') : '–' }}</td>
<td class="num">
@if (e.shareToBuy) {
₹{{ e.shareToBuy | number: '1.0-0' }} <small>({{ e.sharePct }}%)</small>
} @else {
–
}
</td>
<td class="num">{{ e.netInHand ? '₹' + (e.netInHand | number: '1.0-0') : '–' }}</td>
<td class="le-why">
@for (f of e.failed; track $index) {
<div class="le-fail">✗ {{ f }}</div>
}
@for (p of e.passed; track $index) {
<div class="le-pass">✓ {{ p }}</div>
}
@for (n of e.notes; track $index) {
<div class="le-note">• {{ n }}</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No active loan products.</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="pg-sub le-help">
Eligibility is not worked out for this borrower because a loan is overdue, NPA or written off.
</p>
}
</div>
</div>
}
<ul class="le-notes">
@for (n of s.notes; track $index) {
<li>{{ n }}</li>
}
</ul>
}
</div>
@@ -0,0 +1,312 @@
/**
* 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/.
*/
.le-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.le-kpi {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border: 1px solid #e4e7ec;
border-radius: 10px;
background: #fff;
}
.le-kpi--good {
border-left: 4px solid #12b76a;
}
.le-kpi--bad {
border-left: 4px solid #d92d20;
}
.le-kpi-l {
font-size: 12px;
color: #667085;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.le-kpi-v {
font-size: 26px;
font-weight: 700;
}
.le-kpi-s {
font-size: 13px;
color: #475467;
}
.le-dot {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 6px;
border-radius: 50%;
}
.le-dot--good {
background: #12b76a;
}
.le-dot--warn {
background: #f79009;
}
.le-dot--bad {
background: #d92d20;
}
.le-bar {
display: inline-block;
width: 120px;
height: 8px;
margin-right: 6px;
overflow: hidden;
border-radius: 4px;
background: #f2f4f7;
vertical-align: middle;
span {
display: block;
height: 100%;
background: #12b76a;
}
}
.le-bar--bad span {
background: #d92d20;
}
.le-muted {
color: #667085;
}
.le-tabs {
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 0;
}
.le-search {
min-width: 220px;
padding: 8px 10px;
border: 1px solid #d0d5dd;
border-radius: 8px;
}
.le-help {
padding: 0 16px;
}
.le-chip {
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.le-chip--good {
background: #ecfdf3;
color: #067647;
}
.le-chip--watch {
background: #fffaeb;
color: #b54708;
}
.le-chip--bad {
background: #fef3f2;
color: #b42318;
}
.le-selected {
background: #f5f8ff;
}
.le-facts {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 8px 16px 12px;
color: #344054;
}
.le-why div {
font-size: 13px;
line-height: 1.5;
}
.le-fail {
color: #b42318;
}
.le-pass {
color: #067647;
}
.le-note {
color: #667085;
}
.le-error {
color: #b42318;
}
.le-notes {
margin: 8px 0 24px;
color: #667085;
font-size: 13px;
}
.le-late {
color: #b42318;
}
.le-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
overflow-y: auto;
background: rgb(15 23 42 / 55%);
}
.le-modal {
width: min(1100px, 100%);
padding: 20px 24px;
border-radius: 12px;
background: #fff;
box-shadow: 0 20px 50px rgb(0 0 0 / 30%);
}
.le-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.le-views {
margin-bottom: 16px;
}
.le-compact td {
padding-top: 6px;
padding-bottom: 6px;
}
.le-compact .pg-btn {
padding: 4px 14px;
}
.le-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
span {
margin-right: auto;
color: #667085;
}
}
.le-loader {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
margin: 32px auto;
padding: 36px 24px;
max-width: 520px;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 16px rgb(16 24 40 / 8%);
}
.le-loader-title {
font-weight: 600;
font-size: 16px;
color: #1d2939;
}
.le-loader-steps {
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
line-height: 1.9;
color: #98a2b3;
.le-done {
color: #067647;
}
.le-now {
color: #1d4ed8;
font-weight: 600;
}
}
.le-progress {
width: 100%;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: #eaecf0;
span {
display: block;
height: 100%;
background: #1d4ed8;
transition: width 0.6s ease;
}
}
.le-spinner {
width: 44px;
height: 44px;
border: 4px solid #dbe4ff;
border-top-color: #1d4ed8;
border-radius: 50%;
animation: le-spin 0.8s linear infinite;
}
.le-spinner--sm {
display: inline-block;
width: 14px;
height: 14px;
border-width: 2px;
margin-right: 6px;
vertical-align: middle;
}
@keyframes le-spin {
to {
transform: rotate(360deg);
}
}
.le-total td {
font-weight: 600;
border-top: 2px solid #d0d5dd;
}
@@ -0,0 +1,187 @@
/**
* 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, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import {
Borrower,
BorrowerCategory,
LoanEligibilityService,
LoanEligibilitySummary,
LoanStageRow
} from './loan-eligibility.service';
/**
* Loan Eligibility – how the loan book is running (RBI asset classes) and which loan products the good borrowers
* qualify for, using their EMI record and the eligibility rules set on each loan product.
*/
@Component({
selector: 'mifosx-loan-eligibility',
templateUrl: './loan-eligibility.component.html',
styleUrls: [
'../cashier/cashier-shared.scss',
'../investment/investment-shared.scss',
'./loan-eligibility.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class LoanEligibilityComponent implements OnInit, OnDestroy {
private readonly service = inject(LoanEligibilityService);
summary: LoanEligibilitySummary | null = null;
borrowers: Borrower[] = [];
category: BorrowerCategory | '' = 'GOOD';
search = '';
selected: Borrower | null = null;
/** Which part of the page is shown, so the user need not scroll past the loan book to reach borrowers. */
view: 'BOOK' | 'BORROWERS' = 'BOOK';
page = 0;
readonly pageSize = 10;
loading = false;
borrowersLoading = false;
/** Borrower whose detail is being fetched (View button shows it). */
openingId: number | null = null;
error = '';
/** Steps shown while the book is being read. */
readonly loadingSteps = [
'Fetching loan accounts…',
'Reading repayment schedules and overdues…',
'Classifying loans as per RBI IRAC norms…',
'Running product eligibility rules…'
];
loadingStep = 0;
private stepTimer?: ReturnType<typeof setInterval>;
readonly categories: Array<{ value: BorrowerCategory | ''; label: string }> = [
{ value: 'GOOD', label: 'Good borrowers' },
{ value: 'WATCH', label: 'Watch (SMA)' },
{ value: 'BAD', label: 'Bad (NPA / written off)' },
{ value: '', label: 'All borrowers' }
];
ngOnInit(): void {
this.loading = true;
this.loadingStep = 0;
this.stepTimer = setInterval(
() => (this.loadingStep = Math.min(this.loadingStep + 1, this.loadingSteps.length - 1)),
700
);
this.service.getSummary().subscribe({
next: (s) => {
this.summary = s;
this.stopLoading();
},
error: (err) => {
this.stopLoading();
this.error = this.message(err);
}
});
this.loadBorrowers();
}
loadBorrowers(): void {
this.selected = null;
this.borrowersLoading = true;
this.service.getBorrowers(this.category, this.search).subscribe({
next: (b) => {
this.borrowers = b;
this.page = 0;
this.borrowersLoading = false;
},
error: (err) => {
this.borrowersLoading = false;
this.error = this.message(err);
}
});
}
setCategory(c: BorrowerCategory | ''): void {
this.category = c;
this.loadBorrowers();
}
open(b: Borrower): void {
this.openingId = b.clientId;
this.service.getBorrower(b.clientId).subscribe({
next: (d) => {
this.openingId = null;
this.selected = d;
},
error: (err) => {
this.openingId = null;
this.error = this.message(err);
}
});
}
ngOnDestroy(): void {
this.stopLoading();
}
private stopLoading(): void {
this.loading = false;
clearInterval(this.stepTimer);
}
get pagedBorrowers(): Borrower[] {
return this.borrowers.slice(this.page * this.pageSize, (this.page + 1) * this.pageSize);
}
get pageEnd(): number {
return Math.min((this.page + 1) * this.pageSize, this.borrowers.length);
}
get pages(): number[] {
return Array.from({ length: Math.ceil(this.borrowers.length / this.pageSize) }, (_, i) => i);
}
close(): void {
this.selected = null;
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
get performingStages(): LoanStageRow[] {
return (this.summary?.stages ?? []).filter((s) => s.group === 'PERFORMING');
}
get npaStages(): LoanStageRow[] {
return (this.summary?.stages ?? []).filter((s) => s.group === 'NPA');
}
get closedStages(): LoanStageRow[] {
return (this.summary?.stages ?? []).filter((s) => s.group === 'CLOSED');
}
/** Share of the active book in a stage, for the bar. */
share(s: LoanStageRow): number {
const total = this.summary?.activeOutstanding ?? 0;
return total > 0 ? Math.round((s.outstanding / total) * 1000) / 10 : 0;
}
categoryLabel(c: BorrowerCategory): string {
return c === 'GOOD' ? 'Good' : c === 'WATCH' ? 'Watch' : 'Bad';
}
private message(err: unknown): string {
if (err instanceof HttpErrorResponse) {
const e = err.error as { errors?: Array<{ defaultUserMessage?: string }>; defaultUserMessage?: string } | null;
return e?.errors?.[0]?.defaultUserMessage || e?.defaultUserMessage || err.message;
}
return String(err);
}
}
@@ -0,0 +1,726 @@
/**
* 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/.
*/
/**
* Demo data for Loan Eligibility. The bank's rules are applied to it: RBI asset classification on days past due, and
* product eligibility rules (age, age at maturity, income, FOIR, loans per customer, LTV on pledged deposits). The book is generated from a fixed seed, so the
* figures are the same on every load.
*/
import {
Borrower,
BorrowerCategory,
BorrowerLoan,
LoanEligibilitySummary,
LoanStageRow,
ProductEligibility
} from './loan-eligibility.service';
interface DemoProduct {
id: number;
name: string;
minAmount: number;
maxAmount: number;
annualRate: number;
tenureMonths: number;
minAge?: number;
maxAgeAtMaturity?: number;
minMonthlyIncome?: number;
maxFoir?: number;
maxLoansPerCustomer?: number;
/** Loan against deposits: % of the FD / RD balance that can be lent. */
ltvPct?: number;
/** Secured loans need shares of 2.5% of the loan, unsecured 5% (share linking to borrowing). */
secured: boolean;
}
interface DemoMonth {
month: string;
credits: number;
debits: number;
avgBalance: number;
returns: number;
}
interface DemoAccount {
type: 'Savings' | 'Current';
accountNo: string;
months: DemoMonth[];
}
interface DemoLoan {
id: number;
accountNo: string;
productId: number;
status: 'Active' | 'Closed' | 'Written off';
dpd: number;
disbursed: number;
outstanding: number;
overdue: number;
emi: number;
/** EMIs fallen due so far, and how many of them were paid after the due date. */
emisPaid: number;
emisLate: number;
}
interface DemoClient {
id: number;
name: string;
accountNo: string;
age: number;
monthlyIncome?: number;
fdBalance: number;
savingsBalance: number;
loans: DemoLoan[];
/** Paid-up share capital held as a member. */
shareHeld: number;
accounts: DemoAccount[];
}
export const DEMO_PRODUCTS: DemoProduct[] = [
{
id: 1,
name: 'Personal Loan',
minAmount: 25000,
maxAmount: 1000000,
annualRate: 11.5,
tenureMonths: 60,
minAge: 21,
maxAgeAtMaturity: 60,
minMonthlyIncome: 25000,
maxFoir: 50,
maxLoansPerCustomer: 1,
secured: false
},
{
id: 2,
name: 'Home Loan',
minAmount: 500000,
maxAmount: 5000000,
annualRate: 9,
tenureMonths: 240,
minAge: 23,
maxAgeAtMaturity: 70,
minMonthlyIncome: 40000,
maxFoir: 55,
maxLoansPerCustomer: 1,
secured: true
},
{
id: 3,
name: 'Vehicle Loan',
minAmount: 100000,
maxAmount: 1500000,
annualRate: 9.75,
tenureMonths: 84,
minAge: 21,
maxAgeAtMaturity: 65,
minMonthlyIncome: 20000,
maxFoir: 50,
maxLoansPerCustomer: 2,
secured: true
},
{
id: 4,
name: 'Gold Loan',
minAmount: 10000,
maxAmount: 1000000,
annualRate: 9,
tenureMonths: 12,
minAge: 18,
maxLoansPerCustomer: 3,
secured: true
},
{
id: 5,
name: 'Loan against FD',
minAmount: 5000,
maxAmount: 2500000,
annualRate: 8.5,
tenureMonths: 12,
minAge: 18,
ltvPct: 90,
secured: true
},
{
id: 6,
name: 'MSME / Business Loan',
minAmount: 200000,
maxAmount: 2500000,
annualRate: 12,
tenureMonths: 84,
minAge: 25,
maxAgeAtMaturity: 65,
minMonthlyIncome: 50000,
maxFoir: 60,
maxLoansPerCustomer: 1,
secured: true
}
];
/** Membership share every member holds, over and above the shares linked to loans. */
const MEMBERSHIP_SHARE = 1000;
const MONTHS = [
'Apr 2026',
'May 2026',
'Jun 2026',
'Jul 2026',
'Aug 2026',
'Sep 2026'
];
/** Late EMIs a borrower may have in their history and still get a fresh loan. */
const MAX_LATE_EMIS = 2;
/** Cheque / ECS returns allowed in the last six months for a fresh loan. */
const MAX_RETURNS = 1;
function sharePct(productId: number): number {
return DEMO_PRODUCTS.find((p) => p.id === productId)?.secured === false ? 5 : 2.5;
}
function ceil100(n: number): number {
return Math.ceil(n / 100) * 100;
}
const FIRST = [
'Ramesh',
'Sunita',
'Amit',
'Priya',
'Rajesh',
'Kavita',
'Suresh',
'Anjali',
'Mahesh',
'Pooja',
'Dinesh',
'Neha',
'Vijay',
'Rekha',
'Sanjay',
'Meena',
'Arvind',
'Geeta',
'Manoj',
'Seema',
'Rahul',
'Asha',
'Deepak',
'Nisha',
'Ashok',
'Lata',
'Prakash',
'Usha',
'Anil',
'Shobha'
];
const LAST = [
'Sharma',
'Verma',
'Patel',
'Gupta',
'Singh',
'Yadav',
'Jain',
'Agrawal',
'Choudhary',
'Mishra',
'Tiwari',
'Rathore',
'Soni',
'Malviya',
'Dubey',
'Saxena'
];
/** Small deterministic generator so the demo looks the same every time. */
function rng(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) % 4294967296;
return s / 4294967296;
};
}
function emiFor(principal: number, annualRate: number, months: number): number {
const r = annualRate / 1200;
return r === 0 ? principal / months : (principal * r) / (1 - Math.pow(1 + r, -months));
}
function principalForEmi(emi: number, annualRate: number, months: number): number {
const r = annualRate / 1200;
return r === 0 ? emi * months : (emi * (1 - Math.pow(1 + r, -months))) / r;
}
function round(n: number, step = 1): number {
return Math.round(n / step) * step;
}
function buildBook(): DemoClient[] {
const rand = rng(20261019);
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const clients: DemoClient[] = [];
// Separate streams for the repayment record and the bank accounts, so the book above stays as it was.
const track = rng(7);
const bank = rng(11);
let loanId = 1;
for (let i = 0; i < 64; i++) {
const salaried = rand() < 0.85;
const client: DemoClient = {
id: 1001 + i,
name: `${FIRST[i % FIRST.length]} ${pick(LAST)}`,
accountNo: String(100010 + i).padStart(9, '0'),
age: 22 + Math.floor(rand() * 40),
monthlyIncome: salaried ? round(18000 + rand() * 132000, 500) : undefined,
fdBalance: rand() < 0.45 ? round(25000 + rand() * 775000, 1000) : 0,
savingsBalance: round(2000 + rand() * 148000, 100),
loans: [],
shareHeld: 0,
accounts: []
};
const count = 1 + Math.floor(rand() * 3);
// Behaviour of the borrower decides how all their loans run: most pay on time, some slip, a few go bad.
const behaviour = rand();
for (let k = 0; k < count; k++) {
// Stay within each product's age and loans-per-customer rules, as the bank would have at sanction.
const product = pick(
DEMO_PRODUCTS.filter(
(p) =>
(p.id !== 5 || client.fdBalance > 0) &&
client.age >= (p.minAge ?? 18) &&
client.loans.filter((l) => l.productId === p.id).length < (p.maxLoansPerCustomer ?? 3)
)
);
const disbursed = round(
product.minAmount + rand() * Math.min(product.maxAmount - product.minAmount, 1800000),
1000
);
const closed = rand() < 0.25;
let dpd = 0;
if (!closed) {
if (behaviour > 0.975) {
dpd = 91 + Math.floor(rand() * 900);
} else if (behaviour > 0.72) {
dpd = 1 + Math.floor(rand() * 89);
} else if (behaviour > 0.705) {
dpd = 91 + Math.floor(rand() * 300);
}
}
const writtenOff = !closed && dpd > 700 && rand() < 0.5;
const outstanding = closed || writtenOff ? 0 : round(disbursed * (0.25 + rand() * 0.7), 1);
const emi = round(emiFor(disbursed, product.annualRate, product.tenureMonths), 1);
const emisPaid = closed
? Math.min(product.tenureMonths, 12 + Math.floor(track() * 24))
: writtenOff
? Math.max(3, Math.round(product.tenureMonths * 0.3))
: Math.max(1, Math.round(product.tenureMonths * (1 - outstanding / disbursed)));
// Most good payers never miss a date; some were late once or a few times but are regular today.
let emisLate = 0;
if (behaviour > 0.705) {
emisLate = 2 + Math.floor(track() * 6);
} else if (behaviour > 0.55) {
emisLate = 1 + Math.floor(track() * 4);
}
emisLate = Math.min(emisLate, emisPaid);
client.loans.push({
id: loanId,
accountNo: String(500000 + loanId).padStart(9, '0'),
productId: product.id,
status: closed ? 'Closed' : writtenOff ? 'Written off' : 'Active',
dpd: closed || writtenOff ? 0 : dpd,
disbursed,
outstanding,
overdue: dpd > 0 && !writtenOff ? round(Math.min(outstanding, emi * Math.ceil(dpd / 30)), 1) : 0,
emi: closed || writtenOff ? 0 : emi,
emisPaid,
emisLate
});
loanId++;
}
addBanking(client, bank);
clients.push(client);
}
return clients;
}
/** Savings / current account statement for the last six months and the member's shareholding. */
function addBanking(c: DemoClient, bank: () => number): void {
const active = c.loans.filter((l) => l.status === 'Active');
const emi = active.reduce((s, l) => s + l.emi, 0);
const overdue = active.some((l) => l.dpd > 0);
const late = c.loans.reduce((s, l) => s + l.emisLate, 0);
// Salaried members run a savings account; traders and businesses run a current account.
const business = !c.monthlyIncome;
const turnover = c.monthlyIncome ?? 150000 + bank() * 650000;
const spendRatio = business ? 0.82 + bank() * 0.12 : 0.3 + bank() * 0.3;
let returns = overdue ? 1 + Math.floor(bank() * 3) : late > MAX_LATE_EMIS ? 1 : bank() < 0.08 ? 1 : 0;
const months: DemoMonth[] = MONTHS.map((month, i) => {
const credits = round(turnover * (0.9 + bank() * 0.2) + (business ? 0 : bank() * 3000), 10);
// EMIs go out first; household or business spending is a share of what is left.
const debits = round(Math.max(0, credits - emi) * Math.min(0.97, spendRatio * (0.9 + bank() * 0.2)) + emi, 10);
const r = returns > 0 && (i >= MONTHS.length - returns || bank() < 0.2) ? 1 : 0;
returns -= r;
return {
month,
credits,
debits: overdue ? Math.max(debits, round(credits * 1.02, 10)) : debits,
avgBalance: round(c.savingsBalance * (0.7 + bank() * 0.6), 100),
returns: r
};
});
c.accounts.push({
type: business ? 'Current' : 'Savings',
accountNo: String((business ? 300000 : 200000) + c.id).padStart(9, '0'),
months
});
// Shares linked to borrowing: 2.5% of secured and 5% of unsecured loans. A few members are short.
const required = active.reduce((s, l) => s + (l.disbursed * sharePct(l.productId)) / 100, 0);
const short = required > 0 && bank() < 0.15;
c.shareHeld = MEMBERSHIP_SHARE + ceil100(short ? required * 0.5 : required);
}
const BOOK = buildBook();
const AS_ON = '19-10-2026';
const STAGES: Array<Omit<LoanStageRow, 'loans' | 'outstanding' | 'overdue'>> = [
{ stage: 'STANDARD', label: 'Standard', dpdRange: '0', group: 'PERFORMING' },
{ stage: 'SMA0', label: 'SMA-0', dpdRange: '1-30', group: 'PERFORMING' },
{ stage: 'SMA1', label: 'SMA-1', dpdRange: '31-60', group: 'PERFORMING' },
{ stage: 'SMA2', label: 'SMA-2', dpdRange: '61-90', group: 'PERFORMING' },
{ stage: 'SUBSTANDARD', label: 'Sub-standard', dpdRange: '91-455', group: 'NPA' },
{ stage: 'DOUBTFUL1', label: 'Doubtful D1', dpdRange: '456-820', group: 'NPA' },
{ stage: 'DOUBTFUL2', label: 'Doubtful D2', dpdRange: '821-1550', group: 'NPA' },
{ stage: 'DOUBTFUL3', label: 'Doubtful D3', dpdRange: 'above 1550', group: 'NPA' },
{ stage: 'LOSS', label: 'Loss / written off', dpdRange: 'written off', group: 'CLOSED' },
{ stage: 'CLOSED', label: 'Closed', dpdRange: 'repaid', group: 'CLOSED' }
];
/** RBI IRAC class of a loan on days past due. */
function stageOf(l: DemoLoan): string {
if (l.status === 'Written off') {
return 'LOSS';
}
if (l.status === 'Closed') {
return 'CLOSED';
}
const d = l.dpd;
if (d <= 0) {
return 'STANDARD';
}
if (d <= 30) {
return 'SMA0';
}
if (d <= 60) {
return 'SMA1';
}
if (d <= 90) {
return 'SMA2';
}
if (d <= 455) {
return 'SUBSTANDARD';
}
if (d <= 820) {
return 'DOUBTFUL1';
}
return d <= 1550 ? 'DOUBTFUL2' : 'DOUBTFUL3';
}
function labelOf(stage: string): string {
return STAGES.find((s) => s.stage === stage)?.label ?? stage;
}
function isNpa(stage: string): boolean {
return STAGES.find((s) => s.stage === stage)?.group === 'NPA';
}
function categoryOf(c: DemoClient): BorrowerCategory {
let cat: BorrowerCategory = 'GOOD';
for (const l of c.loans) {
const st = stageOf(l);
if (st === 'LOSS' || isNpa(st)) {
return 'BAD';
}
if (l.status === 'Active' && st !== 'STANDARD') {
cat = 'WATCH';
}
}
return cat;
}
function productName(id: number): string {
return DEMO_PRODUCTS.find((p) => p.id === id)?.name ?? String(id);
}
/** Shares the member should hold against the running loans. */
function shareRequired(c: DemoClient): number {
return c.loans
.filter((l) => l.status === 'Active')
.reduce((s, l) => s + (l.disbursed * sharePct(l.productId)) / 100, 0);
}
/** Six-month averages of the operative account. */
function statement(c: DemoClient) {
const ms = c.accounts.flatMap((a) => a.months);
const n = MONTHS.length;
const credits = ms.reduce((s, m) => s + m.credits, 0) / n;
const debits = ms.reduce((s, m) => s + m.debits, 0) / n;
return {
type: c.accounts[0]?.type ?? 'Savings',
credits,
debits,
surplus: credits - debits,
avgBalance: ms.reduce((s, m) => s + m.avgBalance, 0) / n,
returns: ms.reduce((s, m) => s + m.returns, 0)
};
}
const inr = (n: number) => '₹' + Math.round(n).toLocaleString('en-IN');
function eligibility(c: DemoClient, p: DemoProduct): ProductEligibility {
const passed: string[] = [];
const failed: string[] = [];
const notes: string[] = [];
const active = c.loans.filter((l) => l.status === 'Active');
const currentEmi = active.reduce((s, l) => s + l.emi, 0);
let limit = p.maxAmount;
const paid = c.loans.reduce((s, l) => s + l.emisPaid, 0);
const late = c.loans.reduce((s, l) => s + l.emisLate, 0);
if (late > MAX_LATE_EMIS) {
failed.push(`EMI record: ${late} of ${paid} EMIs paid late (allowed up to ${MAX_LATE_EMIS})`);
} else {
passed.push(`EMI record: ${paid - late} of ${paid} EMIs paid on time`);
}
const st = statement(c);
if (st.returns > MAX_RETURNS) {
failed.push(`${st.returns} cheque / ECS returns in the last 6 months (allowed up to ${MAX_RETURNS})`);
} else {
passed.push(`${st.returns} cheque / ECS return(s) in the last 6 months`);
}
if (st.surplus <= 0) {
failed.push(`Account spending ${inr(st.debits)} a month is more than the credits ${inr(st.credits)}`);
} else {
passed.push(
`${st.type} account: credits ${inr(st.credits)} − expenses ${inr(st.debits)} = ${inr(st.surplus)} left a month`
);
if (p.maxFoir !== undefined) {
limit = Math.min(limit, principalForEmi(st.surplus, p.annualRate, p.tenureMonths));
}
}
if (p.minAge !== undefined) {
if (c.age < p.minAge) {
failed.push(`Age ${c.age} is below the minimum ${p.minAge}`);
} else {
passed.push(`Age ${c.age} (minimum ${p.minAge})`);
}
}
if (p.maxAgeAtMaturity !== undefined) {
const atMaturity = c.age + Math.ceil(p.tenureMonths / 12);
if (atMaturity > p.maxAgeAtMaturity) {
failed.push(`Age at maturity ${atMaturity} exceeds ${p.maxAgeAtMaturity}`);
} else {
passed.push(`Age at maturity ${atMaturity} (maximum ${p.maxAgeAtMaturity})`);
}
}
if (p.minMonthlyIncome !== undefined) {
if (!c.monthlyIncome) {
failed.push(`Monthly income not recorded (minimum ${inr(p.minMonthlyIncome)})`);
} else if (c.monthlyIncome < p.minMonthlyIncome) {
failed.push(`Monthly income ${inr(c.monthlyIncome)} is below ${inr(p.minMonthlyIncome)}`);
} else {
passed.push(`Monthly income ${inr(c.monthlyIncome)} (minimum ${inr(p.minMonthlyIncome)})`);
}
}
if (p.maxLoansPerCustomer !== undefined) {
const running = active.filter((l) => l.productId === p.id).length;
if (running >= p.maxLoansPerCustomer) {
failed.push(`${running} loan(s) of this product already running (maximum ${p.maxLoansPerCustomer})`);
} else {
passed.push(`${running} loan(s) of this product running (maximum ${p.maxLoansPerCustomer})`);
}
}
if (p.maxFoir !== undefined && c.monthlyIncome) {
const room = (c.monthlyIncome * p.maxFoir) / 100 - currentEmi;
if (room <= 0) {
failed.push(`Current EMIs ${inr(currentEmi)} already use the FOIR limit of ${p.maxFoir}% of income`);
} else {
const byFoir = principalForEmi(room, p.annualRate, p.tenureMonths);
passed.push(`FOIR ${p.maxFoir}%: EMI room ${inr(room)} a month supports up to ${inr(byFoir)}`);
limit = Math.min(limit, byFoir);
}
}
if (p.ltvPct !== undefined) {
if (c.fdBalance <= 0) {
failed.push('No FD / RD to pledge');
} else {
const byDeposit = (c.fdBalance * p.ltvPct) / 100;
passed.push(`FD ${inr(c.fdBalance)} at ${p.ltvPct}% allows up to ${inr(byDeposit)}`);
limit = Math.min(limit, byDeposit);
}
}
if (p.id === 4) {
notes.push('Final amount depends on the gold valued at the branch');
}
let eligible = failed.length === 0;
if (eligible && limit < p.minAmount) {
failed.push(`Eligible amount ${inr(limit)} is below the product minimum ${inr(p.minAmount)}`);
eligible = false;
}
const amount = eligible ? round(limit, 1000) : undefined;
const pct = p.secured ? 2.5 : 5;
const shortfall = Math.max(0, shareRequired(c) - (c.shareHeld - MEMBERSHIP_SHARE));
const shareToBuy = amount ? ceil100((amount * pct) / 100 + shortfall) : undefined;
if (amount) {
notes.push(
`Shares of ${inr((amount * pct) / 100)} to be taken (${pct}% of the loan, ${p.secured ? 'secured' : 'unsecured'})`
);
if (shortfall > 0) {
notes.push(`Existing share shortfall of ${inr(shortfall)} on running loans is collected too`);
}
}
notes.push('CIBIL score not checked in this demo');
return {
productId: p.id,
productName: p.name,
eligible,
eligibleAmount: amount,
sharePct: pct,
shareToBuy,
netInHand: amount && shareToBuy !== undefined ? amount - shareToBuy : undefined,
minAmount: p.minAmount,
maxAmount: p.maxAmount,
passed,
failed,
notes
};
}
function toBorrower(c: DemoClient, detail: boolean): Borrower {
const category = categoryOf(c);
const active = c.loans.filter((l) => l.status === 'Active');
const order = STAGES.map((s) => s.stage);
const worst = active.map(stageOf).sort((a, b) => order.indexOf(b) - order.indexOf(a))[0];
const elig = category === 'GOOD' ? DEMO_PRODUCTS.map((p) => eligibility(c, p)) : [];
const loans: BorrowerLoan[] = c.loans.map((l) => ({
loanId: l.id,
accountNo: l.accountNo,
productName: productName(l.productId),
status: l.status,
stage: labelOf(stageOf(l)),
dpd: l.dpd,
emisPaid: l.emisPaid,
emisLate: l.emisLate,
disbursed: l.disbursed,
outstanding: l.outstanding,
overdue: l.overdue,
sharePct: sharePct(l.productId),
shareRequired: l.status === 'Active' ? (l.disbursed * sharePct(l.productId)) / 100 : 0
}));
const st = statement(c);
return {
clientId: c.id,
clientName: c.name,
clientAccountNo: c.accountNo,
category,
activeLoans: active.length,
closedLoans: c.loans.filter((l) => l.status === 'Closed').length,
writtenOffLoans: c.loans.filter((l) => l.status === 'Written off').length,
outstanding: active.reduce((s, l) => s + l.outstanding, 0),
maxDpd: active.reduce((m, l) => Math.max(m, l.dpd), 0),
worstStage: worst ? labelOf(worst) : undefined,
monthlyIncome: c.monthlyIncome,
age: c.age,
depositBalance: c.fdBalance + c.savingsBalance,
currentEmi: active.reduce((s, l) => s + l.emi, 0),
eligibleProducts: elig.filter((e) => e.eligible).length,
emisPaid: c.loans.reduce((s, l) => s + l.emisPaid, 0),
emisLate: c.loans.reduce((s, l) => s + l.emisLate, 0),
shareHeld: c.shareHeld,
shareRequired: MEMBERSHIP_SHARE + shareRequired(c),
accountType: st.type,
avgCredits: st.credits,
avgDebits: st.debits,
returns: st.returns,
accounts: detail ? c.accounts : undefined,
loans: detail ? loans : undefined,
eligibility: detail ? elig : undefined
};
}
export function demoSummary(): LoanEligibilitySummary {
const all = BOOK.flatMap((c) => c.loans);
const stages: LoanStageRow[] = STAGES.map((s) => {
const ls = all.filter((l) => stageOf(l) === s.stage);
return {
...s,
loans: ls.length,
outstanding: ls.reduce((a, l) => a + l.outstanding, 0),
overdue: ls.reduce((a, l) => a + l.overdue, 0)
};
});
const active = all.filter((l) => l.status === 'Active');
const npa = active.filter((l) => isNpa(stageOf(l)));
const activeOut = active.reduce((a, l) => a + l.outstanding, 0);
const npaOut = npa.reduce((a, l) => a + l.outstanding, 0);
const cats = BOOK.map(categoryOf);
const linked = BOOK.filter((c) => shareRequired(c) > 0);
const shortOf = (c: DemoClient) => Math.max(0, shareRequired(c) - (c.shareHeld - MEMBERSHIP_SHARE));
return {
shareRequired: linked.reduce((a, c) => a + shareRequired(c), 0),
shareHeld: linked.reduce((a, c) => a + c.shareHeld - MEMBERSHIP_SHARE, 0),
shareShortMembers: linked.filter((c) => shortOf(c) > 0).length,
shareShortfall: linked.reduce((a, c) => a + shortOf(c), 0),
asOnDate: AS_ON,
activeLoans: active.length,
activeOutstanding: activeOut,
performingLoans: active.length - npa.length,
performingOutstanding: activeOut - npaOut,
npaLoans: npa.length,
npaOutstanding: npaOut,
grossNpaPct: activeOut > 0 ? Math.round((npaOut / activeOut) * 10000) / 100 : 0,
closedLoans: all.filter((l) => l.status === 'Closed').length,
writtenOffLoans: all.filter((l) => l.status === 'Written off').length,
writtenOffAmount: all.filter((l) => l.status === 'Written off').reduce((a, l) => a + l.disbursed * 0.6, 0),
goodBorrowers: cats.filter((c) => c === 'GOOD').length,
watchBorrowers: cats.filter((c) => c === 'WATCH').length,
badBorrowers: cats.filter((c) => c === 'BAD').length,
stages,
products: DEMO_PRODUCTS.map((p) => {
const ls = active.filter((l) => l.productId === p.id);
const bad = ls.filter((l) => isNpa(stageOf(l)));
return {
productId: p.id,
productName: p.name,
activeLoans: ls.length,
performingLoans: ls.length - bad.length,
npaLoans: bad.length,
outstanding: ls.reduce((a, l) => a + l.outstanding, 0),
npaOutstanding: bad.reduce((a, l) => a + l.outstanding, 0)
};
}).sort((a, b) => b.outstanding - a.outstanding),
notes: [
'Demo data: the figures are illustrative, not the bank’s books.',
'Share linking to borrowing: members hold shares of 5% of unsecured and 2.5% of secured loans (UCBs keeping CRAR of 12% on a continuous basis are exempt).',
'Classification as per RBI IRAC norms on days past due (90+ days = NPA; Sub-standard up to 12 months as NPA, then Doubtful D1 / D2 / D3).',
'Eligibility uses each product’s rules: age, age at maturity, monthly income, FOIR, loans per customer and, for loans against FD, the LTV on the deposit.'
]
};
}
export function demoBorrowers(category: BorrowerCategory | '', search: string): Borrower[] {
const q = search.trim().toLowerCase();
return BOOK.filter((c) => !category || categoryOf(c) === category)
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.accountNo.includes(q))
.map((c) => toBorrower(c, false))
.sort(
(a, b) =>
b.maxDpd - a.maxDpd || b.eligibleProducts - a.eligibleProducts || a.clientName.localeCompare(b.clientName)
);
}
export function demoBorrower(clientId: number): Borrower | undefined {
const c = BOOK.find((x) => x.id === clientId);
return c ? toBorrower(c, true) : undefined;
}
@@ -6,14 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../../../shared/styles/list-layout' as *;
import { NgModule } from '@angular/core';
import { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
table {
width: 100%;
}
.pf-subtitle {
margin: 4px 0 0;
color: rgb(0 0 0 / 60%);
font-size: 0.875rem;
}
@NgModule({
imports: [LoanEligibilityRoutingModule]
})
export class LoanEligibilityModule {}
@@ -0,0 +1,154 @@
/**
* 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 { Injectable } from '@angular/core';
import { Observable, delay, of, throwError } from 'rxjs';
import { demoBorrower, demoBorrowers, demoSummary } from './loan-eligibility.mock';
export type BorrowerCategory = 'GOOD' | 'WATCH' | 'BAD';
export interface LoanStageRow {
stage: string;
label: string;
dpdRange: string;
group: 'PERFORMING' | 'NPA' | 'CLOSED';
loans: number;
outstanding: number;
overdue: number;
}
export interface LoanProductRow {
productId: number;
productName: string;
activeLoans: number;
performingLoans: number;
npaLoans: number;
outstanding: number;
npaOutstanding: number;
}
/** Loan book health as on the business date (RBI asset classes, products, borrowers). */
export interface LoanEligibilitySummary {
asOnDate: string;
activeLoans: number;
activeOutstanding: number;
performingLoans: number;
performingOutstanding: number;
npaLoans: number;
npaOutstanding: number;
grossNpaPct: number;
closedLoans: number;
writtenOffLoans: number;
writtenOffAmount: number;
/** Share linking to borrowing across members with running loans. */
shareRequired: number;
shareHeld: number;
shareShortMembers: number;
shareShortfall: number;
goodBorrowers: number;
watchBorrowers: number;
badBorrowers: number;
stages: LoanStageRow[];
products: LoanProductRow[];
notes: string[];
}
export interface BorrowerLoan {
loanId: number;
accountNo: string;
productName: string;
status: string;
stage: string;
dpd: number;
emisPaid: number;
emisLate: number;
disbursed: number;
outstanding: number;
overdue: number;
sharePct: number;
shareRequired: number;
}
export interface AccountMonth {
month: string;
credits: number;
debits: number;
avgBalance: number;
returns: number;
}
/** Savings / current account statement, month by month. */
export interface BorrowerAccount {
type: 'Savings' | 'Current';
accountNo: string;
months: AccountMonth[];
}
export interface ProductEligibility {
productId: number;
productName: string;
eligible: boolean;
eligibleAmount?: number;
sharePct: number;
/** Shares to be taken with this loan (incl. any existing shortfall). */
shareToBuy?: number;
netInHand?: number;
minAmount?: number;
maxAmount?: number;
passed: string[];
failed: string[];
notes: string[];
}
export interface Borrower {
clientId: number;
clientName: string;
clientAccountNo: string;
category: BorrowerCategory;
activeLoans: number;
closedLoans: number;
writtenOffLoans: number;
outstanding: number;
maxDpd: number;
worstStage?: string;
monthlyIncome?: number;
age?: number;
depositBalance: number;
currentEmi: number;
eligibleProducts: number;
/** Repayment record over all loans: EMIs fallen due and how many were paid late. */
emisPaid: number;
emisLate: number;
shareHeld: number;
shareRequired: number;
/** Six-month averages of the operative account. */
accountType: 'Savings' | 'Current';
avgCredits: number;
avgDebits: number;
returns: number;
accounts?: BorrowerAccount[];
loans?: BorrowerLoan[];
eligibility?: ProductEligibility[];
}
/** Loan Eligibility (AI / Intelligence – Lending & Risk): demo data, worked out on the eligibility rules. */
@Injectable({ providedIn: 'root' })
export class LoanEligibilityService {
getSummary(): Observable<LoanEligibilitySummary> {
return of(demoSummary()).pipe(delay(2800));
}
getBorrowers(category: BorrowerCategory | '', search: string): Observable<Borrower[]> {
return of(demoBorrowers(category, search)).pipe(delay(700));
}
getBorrower(clientId: number): Observable<Borrower> {
const b = demoBorrower(clientId);
return b ? of(b).pipe(delay(900)) : throwError(() => new Error('Borrower not found'));
}
}
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
@@ -1,664 +0,0 @@
/**
* 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;
}
@@ -50,8 +50,7 @@ export async function createRealLoanAccount(
const locale = settings.language.code;
const dateFormat = settings.dateFormat;
// Fineract rejects a submitted-on date later than its business date, so use that (the date shown in the header), not the PC clock.
const today = dateUtils.formatDate(settings.businessDate, dateFormat);
const today = dateUtils.formatDate(new Date(), dateFormat);
const payload = {
clientId: app.clientId,
@@ -68,8 +67,6 @@ 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,
@@ -87,7 +84,7 @@ export async function createRealLoanAccount(
})
);
const disbursed: any = await firstValueFrom(
await firstValueFrom(
loansService.loanActionButtons(loanId, 'disburse', {
actualDisbursementDate: today,
transactionAmount: app.requestedAmount,
@@ -95,16 +92,6 @@ 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 {
@@ -112,55 +99,3 @@ 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 };
}
@@ -29,7 +29,6 @@ export { BRANCHES, BranchMaster };
export type OriginationStage =
| 'APPLICATION'
| 'KYC_DOCUMENTS'
| 'DEPOSIT_SELECTION'
| 'CIC_CHECK'
| 'FIELD_INVESTIGATION'
| 'PERSONAL_DISCUSSION'
@@ -44,7 +43,6 @@ export type OriginationStage =
export const STAGE_ORDER: OriginationStage[] = [
'APPLICATION',
'KYC_DOCUMENTS',
'DEPOSIT_SELECTION',
'CIC_CHECK',
'FIELD_INVESTIGATION',
'PERSONAL_DISCUSSION',
@@ -60,7 +58,6 @@ export const STAGE_ORDER: OriginationStage[] = [
export const STAGE_LABELS: Record<OriginationStage, string> = {
APPLICATION: 'Application Received',
KYC_DOCUMENTS: 'KYC & Document Collection',
DEPOSIT_SELECTION: 'Deposit Selection & Eligibility',
CIC_CHECK: 'CIC / Credit Bureau Check',
FIELD_INVESTIGATION: 'Field Investigation (FI)',
PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
@@ -73,29 +70,6 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
DISBURSED: 'Disbursed'
};
/** The stages a given product actually goes through (a loan against a deposit has no CIC and no field investigation): Deposit Selection only for a loan against a deposit, and no
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
if (isDepositLoanCode(productCode)) {
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien), 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 !== '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);
}
@@ -162,14 +136,13 @@ export const DISCREPANCY_LABELS: Record<DiscrepancyType, string> = {
FAKE_DECLARATION: 'Fake Declaration Suspected'
};
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
export const APPROVAL_LABELS: Record<ApprovalDecision, string> = {
PENDING: 'Pending',
APPROVED: 'Approved',
REJECTED: 'Rejected',
EXCEPTION_APPROVED: 'Approved on Exception',
REVOKED: 'Returned for Rectification'
EXCEPTION_APPROVED: 'Approved on Exception'
};
export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED';
@@ -207,89 +180,7 @@ 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;
limitPct: number;
eligibleAmount: number;
depositRate: number;
spread: number;
maxTenure: number;
over85: boolean;
over85Reason: string;
thirdParty: boolean;
/** The product requires a lien on the deposit (a product can switch this off). */
lienRequired: boolean;
saved: boolean;
}
/** Lien noted on the pledged deposit(s) for this application. */
export interface LienRecord {
marked: boolean;
certificateNo: string;
markedOn: string;
amount: number;
accountNos: string[];
/** 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' | '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. */
checklist?: { label: string; ok: boolean }[];
/** What the approver ticked as checked herself/himself before approving. */
checkerChecklist?: { label: string; ok: boolean }[];
decidedBy?: string;
decidedOn?: string;
}
export interface LoanApplication {
approvalRequests?: Partial<Record<string, ApprovalRequest>>;
depositSelection?: DepositSelection;
lien?: LienRecord;
/** Database row id, assigned on save. Uploaded files hang off this (documents are stored against
* the application's entity id), so it's absent only on an application not yet persisted. */
id?: number;
@@ -426,13 +317,6 @@ 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
@@ -452,22 +336,9 @@ 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;
@@ -580,176 +451,7 @@ interface OriginationProduct {
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
shareLinkagePercent: number;
/** Loan against a deposit: the share of the deposit value that can be lent (policy 9.5.5: 85%). */
ltvPct?: number;
/** Loan against a deposit: percentage points added to the deposit's own rate. */
spread?: number;
/** Loan against a deposit: when the lien is marked, as set on the product ("disbursement" or "sanction"). */
lienStage?: string;
lienRequired?: boolean;
/** What the eligible amount is calculated on: "balance_accrued" (balance + accrued interest) or "balance". */
eligibleBasis?: string;
/** "deposit_spread" = the deposit's own rate plus the spread; anything else uses the product's own rate. */
interestBasis?: string;
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
baseRate?: number;
/** 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. */
tenureWithinMaturity?: boolean;
certificateRequired?: boolean;
certificatePrefix?: string;
/** Terms shown on the loan plan of a deposit loan. */
penalRate?: number;
penalApplicable?: boolean;
repaymentType?: string;
interestPostingFrequency?: string;
facilityType?: string;
preclosure?: string;
renewalAllowed?: boolean;
/** 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 = [
'FD',
'RD',
'DS',
'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) || depositSubTypeCodes.has(productCode));
}
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
{
code: 'PL',
@@ -884,53 +586,6 @@ interface ProductDocumentTemplate {
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
* borrower's own identity/income proof. */
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
FD: [
{
key: 'fdReceipt',
label: 'FD receipt — original, discharged on the back',
hint: 'Signed by the depositor'
},
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
{
key: 'letterOfContinuity',
label: 'Letter of continuity',
hint: 'Liability and security continue until the loan is repaid'
}
],
RD: [
{ key: 'rdPassbook', label: 'RD passbook', hint: 'Kept by the bank' },
{
key: 'adjustDeclaration',
label: 'Declaration: bank may adjust the loan on RD maturity',
hint: 'Bank may adjust the loan from the deposit on maturity'
},
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
{
key: 'letterOfContinuity',
label: 'Letter of continuity',
hint: 'Liability and security continue until the loan is repaid'
}
],
DS: [
{ key: 'ddsPassbook', label: 'DDS passbook', hint: 'Kept by the bank' },
{
key: 'adjustDeclaration',
label: 'Declaration: bank may adjust the loan on DDS maturity',
hint: 'Bank may adjust the loan from the deposit on maturity'
},
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
{
key: 'letterOfContinuity',
label: 'Letter of continuity',
hint: 'Liability and security continue until the loan is repaid'
}
],
LI: [
{ key: 'licPolicyBond', label: 'Original LIC policy bond', hint: 'Kept by the bank' },
{ key: 'licAssignment', label: 'Assignment deed in favour of the bank', hint: 'Registered with the LIC branch' },
{ key: 'licSurrenderLetter', label: 'LIC surrender value letter', hint: 'Not older than 1 month' },
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' }
],
HL: [
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
@@ -981,100 +636,15 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
}
/**
* Makes a product saved in Product Creation usable by the application screens: the detail page, the eligibility
* check and the Fineract bridge all look a product up by its code in ORIGINATION_PRODUCTS, so a saved product is
* registered there under its Short Name (replacing an earlier registration of the same code).
*/
export function registerOriginationProduct(p: any): OriginationProduct {
const ext = p?.advanceExtensions ?? {};
const 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 = {
code: String(p.shortName),
name: String(p.name).replace(/\s+/g, ' ').trim(),
secured: !isDepositLoanCode(String(p.shortName)) && (ext.productTypeGroup === 'advances' || !!ext.securityType),
minAmt: Number(p?.minPrincipal ?? p?.principal ?? 0),
maxAmt: Number(p?.maxPrincipal ?? p?.principal ?? 0),
minRate: Number(p?.minInterestRatePerPeriod ?? rate),
maxRate: Number(p?.maxInterestRatePerPeriod ?? rate),
minTenure: Number(inMonths ? (p?.minNumberOfRepayments ?? p?.numberOfRepayments ?? 1) : 1),
maxTenure: Number(inMonths ? (p?.maxNumberOfRepayments ?? p?.numberOfRepayments ?? 360) : 360),
incomeMultiplier: 20,
shareLinkagePercent: 5,
ltvPct: Number(ext.ltv ?? 85),
spread: Number(ext.spread ?? 0),
lienStage: String(ext.lienMarkingStage ?? 'disbursement'),
lienRequired: ext.lienRequired !== false,
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
baseRate: rate,
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,
certificateRequired: ext.lienCertificateRequired !== false && ext.lienCertificateRequired !== 'NO',
certificatePrefix: ext.lienCertificatePrefix ? String(ext.lienCertificatePrefix) : undefined,
penalRate: Number(ext.penalRate ?? 0),
penalApplicable: ext.penalApplicable === true,
repaymentType: String(ext.repaymentType ?? 'bullet'),
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
facilityType: String(ext.facilityType ?? 'demand'),
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
renewalAllowed: ext.renewalAllowed === true,
prioritySector: String(ext.prioritySector ?? '').toLowerCase() === 'yes'
};
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
if (at >= 0) {
ORIGINATION_PRODUCTS[at] = entry;
} else {
ORIGINATION_PRODUCTS.push(entry);
}
return entry;
}
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
* as the product-specific documents above, since a specific matter can always need one more
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
export function buildLegalDocuments(secured: boolean, depositLoan = false): ProductDocumentEntry[] {
if (depositLoan) {
// The DP Note, letter of continuity, deposit receipt and letter of lien were collected earlier (KYC & Documents and
// Lien Marking); only what is new at the legal stage is asked here. No mortgage, title or legal opinion: the security is a deposit.
return [
{
key: 'stampDutyReceipt',
label: 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)'
}
].map((doc) => ({ ...doc, custom: false, submitted: false }));
}
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
const templates: ProductDocumentTemplate[] = [
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
{ key: 'feeReceipt', label: 'Processing Fee Receipt' }
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
];
if (secured) {
templates.push(
@@ -1222,11 +792,7 @@ export function progressPct(app: LoanApplication): number {
if (app.overallStatus === 'DISBURSED') {
return 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);
return round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
}
export function stageBadgeClass(app: LoanApplication): string {
@@ -1970,19 +1536,6 @@ 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';
@@ -2286,7 +1839,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
const actionBy =
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
return {
applicationNo: `LN${input.branchCode.replace(/^(OFF|BR)/, '').padStart(2, '0')}${input.productCode}${String(seq).padStart(6, '0')}`,
applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`,
applicationDate: todayDDMMYYYY(),
applicantName: input.applicantName,
branchCode: input.branchCode,
@@ -2358,11 +1911,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
personalDiscussionDate: '—',
declarationTaken: false,
declarationDate: '—',
// 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: [],
guarantorRequired: false,
guarantorClientId: null,
guarantorName: '—',
guarantorHasShareAccount: false,
@@ -2383,7 +1932,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
legalStampDone: false,
fileFilingDone: false,
agreementSigningSlot: '—',
legalDocuments: buildLegalDocuments(input.secured, isDepositLoanCode(input.productCode)),
legalDocuments: buildLegalDocuments(input.secured),
customerSigningDate: '—',
chequeReceived: false,
achSigned: false,
@@ -430,18 +430,14 @@
// 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(200px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(240px, 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;
@@ -869,16 +865,6 @@
}
}
.loi-legal-doc-thumb-remove.loi-bank-remove {
height: 34px;
padding: 0 14px;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fef2f2;
color: #dc2626;
font-weight: 700;
}
// CIC Credit Bureau Hero Card in Stage detail
.loi-cic-hero-card {
display: flex;
@@ -992,212 +978,3 @@
font-weight: 600;
}
}
.loi-optional-tag {
font-weight: 600;
text-transform: none;
letter-spacing: 0;
color: #0369a1;
}
.loi-deposit-note {
margin-bottom: 14px;
padding: 10px 14px;
border: 1px solid #bae6fd;
border-radius: 8px;
background: #f0f9ff;
color: #075985;
font-size: 13px;
line-height: 1.5;
}
.loi-dep-wrap {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.loi-dep-table {
width: 100%;
border-collapse: collapse;
th {
padding: 10px 12px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 10px 12px;
font-size: 13.5px;
border-bottom: 1px solid #f1f5f9;
}
.r {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr.loi-dep-selected td {
background: #eef2ff;
}
tr.loi-dep-disabled td {
color: #94a3b8;
}
}
.loi-dep-calc {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
margin: 12px 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.loi-dep-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13.5px;
color: #334155;
b {
font-variant-numeric: tabular-nums;
color: #0f172a;
}
}
.loi-dep-total {
padding-top: 10px;
border-top: 1px dashed #cbd5e1;
font-size: 15px;
b {
color: #15803d;
font-size: 16px;
}
}
.loi-dep-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 10px;
@media (width <= 700px) {
grid-template-columns: 1fr;
}
}
.loi-deposit-ok {
margin: 6px 0;
padding: 10px 14px;
border: 1px solid #bbf7d0;
border-radius: 8px;
background: #f0fdf4;
color: #15803d;
font-size: 13px;
}
.loi-lien-cert {
margin: 12px 0;
padding: 14px 18px;
border: 2px dashed #94a3b8;
border-radius: 10px;
background: #fffef7;
font-size: 13px;
line-height: 1.6;
color: #334155;
}
.pg-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.loi-approval-wait {
margin: 10px 0;
padding: 12px 14px;
border: 1px solid #fde68a;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
font-size: 13px;
line-height: 1.5;
}
.loi-verified {
margin: 8px 0 12px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 13px;
color: #334155;
ul {
margin: 6px 0 0;
padding-left: 0;
list-style: none;
}
li {
padding: 2px 0;
color: #15803d;
}
.loi-verified--no {
color: #b91c1c;
}
}
.loi-locked {
pointer-events: none;
opacity: 0.75;
}
.loi-checker {
margin: 8px 0 12px;
padding: 12px 14px;
border: 1px solid #c7d2fe;
border-radius: 8px;
background: #eef2ff;
font-size: 13px;
color: #1e1b4b;
}
/* A stage sent for approval: files can still be opened (View), nothing can be added, replaced or removed. */
.loi-docs-locked {
label.pg-btn,
.loi-legal-doc-thumb-remove,
.loi-doc-thumb-remove,
.loi-doc-remove,
.loi-custom-doc-input,
.loan-action-row {
display: none !important;
}
}
.loi-view-link {
margin-left: 8px;
padding: 2px 10px;
border: 1px solid #c7d2fe;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
@@ -8,38 +8,8 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
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 {
return `OFF${officeId}`;
}
import { Observable } from 'rxjs';
import { LoanApplication } from './loan-origination-data';
/**
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
@@ -59,171 +29,6 @@ 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(
map((offices) =>
(offices ?? [])
.slice()
.sort((a, b) => (a.hierarchy ?? '').localeCompare(b.hierarchy ?? '') || a.id - b.id)
.map((office) => ({ code: officeBranchCode(office.id), name: office.name }))
)
);
}
list(): Observable<LoanApplication[]> {
return this.http.get<LoanApplication[]>(this.baseUrl);
}
@@ -262,10 +67,6 @@ export class LoanOriginationService {
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
}
completeDepositSelection(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'deposit-selection-complete', { actionBy, remarks });
}
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
}
@@ -317,37 +118,17 @@ export class LoanOriginationService {
applicationNo: string,
field: string,
body: {
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
approverName: string;
approverRole: string;
remarks?: string;
resolutionNo?: string;
resolutionDate?: string;
checkerChecklist?: { label: string; ok: boolean }[];
}
): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}`, body);
}
submitApproval(
applicationNo: string,
field: string,
body: {
decision: 'APPROVED' | 'EXCEPTION_APPROVED';
approverRole: string;
remarks?: string;
resolutionNo?: string;
resolutionDate?: string;
checklist?: { label: string; ok: boolean }[];
}
): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}/submit`, body);
}
withdrawApproval(applicationNo: string, field: string): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}/withdraw`, {});
}
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'signing', { actionBy });
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,10 +13,9 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClientsService } from '../../clients/clients.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
import { LoanOriginationService } from '../loan-origination.service';
import {
BRANCHES,
BranchMaster,
branchTargetAmount,
Channel,
@@ -26,10 +25,10 @@ import {
generateSeedApplications,
LoanApplication,
ORIGINATION_PRODUCTS,
registerOriginationProduct,
OriginationStage,
OverallStatus,
PRODUCT_TARGET_PCT,
PRODUCT_TYPE_LABELS,
ProductType,
SHARE_LINKAGE_SLABS,
STAGE_LABELS,
@@ -37,7 +36,9 @@ import {
STATUS_LABELS,
TARGET_STATUS_LABELS,
TargetStatus,
allowedProductTypes,
createApplication,
defaultProductType,
progressPct,
stageBadgeClass,
targetStatus,
@@ -109,8 +110,7 @@ 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-pipeline.component.scss'
'../loan-origination-shared.scss'
],
standalone: true,
imports: [
@@ -124,12 +124,7 @@ export class LoanPipelineComponent implements OnInit {
readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly channelLabels = CHANNEL_LABELS;
/** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
branches: BranchMaster[] = [];
/** Active products saved in Product Creation -- the only products an application can be taken for. */
dbProducts: any[] = [];
loadingProducts = true;
readonly productGroups = PRODUCT_TYPE_GROUPS;
readonly branches = BRANCHES;
readonly products = ORIGINATION_PRODUCTS;
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
asOnDate = todayDDMMYYYY();
@@ -157,63 +152,26 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW' as CustomerType,
applicantName: '',
clientId: null as number | null,
branchCode: '',
productGroup: '',
productSubType: '',
productCode: '',
productType: 'TERM_LOAN' as ProductType,
branchCode: this.branches[0].code,
productCode: this.products[0].code,
productType: defaultProductType(this.products[0].code),
requestedAmount: null as number | null,
tenureMonths: null as number | null
};
/** Sub types of the chosen Product Type that actually have an active product saved. */
get subTypeOptions() {
const group = this.newApp.productGroup;
return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
this.dbProducts.some(
(p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
)
);
readonly productTypeLabels = PRODUCT_TYPE_LABELS;
/** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
* are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
get newAppProductTypeOptions(): ProductType[] {
return allowedProductTypes(this.newApp.productCode);
}
/** The products saved in the database for the chosen Product Type and Sub Type. */
get productOptions(): any[] {
return this.dbProducts.filter(
(p) =>
p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
);
}
/** Only the tenures the chosen product allows (its min to max, in months). */
get tenureOptions(): number[] {
const p = this.selectedProduct;
if (!p) {
return [];
}
return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
}
get selectedProduct() {
return this.products.find((p) => p.code === this.newApp.productCode) || null;
}
onProductGroupChange(): void {
this.newApp.productSubType = '';
this.newApp.productCode = '';
}
onSubTypeChange(): void {
const options = this.productOptions;
this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
this.onProductChange();
}
/** Advances have no EMI (revolving limit); Loans are EMI term loans. */
/** Switching Product can change which Product Types are even valid -- reset to that product's
* default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
* on a product that doesn't offer it. */
onProductChange(): void {
this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
const product = this.selectedProduct;
this.newApp.tenureMonths = product ? product.minTenure : null;
this.newApp.productType = defaultProductType(this.newApp.productCode);
}
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
@@ -231,7 +189,6 @@ export class LoanPipelineComponent implements OnInit {
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly originationService = inject(LoanOriginationService);
private readonly catalog = inject(LoanProductCatalogService);
/** The live book, loaded from the database. Every count, report and filter on this screen derives
* from this one array, so the list can never disagree with what an application actually says. */
@@ -264,27 +221,17 @@ export class LoanPipelineComponent implements OnInit {
const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
// The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
// the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
this.newApp.branchCode = this.ownBranchCode || '';
this.catalog.activeProducts().subscribe((products) => {
this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
this.dbProducts.forEach((p) => registerOriginationProduct(p));
this.loadingProducts = false;
});
this.originationService.listBranches().subscribe({
next: (offices) => {
if (offices.length > 0) {
this.branches = offices;
}
},
error: () => {
/* keep the user's own office as the only choice */
}
});
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
this.ownBranchCode =
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
// A branch-level login can only originate applications for its own branch — locked here,
// not just hidden, since the New Application form posts whatever branchCode is bound.
this.newApp.branchCode = this.branchFilter;
}
this.loadApplications();
}
@@ -363,42 +310,6 @@ export class LoanPipelineComponent implements OnInit {
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
}
private get currentUserName(): string {
return (this.auth.getCredentials()?.username ?? '').toLowerCase();
}
/** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
get awaitingApproval() {
const rows: {
app: LoanApplication;
stage: string;
sentBy: string;
sentOn: string;
mine: boolean;
exception: boolean;
}[] = [];
this.applications.forEach((app) => {
Object.entries(app.approvalRequests ?? {}).forEach(
([
,
request
]) => {
if (request && request.status === 'PENDING') {
rows.push({
app,
stage: STAGE_LABELS[app.currentStage],
sentBy: request.submittedBy,
sentOn: request.submittedOn,
mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
exception: request.requestedDecision === 'EXCEPTION_APPROVED'
});
}
}
);
});
return rows;
}
get filteredApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => {
if (this.channelFilter && a.channel !== this.channelFilter) {
@@ -632,7 +543,7 @@ export class LoanPipelineComponent implements OnInit {
// -------------------- New Application intake --------------------
productOf(code: string) {
return this.products.find((p) => p.code === code);
return this.products.find((p) => p.code === code) || this.products[0];
}
/** Switching Customer Type away from "Existing" drops any client picked under the old mode --
@@ -697,27 +608,8 @@ export class LoanPipelineComponent implements OnInit {
if (!this.isHeadOffice && this.ownBranchCode) {
this.newApp.branchCode = this.ownBranchCode;
}
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) ||
this.branches[0] || { code: this.newApp.branchCode, name: this.currentOfficeName };
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
const product = this.productOf(this.newApp.productCode);
if (!product) {
this.toast('Select the Product Type, Sub Type and Product.');
return;
}
if (
this.newApp.requestedAmount &&
(this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
) {
this.toast(
`Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
);
return;
}
const tenure = this.newApp.tenureMonths || product.minTenure;
if (tenure < product.minTenure || tenure > product.maxTenure) {
this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
return;
}
const seq = this.applications.length + 20000;
const app = createApplication(
{
@@ -752,11 +644,9 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW',
applicantName: '',
clientId: null,
branchCode: this.ownBranchCode || this.branches[0]?.code || '',
productGroup: '',
productSubType: '',
productCode: '',
productType: 'TERM_LOAN',
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
productCode: this.products[0].code,
productType: defaultProductType(this.products[0].code),
requestedAmount: null,
tenureMonths: null
};
@@ -6,53 +6,13 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<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>
}
<div class="container">
<mat-stepper
orientation="horizontal"
orientation="vertical"
[linear]="false"
class="cla-stepper"
labelPosition="end"
class="mat-elevation-z8"
labelPosition="bottom"
#loansAccountStepper
(selectionChange)="refreshLosSections()"
>
<ng-template matStepperIcon="number">
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
@@ -75,10 +35,7 @@
</ng-template>
<mat-step [stepControl]="loansAccountDetailsForm">
<ng-template matStepLabel
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon
>{{ 'labels.inputs.DETAILS' | translate }}</ng-template
>
<ng-template matStepLabel>{{ 'labels.inputs.DETAILS' | translate }}</ng-template>
<mifosx-loans-account-details-step
[loansAccountTemplate]="loansAccountTemplate"
@@ -89,9 +46,7 @@
</mat-step>
<mat-step [stepControl]="loansAccountTermsForm">
<ng-template matStepLabel
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.TERMS' | translate }}</ng-template
>
<ng-template matStepLabel>{{ 'labels.inputs.TERMS' | translate }}</ng-template>
<mifosx-loans-account-terms-step
#loanAccountTerms
@@ -100,103 +55,12 @@
[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
><fa-icon icon="coins" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.CHARGES' | translate }}</ng-template
>
<ng-template matStepLabel>{{ 'labels.inputs.CHARGES' | translate }}</ng-template>
<mifosx-loans-account-charges-step
#loanAccountCharges
@@ -204,17 +68,13 @@
[loansAccountTemplate]="loansAccountTemplate"
[loansAccountFormValid]="loansAccountFormValid"
[loansSavingsAccountLinked]="loansSavingsAccountLinked"
[viewOnly]="viewMode"
>
</mifosx-loans-account-charges-step>
</mat-step>
@if (loansAccountFormValid && !losAdvance) {
@if (loansAccountFormValid) {
<mat-step state="repayment" completed>
<ng-template matStepLabel
><fa-icon icon="university" class="cla-tab__icon"></fa-icon
>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template
>
<ng-template matStepLabel>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template>
<mifosx-loans-account-schedule-step
#loanAccountSchedule
[loansAccountTemplate]="loansAccountTemplate"
@@ -228,25 +88,18 @@
@for (datatable of datatables; track datatable) {
<mat-step>
<ng-template matStepLabel
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ datatable.registeredTableName }}</ng-template
>
<ng-template matStepLabel>{{ 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
><fa-icon icon="eye" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template
>
<ng-template matStepLabel>{{ '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,190 +5,3 @@
* 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,16 +7,7 @@
*/
/** Angular Imports */
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 { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Services */
@@ -29,14 +20,7 @@ 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,
MatStepperNext,
MatStepperPrevious
} from '@angular/material/stepper';
import { MatStepper, MatStepperIcon, MatStep, MatStepLabel } 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';
@@ -56,90 +40,25 @@ 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 implements AfterViewChecked {
export class CreateLoansAccountComponent {
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 */
@@ -251,138 +170,6 @@ export class CreateLoansAccountComponent implements AfterViewChecked {
* 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(
@@ -392,10 +179,6 @@ export class CreateLoansAccountComponent implements AfterViewChecked {
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[] = [];
@@ -405,84 +188,15 @@ export class CreateLoansAccountComponent implements AfterViewChecked {
payload['datatables'] = datatables;
}
// 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))
);
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId,
'general'
],
{ relativeTo: this.route }
);
});
}
}
@@ -6,26 +6,24 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<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="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="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"
@@ -104,13 +102,9 @@
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let charge">
@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>
}
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
@@ -42,19 +42,3 @@ 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,9 +147,6 @@ 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 =
@@ -213,16 +210,9 @@ 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,
addedAtCreation: true
chargeId: charge.value.id || charge.value.chargeId
};
this.chargesDataSource = this.chargesDataSource.concat([newCharge]);
charge.value = '';
@@ -366,20 +356,12 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
get loansAccountCharges() {
const uniqueCharges = this.getUniqueCharges(this.chargesDataSource);
return {
charges: uniqueCharges.map(({ addedAtCreation, ...charge }: any) => ({
charges: uniqueCharges.map((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,218 +7,35 @@
-->
<form [formGroup]="loansAccountDetailsForm">
@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>
<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>
@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>
}
</div>
}
} @else {
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
}
</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>
@if (loanProductSelected && !losMode) {
@if (loanProductSelected) {
<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,55 +30,3 @@ 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,12 +35,6 @@ 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
@@ -70,96 +64,6 @@ 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 = '';
@@ -231,22 +135,7 @@ 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;
@@ -275,279 +164,6 @@ 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
};
});
}
/**
@@ -574,7 +190,6 @@ 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;
@@ -1,51 +0,0 @@
<!--
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>
@@ -1,32 +0,0 @@
/**
* 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;
}
}
@@ -1,119 +0,0 @@
/**
* 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 || '—' };
});
});
}
}
@@ -1,171 +0,0 @@
<!--
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>
@@ -1,64 +0,0 @@
/**
* 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;
}
@@ -1,493 +0,0 @@
/**
* 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;
}
}
@@ -1,80 +0,0 @@
<!--
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>
@@ -1,24 +0,0 @@
/**
* 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;
}

Some files were not shown because too many files have changed in this diff Show More