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
niteshandClaude Opus 5.5 55f5c1cf32 feat(ui): longer startup splash and updated loading animation
The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%)
instead of 2, and the loading animation is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:24 +05:30
niteshandClaude Opus 5.5 6507e68203 feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD
- A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly):
  its DR button is disabled, and Save refuses any FD row left on DR (for example by a
  Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature
  closure; the clerk's "Close this account" flow is the one exception.
- Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from
  the customer's own Savings or Current account. The server enforces the funding rules
  (first and only funding, exactly the principal, the source account's owner, freeze
  status and available balance).

Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:03 +05:30
143 changed files with 24807 additions and 2752 deletions
+12 -1
View File
@@ -10,7 +10,8 @@
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable } from 'rxjs';
@@ -198,6 +199,16 @@ export class AccountingService {
return this.http.get('/journalentries/ledger', { params: httpParams });
}
/**
* The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches.
* An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup.
*/
getInterBranchTransfer(transactionId: string): Observable<any> {
return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/**
* @param {string} transactionId Transaction ID to be reverted.
* @param {string} comments Any comments.
@@ -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 });
}
+26
View File
@@ -142,6 +142,32 @@ export class AmlService {
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
}
/**
* Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above.
* They show on the FIU screen as soon as they are posted; generating the month is what files them.
*/
getCtrPendingLines(): Observable<any[]> {
return this.http.get<any[]>('/aml/ctr-reports/pending-lines');
}
/**
* How much came into and went out of each current / Cash Credit account over the last `days` days, with the
* customer's declared turnover and the sanctioned limit alongside.
*/
getCcTracking(days = 30): Observable<any> {
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,
@@ -19,6 +19,7 @@ import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { AuthenticationService } from '../../core/authentication/authentication.service';
interface Denom {
id: number;
@@ -54,6 +55,7 @@ export class CashTransferComponent implements OnInit {
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
private authenticationService = inject(AuthenticationService);
notes: Denom[] = [];
coins: Denom[] = [];
@@ -139,7 +141,14 @@ export class CashTransferComponent implements OnInit {
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
// The vault and its cash are physical, so a branch moves cash only from its own tellers. The
// backend lists every teller below the user's office (Head Office sees all branches), which is
// right for reports but wrong here.
const myOfficeId = this.authenticationService.getCredentials()?.officeId;
const own = (list || []).filter(
(t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId)
);
this.tellers = own.map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
@@ -132,12 +132,12 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.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 branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
@for (b of branchSelectOptions; track b.id) {
<option [ngValue]="b.id">{{ b.name }}</option>
}
</select>
</div>
@@ -184,7 +184,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -267,7 +267,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="isCreditOnly(row)"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -722,7 +722,24 @@
} @else {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
<span class="te-label">Branch</span
><span class="te-value">
@if (isOwnBranchAccount !== null) {
<span
class="te-branch-chip"
[class.te-branch-chip--mine]="isOwnBranchAccount"
[class.te-branch-chip--other]="!isOwnBranchAccount"
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
>
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
@if (!isOwnBranchAccount) {
<span class="te-branch-chip__tag">Other branch</span>
}
</span>
} @else {
{{ officeName }}
}
</span>
</div>
<div class="te-field">
<span class="te-label">A/c No.</span
@@ -1201,7 +1218,24 @@
: '—')
}}</span
>
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
<span class="te-acs-kv"
><b>Branch</b>
@if (isOwnBranchAccount !== null) {
<span
class="te-branch-chip"
[class.te-branch-chip--mine]="isOwnBranchAccount"
[class.te-branch-chip--other]="!isOwnBranchAccount"
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
>
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
@if (!isOwnBranchAccount) {
<span class="te-branch-chip__tag">Other branch</span>
}
</span>
} @else {
{{ officeName }}
}
</span>
<span class="te-acs-kv"
><b>Product</b>
{{
@@ -1592,12 +1626,12 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.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 branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
@for (b of branchSelectOptions; track b.id) {
<option [ngValue]="b.id">{{ b.name }}</option>
}
</select>
</div>
@@ -1644,7 +1678,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1715,7 +1749,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="isCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -3462,3 +3462,39 @@ select.te-cell {
box-shadow: none;
}
}
/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */
.te-branch-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
&--mine {
background: #dcfce7;
border-color: #4ade80;
color: #166534;
}
&--other {
background: #fca5a5;
border-color: #f87171;
color: #7f1d1d;
}
}
.te-branch-chip__tag {
padding: 0 6px;
border-radius: 8px;
background: rgb(255 255 255 / 80%);
color: #7f1d1d;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@@ -10,7 +10,8 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@@ -1879,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();
@@ -2344,6 +2346,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
);
}
/**
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
* exception is the clerk's own "Close this account" flow, which raises a closure request.
*/
isFdCreditOnly(row: TxnRow): boolean {
return row.acTyp === 'FD' && !this.pendingClosure;
}
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
isCreditOnly(row: TxnRow): boolean {
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
@@ -2696,6 +2712,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
branchNameFor(branchId: number): string {
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}`;
}
@@ -4314,6 +4332,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return false;
}
this.accountDetails = acc;
this.resolveAccountBranch(acc, acTyp, token, this.selectedRow);
this.jointHolders = acc.jointHolders ?? [];
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
this.glParent = null;
@@ -4930,6 +4949,98 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
];
}
/**
* Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for
* another branch's, null while either branch is not known (the branch is then shown plain, uncoloured).
*/
get isOwnBranchAccount(): boolean | null {
const mine = this.authService.getCredentials()?.officeId;
const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId;
if (mine == null || accountOffice == null) return null;
return Number(mine) === Number(accountOffice);
}
/** 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 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);
}
return merged;
}
/** 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
* names each account's branch, so it is read from there. A current account's own response already carries it.
*/
private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void {
if (acc?.officeId != null && acc?.officeName) {
this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row);
return;
}
const type = (acTyp ?? '').toUpperCase();
if (!acc?.id || !acc?.clientId || ![
'SD',
'FD',
'RD',
'CA'
].includes(type)) return;
this.http
.get<any>(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
.pipe(catchError(() => of(null)))
.subscribe((res) => {
// stale: another account was picked while this was loading
if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return;
const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? [];
const match = list.find((a: any) => a.id === acc.id);
if (match?.officeId != null && match?.officeName) {
this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row);
}
});
}
private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void {
this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name };
// /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's
// branch must still be selectable, under its real name
if (!this.seenBranches.some((b) => b.id === branch.id)) {
this.seenBranches = [
...this.seenBranches,
branch
];
}
if (row) row.br = branch.id;
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
@@ -5272,6 +5383,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
if (fdDebitRows.length > 0) {
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
'An FD can only be credited — once, with exactly its principal.'
]);
return;
}
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
@@ -5349,7 +5474,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
// (first and only funding, exactly the principal, the source account's owner and available balance).
const allowedTypes = new Set([
'SD',
'CA',
@@ -5357,13 +5484,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
const invalidAccounts = [
...drRows,
...crRows
...crRows.filter((r) => r.acTyp !== 'FD')
].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
@@ -102,6 +102,27 @@
</div>
}
<!-- Money moving between branches: the approver must see it before approving -->
@if (isInterBranch) {
<div class="dri-interbranch">
<mat-icon>alt_route</mat-icon>
<div>
<div class="dri-interbranch__title">Inter-branch transfer</div>
<div class="dri-interbranch__text">
@if (branchNames(drEntries) && branchNames(crEntries)) {
Money moves from <strong>{{ branchNames(drEntries) }}</strong> to
<strong>{{ branchNames(crEntries) }}</strong
>.
} @else {
These accounts are held at different branches: <strong>{{ branchNames(creditEntries) }}</strong
>.
}
Each account is posted in its own branch's books. Check the accounts before approving.
</div>
</div>
</div>
}
<!-- Bank Transfer summary card (replaces GL card) -->
@if (isBankTransferMode) {
<div class="dri-card">
@@ -154,6 +175,7 @@
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th>Branch</th>
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
</tr>
</thead>
@@ -166,11 +188,16 @@
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
</td>
<td>
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
entry.officeName || '—'
}}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
<td colspan="5" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
</tr>
</tbody>
@@ -192,6 +219,7 @@
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
@@ -204,11 +232,16 @@
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
</td>
<td>
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
entry.officeName || '—'
}}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td colspan="5" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
</tr>
</tbody>
@@ -228,6 +261,7 @@
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
@@ -240,11 +274,16 @@
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
</td>
<td>
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
entry.officeName || '—'
}}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td colspan="5" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
</tr>
</tbody>
@@ -534,3 +534,54 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
font-family: 'Courier New', monospace;
letter-spacing: 0.02em;
}
/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */
.dri-branch-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
font-size: 11px;
font-weight: 700;
color: #475569;
&--cross {
background: #fffbeb;
border-color: #fcd34d;
color: #92400e;
}
}
/* Shown above the summary when the accounts are at different branches. */
.dri-interbranch {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 0 12px;
padding: 12px 16px;
border: 1px solid #fcd34d;
border-left: 4px solid #f59e0b;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
mat-icon {
flex-shrink: 0;
color: #d97706;
}
&__title {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
}
&__text {
margin-top: 2px;
font-size: 13px;
line-height: 1.5;
}
}
@@ -25,6 +25,9 @@ interface CreditEntry {
accountType: string;
amount: number;
dbCr?: string;
/** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */
officeId?: number | null;
officeName?: string | null;
}
@Component({
@@ -167,6 +170,19 @@ export class DisbursementReviewComponent implements OnInit {
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
}
/**
* True when the accounts in this request are held at more than one branch, so money moves between branches. The
* approver has to see that before approving: it is booked in each branch's own books.
*/
get isInterBranch(): boolean {
return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1;
}
/** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */
branchNames(entries: CreditEntry[]): string {
return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', ');
}
totalCreditAmount(): number {
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
@@ -930,6 +930,10 @@
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
) {
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
} @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) {
<mat-error
>This mobile number already belongs to another customer. Please enter a different number.</mat-error
>
}
</mat-form-field>
@@ -940,7 +944,11 @@
color="primary"
type="button"
class="verify-btn"
[disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10"
[disabled]="
readOnly ||
createClientForm.get('mobileNo')?.value?.length !== 10 ||
createClientForm.get('mobileNo')?.hasError('contactTaken')
"
(click)="verifyWithDIP()"
>
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
@@ -1008,6 +1016,8 @@
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
) {
<mat-error>Email is <strong>required</strong></mat-error>
} @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) {
<mat-error>This email already belongs to another customer. Please enter a different email.</mat-error>
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
<mat-error>Enter a valid email (e.g. name&#64;example.com)</mat-error>
}
@@ -27,15 +27,16 @@ import {
UntypedFormControl,
ReactiveFormsModule,
ValidatorFn,
AsyncValidatorFn,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { Subject, Subscription, merge, of, timer } from 'rxjs';
import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { toProperNameCase } from 'app/clients/utils/name-case.util';
import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */
@@ -936,6 +937,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
};
}
/**
* A mobile number or email that already belongs to another customer. This is a different person using a contact
* that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one.
* Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what
* debounces it. The client being edited is excluded so its own number is not flagged.
*/
private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn {
return (control: AbstractControl) => {
const value = (control.value ?? '').toString().trim();
if (!value) {
return of(null);
}
return timer(400).pipe(
switchMap(() =>
this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null })
),
map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)),
// A failed lookup must not block the form: the server still refuses a duplicate when the client is saved.
catchError(() => of(null))
);
};
}
/**
* Creates the client form.
*/
@@ -969,7 +993,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.required,
Validators.pattern(/^[6-9]\d{9}$/),
Validators.maxLength(10)
]
],
this.contactTakenValidator('mobileNo')
],
alternateMobileNo: [
'',
@@ -986,7 +1011,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
Validators.maxLength(100)
]
],
this.contactTakenValidator('emailAddress')
],
dateOfBirth: [
'',
@@ -1456,7 +1482,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const control = this.createClientForm.get(controlName);
if (control && control.value) {
const originalValue = control.value;
const capitalized = toProperNameCase(originalValue);
// An entity's full name keeps its typed spelling (matched against the PAN name); a person's
// name parts are title-cased, the same as the Full Name line shows them.
const capitalized =
controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue);
if (originalValue !== capitalized) {
control.setValue(capitalized);
}
@@ -1925,12 +1954,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/** Returns capitalized full name preview from first + middle + last */
get fullNamePreview(): string {
const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '');
const salutationId = this.createClientForm?.get('salutationId')?.value;
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
const first = capitalize(this.createClientForm?.get('firstname')?.value || '');
const middle = capitalize(this.createClientForm?.get('middlename')?.value || '');
const last = capitalize(this.createClientForm?.get('lastname')?.value || '');
const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value);
const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value);
const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value);
const full = [
first,
middle,
@@ -2360,6 +2388,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (generalDetails.fullname) {
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
}
for (const part of [
'firstname',
'middlename',
'lastname'
]) {
if (generalDetails[part]) {
generalDetails[part] = toPersonNameCase(generalDetails[part]);
}
}
if (generalDetails.submittedOnDate instanceof Date) {
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
@@ -0,0 +1,211 @@
<!--
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/.
-->
<section class="identity-check" [class.identity-check--blocking]="status === 'matched' || status === 'conflict'">
<header class="identity-check__header">
<mat-icon class="identity-check__icon">fingerprint</mat-icon>
<div>
<h3 class="identity-check__title">Existing customer check</h3>
<p class="identity-check__hint">
@if (isEntity) {
Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a
new one is not created.
} @else {
Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not
created. Nothing found? What you enter here is filled into the KYC step for you.
}
</p>
</div>
</header>
@if (form) {
<form [formGroup]="form" class="identity-check__fields">
@if (isEntity) {
<mat-form-field appearance="outline" class="identity-check__field">
<mat-label>GSTIN</mat-label>
<input
matInput
formControlName="gstin"
class="identity-check__upper"
maxlength="15"
mifosxRestrictInput="gstin"
[errorStateMatcher]="errorMatcher"
placeholder="e.g. 22AAAAA0000A1Z5"
(input)="capitalise('gstin', $event)"
/>
@if (form.get('gstin')?.hasError('pattern')) {
<mat-error>Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5</mat-error>
}
</mat-form-field>
}
<mat-form-field appearance="outline" class="identity-check__field">
<mat-label>{{ isEntity ? 'Company PAN' : 'PAN' }}</mat-label>
<input
matInput
formControlName="pan"
class="identity-check__upper"
maxlength="10"
mifosxRestrictInput="pan"
[errorStateMatcher]="errorMatcher"
placeholder="e.g. ABCDE1234F"
(input)="capitalise('pan', $event)"
/>
@if (form.get('pan')?.hasError('pattern')) {
<mat-error>Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F</mat-error>
}
</mat-form-field>
@if (!isEntity) {
<mat-form-field appearance="outline" class="identity-check__field">
<mat-label>Aadhaar</mat-label>
<input
matInput
formControlName="aadhaar"
maxlength="12"
inputmode="numeric"
mifosxRestrictInput="digits"
[errorStateMatcher]="errorMatcher"
placeholder="12 digits"
/>
@if (form.get('aadhaar')?.hasError('pattern')) {
<mat-error>Invalid Aadhaar. It must be exactly 12 digits</mat-error>
} @else {
<mat-hint>Only a fingerprint of this number is sent, never the number</mat-hint>
}
</mat-form-field>
}
</form>
}
@switch (status) {
@case ('checking') {
<div class="identity-check__status identity-check__status--info">
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
Checking all branches…
</div>
}
@case ('not-found') {
<div class="identity-check__status identity-check__status--ok">
<mat-icon>check_circle</mat-icon>
<span>
No existing customer found. You can continue; these details will be filled into the KYC step so you do not
type them again.
</span>
</div>
}
@case ('error') {
<div class="identity-check__status identity-check__status--warn">
<mat-icon>warning</mat-icon>
<span>
Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is
saved.
</span>
<button mat-button type="button" (click)="recheck()">Check again</button>
</div>
}
@case ('conflict') {
<div class="identity-check__status identity-check__status--error">
<mat-icon>report</mat-icon>
<span>
These details belong to <strong>different customers</strong>. Check what was typed. If they are correct, ask a
manager to review before continuing.
</span>
</div>
@for (m of matches; track m.clientId) {
<div class="identity-check__card">
<strong>{{ m.displayName }}</strong>
<span class="identity-check__meta">CIF {{ m.cifNumber }} · {{ m.officeName }}</span>
<span class="identity-check__chips">
@for (by of m.matchedBy; track by) {
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
}
</span>
</div>
}
}
@case ('matched') {
@if (match; as m) {
<div class="identity-check__status identity-check__status--error">
<mat-icon>person_search</mat-icon>
<span> This is an <strong>existing customer</strong>. A new client is not created for the same person. </span>
</div>
<div class="identity-check__card">
<div class="identity-check__card-head">
<strong>{{ m.displayName }}</strong>
<span class="identity-check__chips">
@for (by of m.matchedBy; track by) {
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
}
</span>
</div>
<dl class="identity-check__facts">
<div>
<dt>CIF</dt>
<dd>{{ m.cifNumber }}</dd>
</div>
<div>
<dt>Home branch</dt>
<dd>{{ m.officeName }}</dd>
</div>
<div>
<dt>Type</dt>
<dd>{{ legalFormLabel(m) }}</dd>
</div>
@if (m.panMasked) {
<div>
<dt>PAN</dt>
<dd>{{ m.panMasked }}</dd>
</div>
}
@if (m.aadhaarMasked) {
<div>
<dt>Aadhaar</dt>
<dd>{{ m.aadhaarMasked }}</dd>
</div>
}
@if (m.gstinMasked) {
<div>
<dt>GSTIN</dt>
<dd>{{ m.gstinMasked }}</dd>
</div>
}
</dl>
@if (m.visibleToCaller) {
<button
mat-flat-button
color="primary"
type="button"
class="identity-check__action"
[routerLink]="['/clients', m.clientId, 'general']"
>
Open this customer
</button>
} @else {
<p class="identity-check__note">
This customer is registered at <strong>{{ m.officeName }}</strong
>. They stay a single customer (one CIF). Open their page, then choose which account to open for them at
this branch. They are listed under this branch once an account is opened here.
</p>
<button
mat-flat-button
color="primary"
type="button"
class="identity-check__action"
[disabled]="linking"
(click)="openAccountHere(m)"
>
{{ linking ? 'Please wait…' : 'Open account for this customer' }}
</button>
}
</div>
}
}
}
</section>
@@ -0,0 +1,157 @@
/**
* 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/.
*/
:host {
display: block;
}
.identity-check {
margin-bottom: 16px;
padding: 16px 20px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 12px;
&--blocking {
border-color: #fecaca;
background: #fffafa;
}
&__header {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 12px;
}
&__icon {
flex: none;
color: var(--md-sys-color-primary, #1074b9);
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
&__hint {
margin: 2px 0 0;
font-size: 13px;
color: #6b7280;
}
// PAN and GSTIN read in capitals even before the value is rewritten.
&__upper {
text-transform: uppercase;
}
&__fields {
display: flex;
flex-wrap: wrap;
gap: 0 16px;
}
&__field {
flex: 1 1 220px;
min-width: 200px;
}
&__status {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
font-size: 13px;
border-radius: 8px;
&--info {
background: #eff6ff;
color: #1e40af;
}
&--ok {
background: #ecfdf5;
color: #047857;
}
&--warn {
background: #fffbeb;
color: #92400e;
}
&--error {
background: #fef2f2;
color: #991b1b;
}
}
&__card {
margin-top: 10px;
padding: 12px 14px;
background: #fff;
border: 1px solid #fecaca;
border-radius: 10px;
}
&__card-head {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
justify-content: space-between;
}
&__meta {
margin-left: 8px;
font-size: 13px;
color: #6b7280;
}
&__chips {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
&__chip {
padding: 2px 8px;
font-size: 12px;
color: #991b1b;
background: #fee2e2;
border-radius: 999px;
}
&__facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px 16px;
margin: 10px 0 0;
dt {
font-size: 12px;
color: #6b7280;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 500;
}
}
&__action {
margin-top: 12px;
}
&__note {
margin: 12px 0 0;
font-size: 13px;
color: #374151;
}
}
@@ -0,0 +1,308 @@
/**
* 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,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
inject
} from '@angular/core';
import {
AbstractControl,
FormGroupDirective,
NgForm,
UntypedFormBuilder,
UntypedFormGroup,
Validators
} from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { Router } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { Observable, Subject, merge, of } from 'rxjs';
import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { RestrictInputDirective } from '../../../directives/restrict-input.directive';
import { ClientsService } from '../../clients.service';
import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model';
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error';
/**
* What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered
* so the KYC step can be filled in without typing them again.
*/
export interface ClientIdentityState {
status: ClientIdentityStatus;
/** Individual: the person's PAN. Entity: the company's PAN. */
pan: string | null;
/** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */
aadhaar: string | null;
gstin: string | null;
/** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */
blocking: boolean;
}
/**
* Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is
* wrong should say so while it is being typed, and the form should not look fine in the meantime.
*/
class ShowErrorWhenDirty implements ErrorStateMatcher {
isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean {
return !!control && control.invalid && (control.dirty || control.touched);
}
}
const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/;
const AADHAAR_PATTERN = /^\d{12}$/;
const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/;
/**
* "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved.
*
* Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means
* the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the
* KYC step so it does not have to be typed again.
*/
@Component({
selector: 'mifosx-client-identity-check',
templateUrl: './client-identity-check.component.html',
styleUrls: ['./client-identity-check.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
MatProgressSpinner,
RestrictInputDirective
]
})
export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
private router = inject(Router);
private authenticationService = inject(AuthenticationService);
/** 1 = individual, 2 = entity (non-individual). */
@Input() legalFormType = 1;
@Output() identityChange = new EventEmitter<ClientIdentityState>();
form: UntypedFormGroup;
readonly errorMatcher = new ShowErrorWhenDirty();
status: ClientIdentityStatus = 'idle';
matches: ClientIdentityMatch[] = [];
/** True while the customer is being introduced to this branch. */
linking = false;
private destroy$ = new Subject<void>();
private recheck$ = new Subject<void>();
get isEntity(): boolean {
return this.legalFormType === 2;
}
/** The first (and normally only) existing customer found. */
get match(): ClientIdentityMatch | null {
return this.matches[0] ?? null;
}
/**
* True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or
* carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed.
*/
get hasInvalidInput(): boolean {
return !!this.form?.invalid;
}
markAllAsTouched(): void {
this.form?.markAllAsTouched();
}
get state(): ClientIdentityState {
const v = this.normalized();
return {
status: this.status,
pan: v.pan,
aadhaar: v.aadhaar,
gstin: v.gstin,
blocking: this.status === 'matched' || this.status === 'conflict'
};
}
ngOnInit(): void {
this.form = this.formBuilder.group({
pan: [
'',
Validators.pattern(PAN_PATTERN)
],
aadhaar: [
'',
Validators.pattern(AADHAAR_PATTERN)
],
gstin: [
'',
Validators.pattern(GSTIN_PATTERN)
]
});
// A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight.
merge(this.form.valueChanges, this.recheck$)
.pipe(
debounceTime(400),
tap(() => this.startChecking()),
switchMap(() => this.lookup()),
takeUntil(this.destroy$)
)
.subscribe((result) => this.applyResult(result));
}
ngOnChanges(changes: SimpleChanges): void {
// Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale.
if (changes['legalFormType'] && !changes['legalFormType'].firstChange) {
this.reset();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/** Clears the fields and the result, e.g. when a new client creation session starts. */
reset(): void {
this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false });
this.status = 'idle';
this.matches = [];
this.identityChange.emit(this.state);
}
recheck(): void {
this.recheck$.next();
}
/**
* PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put
* back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input
* directive only capitalises when the field is left, which let lower-case show while typing.
*/
capitalise(controlName: 'pan' | 'gstin', event: Event): void {
const input = event.target as HTMLInputElement;
const upper = input.value.toUpperCase();
if (upper !== input.value) {
const { selectionStart, selectionEnd } = input;
input.value = upper;
input.setSelectionRange(selectionStart, selectionEnd);
}
this.form.get(controlName)?.setValue(upper, { emitEvent: false });
}
/** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */
private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } {
const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' };
const pan = String(raw.pan ?? '')
.replace(/\s+/g, '')
.toUpperCase();
const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, '');
const gstin = String(raw.gstin ?? '')
.replace(/\s+/g, '')
.toUpperCase();
return {
pan: PAN_PATTERN.test(pan) ? pan : null,
aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null,
gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null
};
}
private startChecking(): void {
const v = this.normalized();
if (!v.pan && !v.aadhaar && !v.gstin) {
this.status = 'idle';
this.matches = [];
this.identityChange.emit(this.state);
return;
}
this.status = 'checking';
this.matches = [];
this.identityChange.emit(this.state);
}
private lookup(): Observable<ClientIdentityLookupResult | 'error' | null> {
const v = this.normalized();
if (!v.pan && !v.aadhaar && !v.gstin) {
return of(null);
}
return this.clientsService
.lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin })
.pipe(
map((result): ClientIdentityLookupResult | 'error' => result),
catchError(() => of('error' as const))
);
}
private applyResult(result: ClientIdentityLookupResult | 'error' | null): void {
if (result === null) {
// Nothing complete to look up; startChecking() already reset to idle.
return;
}
if (result === 'error') {
// Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding.
this.status = 'error';
this.matches = [];
} else if (result.conflict) {
this.status = 'conflict';
this.matches = result.matches;
} else if (result.matched) {
this.status = 'matched';
this.matches = result.matches;
} else {
this.status = 'not-found';
this.matches = [];
}
this.identityChange.emit(this.state);
}
/**
* Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home
* branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under
* this branch once an account is actually opened here. An account opened for a customer of another branch is held
* at the branch that opens it.
*/
openAccountHere(match: ClientIdentityMatch): void {
const officeId = this.authenticationService.getCredentials()?.officeId;
if (this.linking || officeId == null) {
return;
}
this.linking = true;
this.clientsService.linkClientToMyBranch(match.clientId).subscribe({
next: () => {
this.linking = false;
this.router.navigate([
'/clients',
match.clientId,
'general'
]);
},
// The failure is shown by the global error handler.
error: () => (this.linking = false)
});
}
/** Legal form of the existing customer, for the card. */
legalFormLabel(match: ClientIdentityMatch): string {
return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual';
}
matchedByLabel(by: string): string {
return by === 'AADHAAR' ? 'Aadhaar' : by;
}
}
@@ -43,6 +43,8 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
import { ClientIdentityState } from '../client-identity-check/client-identity-check.component';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
import {
PanDeclarationDialogComponent,
@@ -108,6 +110,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() verificationDateOfBirth: Date | null = null;
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
/**
* The identifiers the officer already typed into the existing-customer check on the General step. They are filled
* in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this
* step wins. Ignored while the check says the customer already exists, since no client is created then.
*/
@Input() set identityPrefill(identity: ClientIdentityState | null) {
if (!identity || identity.blocking) {
return;
}
const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim();
const patch: Record<string, unknown> = {};
if (this.isNonIndividual) {
if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin;
if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan;
} else {
if (identity.pan && isEmpty('panNumber')) {
patch['panNumber'] = identity.pan;
patch['hasPan'] = true;
}
if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar;
}
if (Object.keys(patch).length) {
this.kycForm.patchValue(patch);
}
}
@Input() calculatedRisk: {
category: number;
reason: string;
@@ -1756,7 +1784,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
panNumber: pan,
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000',
...this.aadhaarHashField(aadhaarRaw)
};
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
@@ -2358,7 +2387,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
locale: 'en',
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
aadhaarNumberMasked: aadhaarMasked
aadhaarNumberMasked: aadhaarMasked,
...this.aadhaarHashField(aadhaar)
};
const panVal = this.kycForm.value.panNumber;
if (panVal) {
@@ -2679,6 +2709,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
}
/**
* `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are
* recorded server-side, so only its SHA-256 goes.
*/
private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } {
const hash = aadhaarHash(aadhaar);
return hash ? { aadhaarHash: hash } : {};
}
get kycData(): any {
const formVal = this.kycForm.getRawValue();
const aadhaarRaw = formVal.aadhaarNumber || '';
@@ -2707,6 +2746,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
// The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps
// only the last four digits). Absent when the field holds a masked value reloaded from the server.
...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)),
// Only ever sent when this screen actually holds a value. The seeding status is derived by
// the provider during PAN verification and written straight to the record, so sending an
// empty one back would blank a field the server filled in — which reads as an officer
@@ -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">
@@ -90,6 +90,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -203,6 +218,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
@@ -262,6 +292,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -452,6 +497,21 @@
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -551,6 +611,21 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
@@ -611,10 +686,27 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Branch">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
<td mat-cell *matCellDef="let element">
@if (element.officeName) {
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
<i class="fa fa-map-marker"></i>{{ element.officeName }}
@if (isHomeBranch(element)) {
<span class="branch-chip__home">Home</span>
}
</span>
} @else {
—
}
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{
rdStatusLabel(element)
}}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
@@ -440,3 +440,66 @@ table[mat-table] {
font-size: 11px;
color: #64748b;
}
/*
* Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for
* any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch.
*/
.branch-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
.fa {
font-size: 12px;
}
&--mine {
background: #dcfce7;
color: #166534;
border-color: #4ade80;
}
&--other-0 {
background: #fee2e2;
color: #991b1b;
border-color: #fca5a5;
}
&--other-1 {
background: #fca5a5;
color: #7f1d1d;
border-color: #f87171;
}
&--other-2 {
background: #dc2626;
color: #fff;
border-color: #b91c1c;
}
&--other-3 {
background: #991b1b;
color: #fff;
border-color: #7f1d1d;
}
}
.branch-chip__home {
padding: 0 6px;
border-radius: 8px;
/* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */
background: rgb(255 255 255 / 85%);
color: #1f2937;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@@ -39,6 +39,7 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { CurrencyPipe } from '@angular/common';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { ReportsService } from 'app/reports/reports.service';
import { SettingsService } from 'app/settings/settings.service';
import { Subject } from 'rxjs';
@@ -151,6 +152,7 @@ export class GeneralTabComponent implements OnDestroy {
private router = inject(Router);
private reportsService = inject(ReportsService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
/** Open Loan Accounts Columns */
openLoansColumns: string[] = [
@@ -177,6 +179,7 @@ export class GeneralTabComponent implements OnDestroy {
openSavingsColumns: string[] = [
'Account No',
'Saving Account',
'Branch',
'Last Active',
'Balance',
'Actions'
@@ -185,12 +188,14 @@ export class GeneralTabComponent implements OnDestroy {
closedSavingsColumns: string[] = [
'Account No',
'Saving Account',
'Branch',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Branch',
'Status',
'Last Active',
'Balance',
@@ -336,12 +341,76 @@ export class GeneralTabComponent implements OnDestroy {
/** Client Id */
clientid: any;
/** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */
clientOfficeId: number | null = null;
/** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */
private static readonly OTHER_BRANCH_SHADES = 4;
/** The branch the signed-in user works at: accounts held here read green, every other branch red. */
private get myOfficeId(): number | null {
const officeId = this.authenticationService.getCredentials()?.officeId;
return officeId == null ? null : Number(officeId);
}
/**
* The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between
* visits; computed over every deposit account on the page so one branch has the same shade in every table.
*/
private otherBranchIds(): number[] {
const mine = this.myOfficeId;
const ids = new Set<number>();
for (const account of [
...this.savingAccounts,
...this.currentAccounts
]) {
const officeId = Number(account?.officeId);
if (Number.isFinite(officeId) && officeId !== mine) {
ids.add(officeId);
}
}
return [...ids].sort((a, b) => a - b);
}
/** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */
branchChipClass(account: any): string {
const officeId = Number(account?.officeId);
if (officeId === this.myOfficeId) {
return 'branch-chip branch-chip--mine';
}
const others = this.otherBranchIds();
const rank = Math.max(others.indexOf(officeId), 0);
const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1;
// one other branch gets a mid red, several are spread from the lightest to the darkest
const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1));
return `branch-chip branch-chip--other-${shade}`;
}
branchChipTitle(account: any): string {
const parts = [account?.officeName];
if (Number(account?.officeId) === this.myOfficeId) {
parts.push('your branch');
}
if (this.isHomeBranch(account)) {
parts.push('customer home branch');
}
return parts.join(' - ');
}
isHomeBranch(account: any): boolean {
return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId;
}
/**
* @param {ActivatedRoute} route Activated Route
* @param {ClientsService} clientService Clients Service
* @param {Router} router Router
*/
constructor() {
// The customer's record is resolved on the parent route, not on this tab's own route data.
this.route.parent?.data.subscribe((data: any) => {
this.clientOfficeId = data?.clientViewData?.officeId ?? null;
});
this.route.data.subscribe(
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
this.clientAccountData = data.clientAccountsData;
+22
View File
@@ -50,6 +50,17 @@
/>
</mat-form-field>
@if (isHeadOffice) {
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="officeFilter" (selectionChange)="officeChanged()" placeholder="Branch">
<mat-option [value]="null">All Branches</mat-option>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option>
@@ -92,6 +103,11 @@
row.legalForm?.id === 2 ? 'business' : 'person'
}}</mat-icon>
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
@if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) {
<span class="row-home-chip" matTooltip="Registered at another branch; linked to this branch">
Home: {{ row.officeName }}
</span>
}
@if (row._ckycDone) {
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
}
@@ -114,6 +130,12 @@
</td>
</ng-container>
<!-- Branch Column (Head Office only) -->
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
+12
View File
@@ -7,3 +7,15 @@
*/
@use '../shared/styles/list-layout' as *;
// A customer registered at another branch that has been linked to this one.
.row-home-chip {
margin-left: 8px;
padding: 1px 8px;
font-size: 11px;
font-weight: 500;
color: #92400e;
background: #fef3c7;
border-radius: 999px;
white-space: nowrap;
}
+27 -1
View File
@@ -38,6 +38,8 @@ import { MatIconButton, MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from '../core/authentication/authentication.service';
import { OrganizationService } from '../organization/organization.service';
@Component({
selector: 'mifosx-clients',
@@ -71,6 +73,15 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
})
export class ClientsComponent implements OnInit {
private clientService = inject(ClientsService);
private authenticationService = inject(AuthenticationService);
private organizationService = inject(OrganizationService);
/** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */
isHeadOffice = this.authenticationService.isHeadOfficeUser();
offices: { id: number; name: string }[] = [];
officeFilter: number | null = null;
/** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */
myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null;
/** Returns true if client data masking is enabled */
get hideClientData(): boolean {
@@ -90,6 +101,7 @@ export class ClientsComponent implements OnInit {
'displayName',
'cifNumber',
'legalForm',
'officeName',
'status',
'profileCompletion',
'actions'
@@ -119,6 +131,13 @@ export class ClientsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort;
ngOnInit() {
if (!this.isHeadOffice) {
this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName');
} else {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name }));
});
}
if (environment.preloadClients) {
this.getClients();
}
@@ -144,7 +163,8 @@ export class ClientsComponent implements OnInit {
this.sortDirection,
false,
'',
true
true,
this.isHeadOffice ? this.officeFilter : null
)
.subscribe(
(data: any) => {
@@ -233,6 +253,12 @@ export class ClientsComponent implements OnInit {
this.dataSource.data = filtered;
}
/** Branch filter is applied by the server so paging and the record count stay correct. */
officeChanged() {
this.resetPaginator();
this.getClients();
}
pageChanged(event: PageEvent) {
this.pageSize = event.pageSize;
this.currentPage = event.pageIndex;
+137 -3
View File
@@ -18,6 +18,7 @@ import { map, catchError } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
import { ClientIdentityLookupResult } from './models/client-identity.model';
/**
* Clients service.
@@ -742,9 +743,14 @@ export class ClientsService {
sortDirection: string = '',
activeOnly: boolean = false,
legalFormType: string = '',
silent: boolean = false
silent: boolean = false,
officeId: number | null = null
) {
const innerRequest: any = { text };
// Branch filter (Head Office list). The server still limits results to the caller's office hierarchy.
if (officeId != null) {
innerRequest.officeId = officeId;
}
if (activeOnly) {
innerRequest.activeOnly = true;
}
@@ -840,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([])));
@@ -868,6 +879,39 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/kyc`, kycData);
}
/**
* "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN.
* Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another
* client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number.
*/
/**
* Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account
* for them there. The customer stays at the home branch. Safe to repeat.
*/
linkClientToMyBranch(
clientId: number
): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> {
return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>(
'/clientidentity/link',
{
clientId
}
);
}
lookupClientIdentity(identity: {
panNumber?: string | null;
aadhaarHash?: string | null;
gstinNumber?: string | null;
mobileNo?: string | null;
emailAddress?: string | null;
excludeClientId?: number | null;
}): Observable<ClientIdentityLookupResult> {
return this.http.post<ClientIdentityLookupResult>('/clientidentity/lookup', identity, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/**
* Screen a client name against the UN Security Council consolidated sanctions list.
* @param name The name to screen
@@ -1073,9 +1117,91 @@ export class ClientsService {
*/
saveKycContactLog(
clientId: string | number,
data: { channel: string; notes: string; officerName?: string }
data: { channel: string; notes: string; officerName?: string; noticeType?: string },
silent = false
): Observable<any> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
return this.http.post(
`/kyc/exceptions/${clientId}/contact-log`,
data,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
/**
* The subject and text a customer would receive for the stage they are at today.
* `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage.
*/
previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> {
return this.http.get<{ subject?: string; body?: string }>(
`/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`,
{
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
}
);
}
/** Queues notices to be sent in the background by the queue job. */
enqueueKycNotices(body: {
items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[];
notes?: string;
officerName?: string;
sendAt?: string;
}): 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. */
getKycNoticeQueue(status = ''): Observable<any[]> {
return this.http.get<any[]>(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable<any> {
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
}
/** Notices per day (customers, notices, outcomes), newest day first. */
getKycContactAuditDays(
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),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/** Customer notices on record, newest first, a page at a time. */
getKycContactAudit(
filter: {
from?: string;
to?: string;
channel?: string;
status?: string;
search?: string;
offset?: number;
limit?: number;
} = {}
): Observable<{ total: number; rows: any[] }> {
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', {
params: this.auditParams(filter),
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
let params = new HttpParams();
Object.entries(filter).forEach(
([
k,
v
]) => {
if (v !== undefined && v !== null && v !== '') {
params = params.set(k, String(v));
}
}
);
return params;
}
getKycContactLog(clientId: string | number): Observable<any[]> {
@@ -1092,6 +1218,14 @@ export class ClientsService {
return this.http.get<any[]>('/kyc/exceptions/lapsed');
}
/** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */
getExpiringKyc(days = 30): Observable<any[]> {
// The caller reports a failure in its own banner, so the global popup is suppressed.
return this.http.get<any[]>(`/kyc/exceptions/expiring?days=${days}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
/**
* Savings accounts the dormancy job has marked.
*
@@ -38,6 +38,12 @@
}
</div>
}
@if (!createdClientId) {
<mifosx-client-identity-check
[legalFormType]="legalFormType"
(identityChange)="onIdentityChange($event)"
></mifosx-client-identity-check>
}
<mifosx-client-general-step
[clientTemplate]="clientTemplate"
[prefillData]="prefillData"
@@ -79,6 +85,7 @@
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
[identityPrefill]="identity"
></mifosx-client-kyc-step>
</div>
@@ -23,6 +23,10 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
import {
ClientIdentityCheckComponent,
ClientIdentityState
} from '../client-stepper/client-identity-check/client-identity-check.component';
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
@@ -60,6 +64,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
ClientEntityKycStepComponent,
ClientDatatableStepComponent,
ClientKycStepComponent,
ClientIdentityCheckComponent,
ClientPreviewStepComponent,
ClientFamilyMembersStepComponent,
MatProgressBarModule,
@@ -79,6 +84,7 @@ export class CreateClientComponent {
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
@ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent;
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
@@ -87,6 +93,17 @@ export class CreateClientComponent {
selectedTabIndex = 0;
createdClientId = '';
savingClient = false;
/**
* Result of the existing-customer check at the top of the General step. While it says the customer already exists
* (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled
* into the KYC step.
*/
identity: ClientIdentityState | null = null;
onIdentityChange(state: ClientIdentityState): void {
this.identity = state;
}
/** Pre-fill data passed from "Create as Client" on a manual family member */
prefillData: any = null;
@@ -138,6 +155,8 @@ export class CreateClientComponent {
this.highestTabReached = 0;
this.validationError = '';
this.prefillData = null;
this.identity = null;
this.identityCheck?.reset();
this.clientGeneralStep?.resetForNewClient();
this.clientKycStep?.resetForNewClient();
this.clientIndiaAddressStep?.resetForNewClient();
@@ -212,6 +231,10 @@ export class CreateClientComponent {
const idx = this.selectedTabIndex;
if (idx === 0) {
// The customer is already on the books (any branch), or the identifiers belong to different customers.
if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false;
// A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared.
if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false;
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
if (this.clientGeneralStep.sanctionsBlocked) return false;
// A number the officer rejected on the DIP result must be replaced, not carried forward.
@@ -274,6 +297,17 @@ export class CreateClientComponent {
if (this.savingClient) return 'Saving — please wait…';
if (this.currentStepValid) return '';
const idx = this.selectedTabIndex;
if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) {
return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.';
}
if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') {
return 'Checking whether this customer already exists…';
}
if (idx === 0 && !this.createdClientId && this.identity?.blocking) {
return this.identity.status === 'conflict'
? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.'
: 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.';
}
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
const disposition = this.clientGeneralStep.sanctionsDisposition;
if (disposition === 'FALSE_POSITIVE') {
@@ -358,6 +392,19 @@ export class CreateClientComponent {
onNext() {
// If on General tab and client not yet created — save first
if (this.selectedTabIndex === 0 && !this.createdClientId) {
// Defensive re-check: the button is disabled while this holds, but a second client for the same person is the
// one mistake this screen exists to prevent.
if (this.identityCheck?.hasInvalidInput) {
this.identityCheck.markAllAsTouched();
this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 });
return;
}
if (this.identity?.blocking) {
this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', {
duration: 6000
});
return;
}
if (!this.clientGeneralForm?.valid) {
this.clientGeneralForm?.markAllAsTouched();
setTimeout(() => {
@@ -0,0 +1,34 @@
/**
* 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/.
*/
/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */
export interface ClientIdentityMatch {
clientId: number;
displayName: string;
cifNumber: string;
officeId: number;
officeName: string;
legalFormEnum: number | null;
kycStatus: number;
/** True when the caller's branch can open this client; false when it sits in another branch. */
visibleToCaller: boolean;
panMasked: string | null;
aadhaarMasked: string | null;
gstinMasked: string | null;
matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>;
}
export interface ClientIdentityLookupResult {
/** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */
matched: boolean;
/** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */
conflict: boolean;
matches: ClientIdentityMatch[];
/** A phone or email already used by another client. Never means "the same customer". */
contactConflicts: { mobileNo: boolean; emailAddress: boolean };
}
@@ -0,0 +1,52 @@
/**
* 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 { createHash } from 'crypto';
import { aadhaarHash, sha256Hex } from './aadhaar-hash.util';
describe('sha256Hex', () => {
it('matches the published SHA-256 test vectors', () => {
expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe(
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1'
);
});
it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => {
for (const value of [
'000000000000',
'123456789012',
'999999999999',
'0'.repeat(55),
'0'.repeat(56),
'0'.repeat(64)
]) {
expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex'));
}
});
});
describe('aadhaarHash', () => {
it('hashes a 12-digit number', () => {
expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex'));
});
it('ignores the spaces and hyphens people type between the groups', () => {
expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012'));
expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012'));
});
it('returns null for anything that is not a full number', () => {
expect(aadhaarHash('')).toBeNull();
expect(aadhaarHash(null)).toBeNull();
expect(aadhaarHash('XXXXXXXX1234')).toBeNull();
expect(aadhaarHash('XXXXXXXX0000')).toBeNull();
expect(aadhaarHash('12345678901')).toBeNull();
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* 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/.
*/
/**
* The comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters.
*
* Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log
* on the server, so the 12-digit number must not be in the request.
*
* A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not
* protection against someone who can read the stored value.
*
* Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure
* contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be
* served over plain HTTP.
*/
const K = [
0x428a2f98,
0x71374491,
0xb5c0fbcf,
0xe9b5dba5,
0x3956c25b,
0x59f111f1,
0x923f82a4,
0xab1c5ed5,
0xd807aa98,
0x12835b01,
0x243185be,
0x550c7dc3,
0x72be5d74,
0x80deb1fe,
0x9bdc06a7,
0xc19bf174,
0xe49b69c1,
0xefbe4786,
0x0fc19dc6,
0x240ca1cc,
0x2de92c6f,
0x4a7484aa,
0x5cb0a9dc,
0x76f988da,
0x983e5152,
0xa831c66d,
0xb00327c8,
0xbf597fc7,
0xc6e00bf3,
0xd5a79147,
0x06ca6351,
0x14292967,
0x27b70a85,
0x2e1b2138,
0x4d2c6dfc,
0x53380d13,
0x650a7354,
0x766a0abb,
0x81c2c92e,
0x92722c85,
0xa2bfe8a1,
0xa81a664b,
0xc24b8b70,
0xc76c51a3,
0xd192e819,
0xd6990624,
0xf40e3585,
0x106aa070,
0x19a4c116,
0x1e376c08,
0x2748774c,
0x34b0bcb5,
0x391c0cb3,
0x4ed8aa4a,
0x5b9cca4f,
0x682e6ff3,
0x748f82ee,
0x78a5636f,
0x84c87814,
0x8cc70208,
0x90befffa,
0xa4506ceb,
0xbef9a3f7,
0xc67178f2
];
function rotr(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */
export function sha256Hex(text: string): string {
const bytes: number[] = [];
for (const ch of unescape(encodeURIComponent(text))) {
bytes.push(ch.charCodeAt(0));
}
const bitLength = bytes.length * 8;
bytes.push(0x80);
while (bytes.length % 64 !== 56) {
bytes.push(0);
}
// 64-bit big-endian message length; inputs here are tiny so the high word is always 0.
const high = Math.floor(bitLength / 0x100000000);
const low = bitLength >>> 0;
for (const word of [
high,
low
]) {
bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
}
const h = [
0x6a09e667,
0xbb67ae85,
0x3c6ef372,
0xa54ff53a,
0x510e527f,
0x9b05688c,
0x1f83d9ab,
0x5be0cd19
];
const w = new Array<number>(64);
for (let offset = 0; offset < bytes.length; offset += 64) {
for (let i = 0; i < 16; i++) {
const j = offset + i * 4;
w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}
let [
a,
b,
c,
d,
e,
f,
g,
hh
] = h;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
hh = g;
g = f;
f = e;
e = (d + t1) | 0;
d = c;
c = b;
b = a;
a = (t1 + t2) | 0;
}
h[0] = (h[0] + a) | 0;
h[1] = (h[1] + b) | 0;
h[2] = (h[2] + c) | 0;
h[3] = (h[3] + d) | 0;
h[4] = (h[4] + e) | 0;
h[5] = (h[5] + f) | 0;
h[6] = (h[6] + g) | 0;
h[7] = (h[7] + hh) | 0;
}
return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');
}
/**
* The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder).
* Spaces and hyphens people type between the groups are ignored.
*/
export function aadhaarHash(aadhaar: string | null | undefined): string | null {
const digits = (aadhaar ?? '').replace(/[\s-]/g, '');
return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null;
}
+18 -1
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { toProperNameCase } from './name-case.util';
import { toPersonNameCase, toProperNameCase } from './name-case.util';
describe('toProperNameCase', () => {
it('raises a lower-case first letter on every word', () => {
@@ -53,3 +53,20 @@ describe('toProperNameCase', () => {
expect(toProperNameCase(undefined)).toBe('');
});
});
describe('toPersonNameCase', () => {
it('title-cases every word, lowering mixed or upper case', () => {
expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya');
expect(toPersonNameCase('KESHAV')).toBe('Keshav');
});
it('keeps hyphen and apostrophe name prefixes', () => {
expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie');
expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza");
});
it('returns an empty string for blank or non-string input', () => {
expect(toPersonNameCase(' ')).toBe('');
expect(toPersonNameCase(null)).toBe('');
});
});
+14
View File
@@ -62,3 +62,17 @@ export function toProperNameCase(value: string | null | undefined): string {
}
return collapsed.split(' ').map(capitalizeWord).join(' ');
}
/**
* Title case for an individual's first, middle and last name: each word's first letter raised,
* the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on
* the general step has always shown; using it for the stored value too keeps the preview and the
* saved client the same as that line. Entity names stay on {@link toProperNameCase} — their
* spelling is matched against the registered PAN name.
*/
export function toPersonNameCase(value: string | null | undefined): string {
if (typeof value !== 'string') {
return '';
}
return toProperNameCase(value.toLowerCase());
}
+7 -1
View File
@@ -35,6 +35,7 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/profitappropriationconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
@@ -53,7 +54,12 @@ export class CacheInterceptor implements HttpInterceptor {
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
'/recurringdepositaccounts/dashboard',
// Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them.
'/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'
];
private isCacheable(url: string): boolean {
+26 -4
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>
@@ -508,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"
@@ -1227,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>
@@ -2036,11 +2058,11 @@
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('ALM')"
(click)="rbiMenuOpen = false; openAlmReportDialog()"
[class.hidden]="!matchesRbiSearch('ALM Average Liabilities and Assets Time Bucket Breakdown')"
(click)="rbiMenuOpen = false; openAlmBucketBreakdownDialog()"
>
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Asset Liability
Management)
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Average Liabilities
and Assets) – Time Bucket Breakdown
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
+19
View File
@@ -364,6 +364,25 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Opens the ALM Time-Bucket Breakdown: which balance-sheet item goes to which bucket of the
* Structural Liquidity Statement. Rules are editable and kept per browser.
*/
async openAlmBucketBreakdownDialog(): Promise<void> {
const { AlmBucketBreakdownDialogComponent } =
await import('../../home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component');
this.dialog.open(AlmBucketBreakdownDialogComponent, {
width: '94vw',
maxWidth: '1320px',
maxHeight: '92vh',
panelClass: 'crr-slr-dialog-panel',
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'alm-dlg-backdrop'
});
}
/**
* Unsubscribes from progress bar.
*/
@@ -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>
@@ -838,9 +850,23 @@
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
</td>
<td class="ni-td ni-td--center">
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
{{ c.sharePercentage }}%
</span>
@if (readOnly) {
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
{{ c.sharePercentage }}%
</span>
} @else {
<span class="ni-pct-input">
<input
#pct
type="number"
min="0"
max="100"
[value]="c.sharePercentage"
(change)="setPartnerShare(c, pct.value); pct.value = '' + c.sharePercentage"
aria-label="Ownership percentage"
/>%
</span>
}
</td>
<td class="ni-td ni-td--center">
<button
@@ -465,6 +465,31 @@ $ni-bg: #f9fafb;
color: #374151;
}
.ni-pct-input {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 700;
color: $ni-primary;
input {
width: 52px;
padding: 3px 6px;
border: 1px solid #c7d2fe;
border-radius: 6px;
background: #eef2ff;
font: inherit;
color: inherit;
text-align: right;
&:focus {
outline: 2px solid $ni-primary;
outline-offset: 0;
}
}
}
.ni-pct-badge {
display: inline-flex;
align-items: center;
@@ -1710,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;
}
@@ -29,6 +29,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
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
@@ -56,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;
@@ -207,12 +218,20 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.autoDetectAccountStatus();
}
}
if (this.isNonIndividualClient && !this.isEditMode && !this.readOnly) {
this.prefillPartnersFromClientSignatories(client);
}
this.cdr.detectChanges();
});
}
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
@@ -250,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: [
@@ -622,6 +659,52 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
}
// ── Non-Individual (existing) helpers ──────────────────────────────────
/**
* A new entity account starts with the authorised signatories recorded on the client at creation
* (clientNonPersonDetails.authorizedSignatoryClientIds), so they need not be added again. Ownership is
* not captured at client creation, so it starts at 0 and is entered in the table; Add Partner stays
* available for anyone else.
*/
private prefillPartnersFromClientSignatories(client: any): void {
if (this.relatedClients.length > 0) {
return;
}
const ids: number[] = String(client?.clientNonPersonDetails?.authorizedSignatoryClientIds ?? '')
.split(',')
.map((id) => Number(id.trim()))
.filter((id, i, all) => id > 0 && id !== this.primaryClientId && all.indexOf(id) === i);
if (!ids.length) {
return;
}
// One lookup per signatory for the CIF; a signatory that can no longer be read is skipped, not fatal.
forkJoin(
ids.map((id) => this.clientsService.getClientData(String(id), true).pipe(catchError(() => of(null))))
).subscribe((signatories: any[]) => {
// The user may have added partners by hand while the lookups ran — never overwrite those.
if (this.relatedClients.length > 0) {
return;
}
this.relatedClients = signatories
.filter((s) => !!s?.id)
.map((s) => ({
clientId: s.id,
clientName: s.displayName ?? s.fullname ?? '',
clientCif: s.cifNumber ?? s.cifNo ?? '',
sharePercentage: 0,
isMandatory: false
}));
this.autoSetSignatoryRights();
this.cdr.detectChanges();
});
}
/** Inline ownership edit — clamped so the partners' total never exceeds 100%. */
setPartnerShare(client: any, raw: any): void {
const othersTotal = this.totalSharePercentage - (client.sharePercentage || 0);
const value = Math.round(Number(raw) || 0);
client.sharePercentage = Math.min(Math.max(value, 0), Math.max(0, 100 - othersTotal));
}
openAddRelatedClientDialog(): void {
const excludedClientIds = [
...(this.primaryClientId ? [this.primaryClientId] : []),
@@ -10,7 +10,7 @@ import { Directive, HostListener, Input, ElementRef, inject } from '@angular/cor
import { NgControl } from '@angular/forms';
/** The kinds of field this knows how to hold to a shape. */
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar';
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar' | 'gstin' | 'tan';
interface Rule {
/** Characters this kind will accept at all. */
@@ -27,7 +27,10 @@ const RULES: Record<RestrictKind, Rule> = {
// A PAN is five letters, four digits, one letter - and is always written in capitals.
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true },
// GSTIN is fifteen letters and digits, TAN ten; both are always written in capitals.
gstin: { allowed: /[A-Za-z0-9]/, maxLength: 15, upper: true },
tan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }
};
/**
+2
View File
@@ -54,6 +54,8 @@ export interface CtrTransaction {
declaration: string;
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
customerName?: string;
/** True for a transaction that has crossed the threshold but is not yet in a generated CTR. */
pendingFiling?: boolean;
/**
* CTR does not judge suspicion. It records only whether the source of funds was
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
+232 -140
View File
@@ -223,6 +223,15 @@
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
} @else if (ctrPendingCount > 0) {
<div class="fiu-note">
<fa-icon icon="info-circle"></fa-icon>
{{ ctrPendingCount }} cash transaction{{ ctrPendingCount > 1 ? 's' : '' }} of ₹10,00,000 or more
{{ ctrPendingCount > 1 ? 'are' : 'is' }} listed below automatically as soon as
{{ ctrPendingCount > 1 ? 'they are' : 'it is' }} posted.
{{ ctrPendingCount > 1 ? 'They are' : 'It is' }} not in a filed report yet — pick the month under “Generate
CTR” and run it to include {{ ctrPendingCount > 1 ? 'them' : 'it' }}.
</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -342,27 +351,103 @@
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
<th class="ta-c">Customers</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<tr style="cursor: pointer" (click)="openCtrReport(r)">
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td>{{ r.generatedOn ? (asDate(r.generatedOn) | date: 'medium') : '—' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
<td>{{ r.submittedOn ? (asDate(r.submittedOn) | date: 'medium') : '—' }}</td>
<td class="ta-c" (click)="$event.stopPropagation()">
<button class="fiu-btn fiu-btn-g" (click)="openCtrReport(r)">View</button>
<button class="fiu-btn fiu-btn-g" (click)="exportCtrReport(r)">Excel</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
<td colspan="6" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- The customers and transactions one CTR report names. Opened from a row above; declared here so it keeps this
component's state and styles. -->
<ng-template #ctrReportDialog>
<div class="fiu-dialog">
@if (ctrReportView; as report) {
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR report — {{ report.reportMonth }}</h3>
<p>
{{ report.status }} · {{ ctrReportLines.length }} record{{ ctrReportLines.length === 1 ? '' : 's' }} ·
total ₹{{ ctrReportTotal | number: '1.2-2' }}
</p>
</div>
<div class="fiu-action-group">
<button
class="fiu-btn fiu-btn-xml"
[disabled]="ctrReportLinesLoading"
(click)="exportCtrReport(report)"
>
Export Excel
</button>
<button class="fiu-btn fiu-btn-g" (click)="closeCtrReport()">Close</button>
</div>
</div>
@if (ctrReportLinesLoading) {
<div class="fiu-note">Loading the customers in this report…</div>
} @else {
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Customer</th>
<th>Customer ID / CKYC</th>
<th>Account</th>
<th>Branch</th>
<th>Date</th>
<th>Direction</th>
<th>Type</th>
<th class="ta-r">Amount</th>
</tr>
</thead>
<tbody>
@for (l of ctrReportLines; track $index) {
<tr>
<td>
{{ l.clientName }}
<div style="font-size: 11px; color: var(--f-g500)">
{{ l.individual === false ? 'Entity' : 'Individual' }}
</div>
</td>
<td class="mono">{{ l.ckycNumber || l.clientExternalId || '—' }}</td>
<td class="mono">{{ l.accountNo || '—' }}</td>
<td>{{ l.branchName || '—' }}</td>
<td>{{ l.transactionDate ? (asDate(l.transactionDate) | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ l.direction || '—' }}</td>
<td>{{ l.connectedGroup ? 'Connected group (month total)' : 'Single transaction' }}</td>
<td class="ta-r">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">This report names no customers.</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
</ng-template>
</div>
}
@@ -581,146 +666,153 @@
</div>
}
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
<p>
Alert #{{ selectedStrCase.alertId }} &middot;
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
<span class="escalation-badge">Risk category auto-escalated</span>
<!-- The case investigation. Opened as a popup by selectStrCase(); declared here so it keeps this component's state. -->
<ng-template #strCaseDialog>
<div class="fiu-dialog">
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
<p>
Alert #{{ selectedStrCase.alertId }} &middot;
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
<span class="escalation-badge">Risk category auto-escalated</span>
}
</p>
</div>
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div
class="case-outcome"
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
[class.case-outcome--suspicious]="
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
"
>
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes"
>Investigation notes (source-of-transaction history reviewed)</label
>
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
</div>
<div class="form-row">
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<!-- Verification checklist — the verdict is derived, not chosen. -->
<div class="checklist">
<div class="checklist-head">
<span>Principal Officer verification — mark each point as found</span>
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-pressed]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-pressed]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived verdict -->
<div
class="verdict"
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
>
@if (!allAnswered) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Answer every verification point to determine the outcome —
{{ strChecklist.length - answeredCount }} still pending.
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case
will be closed and nothing filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
{{ failedFindings[0].label }}
@if (failedFindings.length > 1) {
<span> and {{ failedFindings.length - 1 }} more</span>
}
.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
Conclude as
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
</div>
}
</p>
</div>
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div
class="case-outcome"
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
[class.case-outcome--suspicious]="
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
"
>
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
</div>
<div class="form-row">
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<!-- Verification checklist — the verdict is derived, not chosen. -->
<div class="checklist">
<div class="checklist-head">
<span>Principal Officer verification — mark each point as found</span>
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-pressed]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-pressed]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived verdict -->
<div
class="verdict"
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
>
@if (!allAnswered) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Answer every verification point to determine the outcome —
{{ strChecklist.length - answeredCount }} still pending.
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
closed and nothing filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
{{ failedFindings[0].label }}
@if (failedFindings.length > 1) {
<span> and {{ failedFindings.length - 1 }} more</span>
}
.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
Conclude as
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
</div>
}
</div>
}
</div>
}
</ng-template>
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
<div class="fiu-form-meta">
+7 -1
View File
@@ -6,7 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
:host,
.fiu-dialog {
--f-navy: #0b1e3d;
--f-blue: #1d4ed8;
--f-blue-l: #eff6ff;
@@ -1858,3 +1859,8 @@
white-space: normal;
font-size: 12.5px;
}
/* The STR case popup renders in the overlay, outside :host, so it carries the page's colour variables itself. */
.fiu-dialog {
padding: 4px;
}
+174 -6
View File
@@ -6,7 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, inject, ViewChild, TemplateRef } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { forkJoin, of } from 'rxjs';
import { exportWorkbook } from 'app/cashier/cashier-export';
import { catchError } from 'rxjs/operators';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -131,6 +136,134 @@ export class FiuComponent implements OnInit {
private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
private route = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
/** The STR case investigation, shown in a popup rather than a panel pushed in under the case list. */
@ViewChild('strCaseDialog') strCaseDialogTpl!: TemplateRef<unknown>;
private strCaseDialogRef: MatDialogRef<unknown> | null = null;
/** The CTR report whose customers are open in the popup, and the lines behind it. */
@ViewChild('ctrReportDialog') ctrReportDialogTpl!: TemplateRef<unknown>;
private ctrReportDialogRef: MatDialogRef<unknown> | null = null;
ctrReportView: any = null;
ctrReportLines: any[] = [];
ctrReportLinesLoading = false;
/** Opens a generated CTR report with every customer and transaction it names. */
openCtrReport(report: any): void {
this.ctrReportView = report;
this.ctrReportLines = [];
this.ctrReportLinesLoading = true;
this.amlService.getCtrReportLines(report.id).subscribe({
next: (lines) => {
this.ctrReportLines = lines || [];
this.ctrReportLinesLoading = false;
},
error: () => {
this.ctrReportLinesLoading = false;
this.toast(`Could not load the customers in the ${report.reportMonth} report`);
}
});
this.ctrReportDialogRef?.close();
this.ctrReportDialogRef = this.dialog.open(this.ctrReportDialogTpl, {
width: '1280px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false
});
this.ctrReportDialogRef.afterClosed().subscribe(() => {
this.ctrReportView = null;
this.ctrReportDialogRef = null;
});
}
closeCtrReport(): void {
this.ctrReportDialogRef?.close();
}
/** Total of the amounts in the open report, so the popup states what the filing adds up to. */
get ctrReportTotal(): number {
return this.ctrReportLines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
}
/**
* Excel workbook of a CTR report with the customer behind every line. Fetches the lines itself unless the popup
* already holds them, so it works straight from the reports table as well as from inside the popup.
*/
exportCtrReport(report: any): void {
const build = (lines: any[]) => this.buildCtrReportWorkbook(report, lines);
if (this.ctrReportView?.id === report.id && !this.ctrReportLinesLoading) {
build(this.ctrReportLines);
return;
}
this.amlService.getCtrReportLines(report.id).subscribe({
next: (lines) => build(lines || []),
error: () => this.toast(`Could not load the ${report.reportMonth} report to export it`)
});
}
private async buildCtrReportWorkbook(report: any, lines: any[]): Promise<void> {
const total = lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
await exportWorkbook(
[
{
sheetName: `CTR ${report.reportMonth}`,
title: `Cash Transaction Report — ${report.reportMonth}`,
subtitle: `${report.status} · ${lines.length} record${lines.length === 1 ? '' : 's'} · total ₹${total.toLocaleString('en-IN')} · generated ${new Date().toLocaleString('en-IN')}`,
headers: [
'Report Month',
'Customer',
'Customer ID / CKYC Ref',
'CKYC Number',
'Customer Type',
'Account No',
'Branch',
'Account Status',
'Account Opened',
'Transaction ID',
'Transaction Date',
'Direction',
'Line Type',
'Amount (₹)'
],
rows: lines.map((l) => [
l.reportMonth || report.reportMonth,
l.clientName || '',
l.clientExternalId || '',
l.ckycNumber || '',
l.individual === false ? 'Entity' : 'Individual',
l.accountNo || '',
l.branchName || '',
l.accountStatus || '',
toIsoDate(l.accountOpenedOn) || '',
l.transactionId ?? null,
toIsoDate(l.transactionDate) || '',
l.direction || '',
l.connectedGroup ? 'Connected group — month total' : 'Single transaction',
Number(l.amount) || 0
]),
widths: [
12,
28,
22,
18,
14,
20,
18,
16,
16,
14,
16,
12,
28,
18
]
}
],
`CTR-${report.reportMonth}`
);
}
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -145,6 +278,8 @@ export class FiuComponent implements OnInit {
*/
ctrTxns: CtrTransaction[] = [];
ctrLinesLoading = false;
/** Reportable cash transactions already posted but not yet in a generated CTR, listed automatically. */
ctrPendingCount = 0;
ctrLinesError = '';
/**
* ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2.
@@ -682,10 +817,23 @@ export class FiuComponent implements OnInit {
loadCtrLines(): void {
this.ctrLinesLoading = true;
this.ctrLinesError = '';
this.amlService.getCtrReportLines().subscribe({
next: (lines) => {
// The pending list is an extra: if it cannot be read, the generated lines still show.
forkJoin({
generated: this.amlService.getCtrReportLines(),
pending: this.amlService.getCtrPendingLines().pipe(catchError(() => of([] as any[])))
}).subscribe({
next: ({ generated, pending }) => {
this.ctrLinesLoading = false;
this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
const generatedRows = (generated || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
const pendingRows = (pending || []).map((l: any, i: number) => ({
...this.toCtrTransaction(l, generatedRows.length + i + 1),
pendingFiling: true
}));
this.ctrPendingCount = pendingRows.length;
this.ctrTxns = [
...generatedRows,
...pendingRows
];
// Cached so the compliance flags an officer sets below survive a reload, keyed on the rows
// that actually came back rather than on a seeded set.
this.applyStoredCtrCompliance();
@@ -693,6 +841,7 @@ export class FiuComponent implements OnInit {
error: (err: any) => {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrPendingCount = 0;
this.ctrLinesError =
err?.error?.defaultUserMessage ||
'Could not load the generated CTR lines. Generate a report for the month first.';
@@ -820,6 +969,17 @@ export class FiuComponent implements OnInit {
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
this.fileReferenceNo = strCase.strReferenceNo || '';
this.resetChecklist();
this.strCaseDialogRef?.close();
this.strCaseDialogRef = this.dialog.open(this.strCaseDialogTpl, {
width: '1100px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false
});
this.strCaseDialogRef.afterClosed().subscribe(() => {
this.selectedStrCase = null;
this.strCaseDialogRef = null;
});
}
/** Plain-language outcome for a live STR case, shown in the investigation panel. */
@@ -837,6 +997,7 @@ export class FiuComponent implements OnInit {
}
closeStrCaseDetail(): void {
this.strCaseDialogRef?.close();
this.selectedStrCase = null;
}
@@ -871,8 +1032,9 @@ export class FiuComponent implements OnInit {
? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}`
: `STR case #${updated.id} concluded not suspicious — no filing required`
);
this.selectedStrCase = updated;
this.loadAmlStrCases();
// Concluded: close the popup so the case's new status is what the officer sees in the list.
this.closeStrCaseDetail();
},
error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`)
});
@@ -889,8 +1051,8 @@ export class FiuComponent implements OnInit {
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({
next: (updated) => {
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
this.selectedStrCase = updated;
this.loadAmlStrCases();
this.closeStrCaseDetail();
},
error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`)
});
@@ -1784,6 +1946,12 @@ export class FiuComponent implements OnInit {
}
toast(msg: string): void {
// The page's own toast sits behind the popup's backdrop, so while the STR case popup is open the message would be
// there but unseen - which read as the action having done nothing. A snackbar is drawn above the popup.
if (this.strCaseDialogRef || this.ctrReportDialogRef) {
this.snackBar.open(msg, 'OK', { duration: 5000 });
return;
}
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
@@ -0,0 +1,852 @@
<!--
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="bkd">
<div class="bkd-head">
<div class="bkd-head-left">
<div class="bkd-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
<div>
<h2 class="bkd-title">ALM Time-Bucket Breakdown</h2>
<p class="bkd-sub">Day-wise breakdown of FD / RD / Loan from the as-on date</p>
</div>
</div>
<div class="bkd-head-actions">
@if (view === 'config') {
<button type="button" class="bkd-btn" [class.bkd-btn-on]="editMode" (click)="toggleEdit()">
{{ editMode ? 'Done editing lines' : 'Edit lines' }}
</button>
<button type="button" class="bkd-btn" (click)="reset()">Reset to RBI defaults</button>
}
<button class="bkd-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
</div>
</div>
<div class="bkd-tabs" role="tablist">
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'days'" (click)="view = 'days'">
Day-wise breakdown
</button>
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'config'" (click)="view = 'config'">
Configuration table
</button>
</div>
<div class="bkd-body">
@if (view === 'config') {
<p class="bkd-note">
<fa-icon icon="info-circle"></fa-icon>&nbsp; Each line shows which bucket its money goes to. You can change the
<strong>%</strong> and the rule. Changes are saved in this browser straight away. Hover over a line name to see
why it is slotted that way.
</p>
@if (problemCount > 0) {
<p class="bkd-warn bkd-banner">
{{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow
totals below.
</p>
}
}
@if (view === 'config') {
<!-- The date the figures are as on, and how they are read -->
<div class="bkd-toolbar">
<label>
<span>As-on date</span>
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="loadBreakdown()" aria-label="As-on date" />
</label>
<label>
<span>Amount unit</span>
<select [(ngModel)]="unit" aria-label="Unit">
@for (u of units; track u.value) {
<option [value]="u.value">{{ u.label }}</option>
}
</select>
</label>
<div class="bkd-toolbar-state">
@if (loading) {
<span>Loading data from accounts…</span>
} @else if (loadError) {
<span class="bkd-warn">{{ loadError }}</span>
} @else if (breakdown) {
<span class="bkd-ok"> FD, RD and Loan data as on {{ breakdown.asOnDate }}. </span>
}
</div>
</div>
}
@if (view === 'days') {
<section class="bkd-days-view">
<h4 class="bkd-h4 bkd-h4-first">1. Select</h4>
<div class="bkd-modes" role="tablist">
<button type="button" class="bkd-mode" [class.on]="selectBy === 'types'" (click)="setSelectBy('types')">
By account type
</button>
<button type="button" class="bkd-mode" [class.on]="selectBy === 'accounts'" (click)="setSelectBy('accounts')">
Individual accounts
</button>
</div>
@if (selectBy === 'types') {
<p class="bkd-days-intro">Tick one or more. You can pick from both sides together.</p>
@for (grp of pickGroups; track grp.side) {
<div class="bkd-pick-group">
<div
class="bkd-pick-title"
[class.bkd-pick-liab]="grp.side === 'liability'"
[class.bkd-pick-asset]="grp.side === 'asset'"
>
{{ grp.title }}
</div>
<div class="bkd-pick-list">
@for (t of grp.items; track t.value) {
<button
type="button"
class="bkd-pick-opt"
[class.on]="isTypePicked(t.value)"
role="checkbox"
[attr.aria-checked]="isTypePicked(t.value)"
(click)="toggleType(t.value)"
>
<span class="bkd-pick-box">{{ isTypePicked(t.value) ? '✓' : '' }}</span>
{{ t.label }}
</button>
}
</div>
</div>
}
<div class="bkd-pick-actions">
<button type="button" class="bkd-link" (click)="selectAllTypes()">Select all</button>
<button type="button" class="bkd-link" (click)="clearTypes()">Clear</button>
</div>
} @else {
<div class="bkd-search">
<input
type="text"
[(ngModel)]="searchText"
(ngModelChange)="onSearchInput()"
placeholder="Search by account number or customer name"
aria-label="Search accounts"
/>
<select [(ngModel)]="searchKind" (ngModelChange)="onSearchInput()" aria-label="Kind of account">
<option value="">All accounts</option>
<option value="FD">FD</option>
<option value="RD">RD</option>
<option value="LOAN">Loan</option>
</select>
</div>
@if (searching) {
<p class="bkd-days-note">Searching…</p>
} @else if (searchError) {
<p class="bkd-warn">{{ searchError }}</p>
} @else if (searchText.trim().length < 2) {
<p class="bkd-days-note">Type at least 2 characters of an account number or a customer name.</p>
} @else if (!searchResults.length) {
<p class="bkd-days-note">No accounts found.</p>
}
@if (searchResults.length) {
<div class="bkd-hits">
@for (hit of searchResults; track hit.kind + '-' + hit.id) {
<button type="button" class="bkd-hit" [class.picked]="isPicked(hit)" (click)="pick(hit)">
<span class="bkd-hit-kind">{{ hit.kind }}</span>
<span class="bkd-hit-main">
<strong>{{ hit.accountNo }}</strong> &middot; {{ hit.customer }}
<small
>{{ hit.product }}
@if (hit.date) {
&middot; matures {{ hit.date | date: 'dd-MMM-yyyy' }}
}
</small>
</span>
<span class="bkd-hit-amt">{{ amountText(hit.amount) }}</span>
<span class="bkd-hit-state">{{ isPicked(hit) ? 'Selected' : 'Select' }}</span>
</button>
}
</div>
}
@if (picked.length) {
<div class="bkd-picked">
<strong>Selected ({{ picked.length }})</strong>
@for (p of picked; track p.kind + '-' + p.id) {
<span class="bkd-pill-sel">
{{ p.kind }} {{ p.accountNo }}
<button type="button" (click)="unpick(p)" aria-label="Remove">&#10005;</button>
</span>
}
<button type="button" class="bkd-link" (click)="clearPicked()">Clear all</button>
</div>
}
}
<h4 class="bkd-h4">2. Date and unit</h4>
<div class="bkd-submit-row">
<label>
<span>As-on date</span>
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="onAsOnChange()" aria-label="As-on date" />
</label>
<label>
<span>Amount unit</span>
<select [(ngModel)]="unit" aria-label="Amount unit">
@for (u of units; track u.value) {
<option [value]="u.value">{{ u.label }}</option>
}
</select>
</label>
<button type="button" class="bkd-btn bkd-btn-on bkd-submit" [disabled]="!canSubmit" (click)="submitReport()">
Submit
</button>
</div>
@if (reportShown && selectBy === 'types') {
<section class="bkd-report-out">
<h4 class="bkd-h4 bkd-report-title">
{{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }}
<small>as on {{ asOnIso }} &middot; Amounts in {{ unitWord }}</small>
</h4>
@if (loading) {
<p class="bkd-days-note">Loading data from accounts…</p>
}
@if (loadError) {
<p class="bkd-warn">{{ loadError }}</p>
}
@if (breakdown) {
@for (c of typeColumns; track c.value) {
@if (c.notice) {
<p class="bkd-warn bkd-banner">{{ c.label }}: {{ c.notice }}</p>
}
}
<div class="bkd-acc-wrap bkd-matrix-wrap">
<table class="bkd-tbl bkd-matrix">
<thead>
<tr>
<th>Bucket</th>
<th>Days</th>
<th>Dates</th>
@for (c of typeColumns; track c.value) {
<th
class="bkd-num"
[class.bkd-pick-liab]="c.side === 'liability'"
[class.bkd-pick-asset]="c.side === 'asset'"
>
{{ c.label }}
</th>
}
@if (typeHasLiability && typeHasAsset) {
<th class="bkd-num bkd-pick-liab">Liabilities total</th>
<th class="bkd-num bkd-pick-asset">Assets total</th>
} @else if (typeColumns.length > 1) {
<th class="bkd-num">Total</th>
}
</tr>
</thead>
<tbody>
@for (r of typeMatrix; track r.label) {
<tr [class.bkd-dim]="!r.liab && !r.asset">
<td class="bkd-b-name">{{ r.label }}</td>
<td>{{ r.days }}</td>
<td>{{ r.dates }}</td>
@for (v of r.cells; track $index) {
<td class="bkd-num">{{ amountText(v) }}</td>
}
@if (typeHasLiability && typeHasAsset) {
<td class="bkd-num">{{ amountText(r.liab) }}</td>
<td class="bkd-num">{{ amountText(r.asset) }}</td>
} @else if (typeColumns.length > 1) {
<td class="bkd-num">{{ amountText(r.liab + r.asset) }}</td>
}
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
@for (c of typeColumns; track c.value) {
<td class="bkd-num">{{ amountText(c.total) }}</td>
}
@if (typeHasLiability && typeHasAsset) {
<td class="bkd-num">{{ amountText(liabilityGrandTotal) }}</td>
<td class="bkd-num">{{ amountText(assetGrandTotal) }}</td>
} @else if (typeColumns.length > 1) {
<td class="bkd-num">{{ amountText(liabilityGrandTotal + assetGrandTotal) }}</td>
}
</tr>
</tfoot>
</table>
</div>
<p class="bkd-days-note">
Deposits and term loans are slotted by their own dates: a deposit once on its maturity date, a loan once
for each unpaid instalment on its due date. Anything already due goes in the first bucket (a loan
instalment follows the Overdue rule). CC / OD and NPA loans are divided by the bucket-wise % set for
them in the Configuration table. Deposit amounts are balances, loan amounts are principal; interest is a
separate line of the statement.
</p>
}
</section>
}
@if (reportShown && selectBy === 'accounts') {
<section class="bkd-report-out">
<h4 class="bkd-h4 bkd-report-title">
{{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }}
<small>as on {{ asOnIso }} &middot; Amounts in {{ unitWord }}</small>
</h4>
@if (reportLoading) {
<p class="bkd-days-note">Loading…</p>
}
@if (reportError) {
<p class="bkd-warn">{{ reportError }}</p>
}
@if (report; as rep) {
<table class="bkd-tbl bkd-focus-tbl">
<thead>
<tr>
<th>Bucket</th>
<th>Days</th>
<th>Dates</th>
<th class="bkd-num">Amount</th>
<th class="bkd-num">%</th>
<th class="bkd-num">Entries</th>
</tr>
</thead>
<tbody>
@for (r of reportBuckets; track r.label) {
<tr [class.bkd-dim]="!r.amount">
<td class="bkd-b-name">{{ r.label }}</td>
<td>{{ r.days }}</td>
<td>{{ r.dates }}</td>
<td class="bkd-num">{{ amountText(r.amount) }}</td>
<td class="bkd-num">{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}</td>
<td class="bkd-num">{{ r.count || '' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="bkd-num">{{ amountText(rep.total) }}</td>
<td class="bkd-num">{{ rep.total ? '100%' : '' }}</td>
<td class="bkd-num">{{ rep.count }}</td>
</tr>
</tfoot>
</table>
<p class="bkd-days-note">
A deposit counts once, on its maturity date; a loan counts once for each unpaid instalment, on its due
date. Anything already due goes in the first bucket (a loan instalment follows the Overdue rule).
Deposit amounts are balances, loan amounts are the principal of each instalment; interest is a separate
line of the statement.
</p>
<h4 class="bkd-h4">Each entry</h4>
<div class="bkd-acc-wrap">
<table class="bkd-tbl bkd-acc">
<thead>
<tr>
<th>Account</th>
<th>Customer</th>
<th>Product</th>
<th>Due / maturity date</th>
<th class="bkd-num">Days</th>
<th>Bucket</th>
<th>Status</th>
<th class="bkd-num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of rep.rows; track $index) {
<tr>
<td class="bkd-b-name">
{{ r.kind }} {{ r.accountNo }}
@if (r.installment) {
<span class="bkd-b-hint">instalment {{ r.installment }}</span>
}
</td>
<td>{{ r.customer }}</td>
<td>
{{ r.product }}
@if (r.note) {
<div class="bkd-warn">{{ r.note }}</div>
}
</td>
<td>{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}</td>
<td class="bkd-num">{{ r.days === null ? '' : r.days }}</td>
<td>{{ bucketName(r.bucket) }}</td>
<td>{{ r.status }}</td>
<td class="bkd-num">{{ amountText(r.amount) }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="bkd-empty">Nothing to show for these accounts on this date.</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
</section>
} @else {
<section class="bkd-setup">
<div class="bkd-range-head">
<h4 class="bkd-h4 bkd-h4-first">Bucket ranges</h4>
<button type="button" class="bkd-btn bkd-btn-sm" [class.bkd-btn-on]="rangeEdit" (click)="toggleRangeEdit()">
{{ rangeEdit ? 'Done' : 'Edit' }}
</button>
</div>
<p class="bkd-days-intro">
@if (rangeEdit) {
Change the Start / End of a bucket, or add and remove buckets. When you change the End of a bucket, the
Start of the next one and its heading change with it.
} @else {
Start and End of each bucket. Click Edit to change, add or remove buckets.
}
</p>
<table class="bkd-tbl bkd-range">
<thead>
<tr>
<th>#</th>
<th>Start</th>
<th>End</th>
<th>Heading</th>
<th>Days / Dates</th>
@if (rangeEdit) {
<th></th>
}
</tr>
</thead>
<tbody>
@for (b of cfg.buckets; track $index) {
<tr>
<td class="bkd-range-n">{{ $index + 1 }}</td>
<td>
@if (fromOf($index); as f) {
@if (rangeEdit) {
<span class="bkd-span-edit">
<input
type="number"
min="1"
[ngModel]="f.n"
(ngModelChange)="setFrom($index, { n: $event })"
aria-label="Start"
/>
<select
[ngModel]="f.unit"
(ngModelChange)="setFrom($index, { unit: $event })"
aria-label="Start unit"
>
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
</span>
} @else {
<span class="bkd-range-val">{{ f.n }} {{ unitLetter(f.unit) }}</span>
}
}
</td>
<td>
@if (b.upTo; as t) {
@if (rangeEdit) {
<span class="bkd-span-edit">
<input
type="number"
min="1"
[ngModel]="t.n"
(ngModelChange)="setTo($index, { n: $event })"
aria-label="End"
/>
<select
[ngModel]="t.unit"
(ngModelChange)="setTo($index, { unit: $event })"
aria-label="End unit"
>
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
</span>
} @else {
<span class="bkd-range-val">{{ t.n }} {{ unitLetter(t.unit) }}</span>
}
} @else {
<span class="bkd-none">end</span>
}
</td>
<td class="bkd-b-name">
{{ b.label }}
@if (rangeProblem($index); as problem) {
<div class="bkd-warn">{{ problem }}</div>
}
</td>
<td class="bkd-b-hint">
@if (windows[$index]; as w) {
{{ dayRange(w) }} &middot; {{ dateRange(w) }}
}
</td>
@if (rangeEdit) {
<td>
@if (cfg.buckets.length > 2) {
<button
type="button"
class="bkd-x"
(click)="removeBucket($index)"
matTooltip="Remove this bucket"
aria-label="Remove bucket"
>
&#10005;
</button>
}
</td>
}
</tr>
}
</tbody>
</table>
@if (rangeEdit) {
<!-- A bucket the rules did not have yesterday: add it here, it lands in date order -->
<div class="bkd-addbucket">
<span class="bkd-addbucket-title">+ Add a bucket ending at:</span>
<span class="bkd-span-edit">
<input type="number" min="1" [(ngModel)]="newEnd.n" aria-label="End of the new bucket" />
<select [(ngModel)]="newEnd.unit" aria-label="Unit of the new bucket">
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
</span>
<button type="button" class="bkd-btn bkd-btn-sm" (click)="addBucket()">Add</button>
<span class="bkd-b-hint">
It is placed in the right position automatically and splits the bucket it falls inside into two.
</span>
</div>
}
<div class="bkd-calc">
<label>
<span>Check a maturity date</span>
<input type="date" [(ngModel)]="maturityIso" aria-label="Maturity date" />
</label>
@if (dayCheck; as c) {
<div class="bkd-calc-out">
@if (c.due) {
<strong>Already due</strong> ({{ -c.days }} days ago) &rarr; <strong>{{ c.bucket }}</strong>
} @else {
<strong>{{ c.days }} days left</strong> &rarr; <strong>{{ c.bucket }}</strong>
}
</div>
} @else {
<div class="bkd-calc-out bkd-none">Pick a maturity date and its bucket will show here.</div>
}
</div>
</section>
<!-- ═══ The configuration table, laid out like the filed statement ═══ -->
@for (sec of sections; track sec.side) {
<div class="bkd-sheet-wrap">
<table class="bkd-sheet">
<thead>
<tr>
<th colspan="3" rowspan="2" class="s-title">{{ sec.title }}</th>
<th [attr.colspan]="cfg.buckets.length" class="s-span">RESIDUAL MATURITY</th>
<th rowspan="2" class="s-total-h">Total</th>
@if (editMode) {
<th rowspan="2" class="s-tools-h">Line</th>
}
</tr>
<tr>
@for (b of cfg.buckets; track $index) {
<th class="s-bucket">{{ b.label }}</th>
}
</tr>
</thead>
<tbody>
@for (item of itemsOf(sec.side); track item.id) {
@if (item.header) {
<tr class="s-head">
<td class="s-ref">
@if (editMode) {
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
} @else {
{{ item.ref }}
}
</td>
<td colspan="2" class="s-name">
@if (editMode) {
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
} @else {
{{ item.name }}
}
</td>
<td [attr.colspan]="cfg.buckets.length + 1" class="s-head-fill"></td>
@if (editMode) {
<td class="s-tools">
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
&#10005;
</button>
</td>
}
</tr>
} @else {
<tr [class.s-off]="item.included === false">
<td class="s-ref">
@if (editMode) {
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
} @else {
{{ item.ref }}
}
</td>
<td
class="s-name"
[class.s-sub]="item.level === 1"
[matTooltip]="item.reason"
matTooltipPosition="above"
>
@if (editMode) {
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
} @else {
{{ item.name }}
}
</td>
<td class="s-rule">
<select
[ngModel]="item.rule"
(ngModelChange)="ruleChanged(item, $event)"
aria-label="Rule"
[matTooltip]="item.reason"
>
@for (r of ruleOptions; track r.value) {
<option [value]="r.value">{{ r.label }}</option>
}
</select>
@if (item.rule === 'maturity' || item.rule === 'behavioural') {
<!-- Which accounts fill a due-date line, or give a percentage-split line its total -->
<select
class="s-src"
[(ngModel)]="item.source"
(ngModelChange)="changed()"
aria-label="Which accounts the amount comes from"
matTooltip="Which accounts the amount of this line comes from"
>
@for (o of sourceOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
}
</td>
@if (item.rule === 'maturity') {
@if (autoAmounts(item); as amounts) {
<!-- Filled by the accounts themselves, each in the bucket of its own date -->
@for (v of amounts; track $index) {
<td class="s-auto" [class.s-auto-zero]="!v">{{ amountText(v) }}</td>
}
<td class="s-tot s-auto-total" [matTooltip]="autoAccounts(item) + ' accounts'">
{{ amountText(autoTotal(amounts)) }}
</td>
} @else {
<td [attr.colspan]="cfg.buckets.length" class="s-due">
@if (!item.source) {
This line has to be filled by hand: it has no account source yet
} @else if (loading) {
Data aa raha hai…
} @else {
No data came from the accounts
}
</td>
<td class="s-tot">&mdash;</td>
}
} @else if (item.rule === 'fixed') {
<!-- A fixed line sits in one bucket: click the bucket it should move to -->
@for (b of cfg.buckets; track $index) {
<td
class="s-cell s-pick"
[class.s-cell-on]="share(item, $index) !== null"
role="button"
tabindex="0"
[attr.aria-label]="'Put it in ' + b.label"
[matTooltip]="share(item, $index) !== null ? b.label : 'Yahan le jaayein: ' + b.label"
(click)="setFixed(item, $index)"
(keydown.enter)="setFixed(item, $index)"
(keydown.space)="setFixed(item, $index); $event.preventDefault()"
>
{{ share(item, $index) !== null ? '100%' : '' }}
</td>
}
} @else {
@for (b of cfg.buckets; track $index) {
<td class="s-cell s-cell-input" [class.s-cell-on]="share(item, $index) !== null">
<input
type="number"
min="0"
max="100"
step="any"
placeholder="%"
[ngModel]="share(item, $index)"
(ngModelChange)="setShare(item, $index, $event)"
[attr.aria-label]="b.label"
/>
</td>
}
<td
class="s-tot"
[class.s-tot-ok]="rowState(item) === 'ok'"
[class.s-tot-bad]="rowState(item) === 'bad'"
[class.s-tot-todo]="rowState(item) === 'todo'"
>
{{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }}
</td>
}
@if (editMode) {
<td class="s-tools">
<label class="s-incl" matTooltip="Show in report">
<input
type="checkbox"
[ngModel]="item.included !== false"
(ngModelChange)="item.included = $event; changed()"
aria-label="In report"
/>
</label>
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
&#10005;
</button>
</td>
}
</tr>
}
}
</tbody>
</table>
@if (editMode) {
<button type="button" class="bkd-btn bkd-btn-sm s-add" (click)="addLine(sec.side)">
+ Add {{ sec.title === 'OUTFLOWS' ? 'an Outflow' : 'an Inflow' }} line
</button>
}
</div>
}
@if (editMode) {
<div class="bkd-bucket-edit">
<div class="bkd-editor-sub">Bucket names and day span</div>
@for (b of cfg.buckets; track $index) {
<div class="bkd-bucket-edit-row">
<span class="bkd-step-n">{{ $index + 1 }}</span>
<input type="text" [(ngModel)]="b.label" (ngModelChange)="changed()" aria-label="Bucket name" />
<input type="text" [(ngModel)]="b.hint" (ngModelChange)="changed()" aria-label="Bucket description" />
<div class="bkd-span">
@if (b.upTo) {
<input
type="number"
min="1"
[(ngModel)]="b.upTo.n"
(ngModelChange)="bucketsChanged()"
aria-label="How far it reaches"
/>
<select [(ngModel)]="b.upTo.unit" (ngModelChange)="bucketsChanged()" aria-label="Unit">
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
} @else {
<span class="bkd-none">No end</span>
}
<label class="bkd-check">
<input
type="checkbox"
[ngModel]="!b.upTo"
(ngModelChange)="setOpenEnded(b, $event)"
aria-label="No end"
/>
No end
</label>
</div>
</div>
}
@if (cfg.overdue; as o) {
<div class="bkd-editor-sub bkd-overdue-title">Overdue (due but not received): which bucket</div>
<div class="bkd-overdue">
<label>
<span>Overdue less than a month</span>
<select [(ngModel)]="o.recent" (ngModelChange)="bucketsChanged()" aria-label="Recent overdue bucket">
@for (b of cfg.buckets; track $index) {
<option [ngValue]="$index">{{ b.label }}</option>
}
</select>
</label>
<label>
<span>Overdue more than a month</span>
<select [(ngModel)]="o.old" (ngModelChange)="bucketsChanged()" aria-label="Old overdue bucket">
@for (b of cfg.buckets; track $index) {
<option [ngValue]="$index">{{ b.label }}</option>
}
</select>
</label>
</div>
}
</div>
}
<!-- What the accounts could not put in a date bucket, so nothing is silently left out -->
@if (breakdown; as b) {
<section class="bkd-apart-box">
<h4 class="bkd-h4">Shown separately (not slotted by date)</h4>
<ul class="bkd-apart">
@for (n of b.notSlotted; track n.key) {
<li>
<strong>{{ n.label }}</strong> &mdash; {{ n.accounts }} loan, outstanding
{{ amountText(n.outstanding) }}
<div class="bkd-b-hint">{{ n.why }}</div>
</li>
}
</ul>
<ul class="bkd-notes">
@for (note of b.notes; track note) {
<li>{{ note }}</li>
}
</ul>
</section>
}
<!-- ═══ What the report will show ═══ -->
<section class="bkd-report">
<h3 class="bkd-h3">What the report will show</h3>
<div class="bkd-report-card">
<div class="r-title">Statement of Structural Liquidity as on {{ asOnIso }}</div>
<div class="r-sub">( Amounts in {{ unitWord }} )</div>
</div>
<ul class="bkd-report-list">
<li>
Two tables, <strong>OUTFLOWS</strong> and <strong>INFLOWS</strong>, with the lines above. Each line’s amount
sits in its bucket, with a <strong>Total</strong> in the last column.
</li>
<li><strong>A. TOTAL OUTFLOWS</strong> &mdash; the sum of all outflow lines, bucket by bucket.</li>
<li><strong>B. TOTAL INFLOWS</strong> &mdash; the sum of all inflow lines, bucket by bucket.</li>
<li><strong>C. MISMATCH (B − A)</strong> &mdash; inflow minus outflow, in each bucket.</li>
<li>
<strong>D. CUMULATIVE MISMATCH</strong> &mdash; running total of C, from the first bucket up to that bucket.
</li>
<li><strong>E. C as % to A</strong> &mdash; mismatch divided by outflow, times 100.</li>
@for (l of cfg.limits; track $index) {
<li class="bkd-limit">
<strong>{{ cfg.buckets[l.bucket]?.label }}</strong
>: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does.
</li>
}
</ul>
<p class="bkd-days-note">
Where the amount comes from: <strong>By due date</strong> lines take their data from FD, RD and Loan accounts.
<strong>Fixed</strong>, <strong>Split</strong> and <strong>By study</strong> lines take the ledger (GL)
balance and apply the % above. Lines with no register have to be filled in by hand.
</p>
</section>
}
</div>
</div>
@@ -0,0 +1,962 @@
/**
* 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, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, catchError, debounceTime, of, switchMap } from 'rxjs';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { businessTodayIso } from 'app/core/utils/business-today';
import {
AlmBucket,
AlmConfig,
AlmItem,
AlmRule,
AlmSide,
AlmPlacement,
AlmSource,
AlmSpan,
AlmUnit,
RULE_OPTIONS,
SHARE_SOURCES,
SOURCE_OPTIONS,
defaultConfig
} from './alm-bucket-breakdown.config';
import {
BucketWindow,
addSpan,
bucketIndexFor,
bucketLabel,
bucketWindows,
dayRangeText,
daysBetween,
formatDate,
fromSpanOf,
parseIsoDate,
setFromSpan
} from './alm-bucket.util';
import { AccountHit, AccountRow, AlmMaturityService, MaturityBreakdown, SelectedReport } from './alm-maturity.service';
type AmountUnit = 'rupee' | 'lakh' | 'crore';
/** How a line's shares stand: whether the bank still has something to fill in. */
type RowState = 'spread' | 'ok' | 'todo' | 'bad';
/** Which of the server's account groups each source adds together. */
const SOURCE_GROUPS: Record<'FD_RD' | 'FD' | 'RD' | 'LOAN', string[]> = {
FD_RD: [
'FD',
'RD'
],
FD: ['FD'],
RD: ['RD'],
LOAN: ['LOAN']
};
/** The two tables of the statement, in the order it prints them. */
const SECTIONS: { side: AlmSide; title: string }[] = [
{ side: 'liability', title: 'OUTFLOWS' },
{ side: 'asset', title: 'INFLOWS' }
];
/**
* Where the browser keeps the bank's edits. Same arrangement as the OSS GL mapping: there is no
* backend table for this yet, so the setting belongs to the browser it was made in. The suffix is
* the shape of what is stored; a different shape starts again from RBI's defaults.
*/
const STORAGE_KEY = 'alm_bucket_breakdown_config_v2';
/**
* ALM Time-Bucket Breakdown — the configuration of the Structural Liquidity Statement.
*
* One table laid out like the statement the bank files: the same numbered Outflow and Inflow lines
* down the side, the same residual-maturity buckets across the top. Each line says how its balance
* is slotted — wholly in one bucket, by the due date of each account, or by percentages the bank
* sets. The statement itself is computed from this configuration.
*
* Rules start from RBI's Urban Co-operative Bank defaults but none is fixed: every line, bucket and
* percentage can be edited, and "Reset" returns to RBI's.
*/
@Component({
selector: 'mifosx-alm-bucket-breakdown-dialog',
standalone: true,
templateUrl: './alm-bucket-breakdown-dialog.component.html',
styleUrls: ['./alm-bucket-breakdown-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatTooltipModule,
FaIconComponent
]
})
export class AlmBucketBreakdownDialogComponent {
private dialogRef = inject(MatDialogRef<AlmBucketBreakdownDialogComponent>);
private service = inject(AlmMaturityService);
private destroyRef = inject(DestroyRef);
readonly ruleOptions = RULE_OPTIONS;
readonly sourceOptions = SOURCE_OPTIONS;
readonly sections = SECTIONS;
cfg: AlmConfig = this.load();
/** Which tab is open: the day breakdown of one product, or the statement's configuration table. */
view: 'days' | 'config' = 'days';
/** Lines can be renamed, added, removed and left out of the report only while this is on. */
editMode = false;
/** The date the buckets are counted from — the business date, not the browser's clock. */
asOnIso = businessTodayIso();
/** Maturity typed into the day calculator. */
maturityIso = '';
/** Amounts come back in rupees; the bank reads them in lakhs unless told otherwise. */
readonly units: { value: AmountUnit; label: string; divisor: number }[] = [
{ value: 'rupee', label: '₹ (rupaye)', divisor: 1 },
{ value: 'lakh', label: 'Lakh', divisor: 100_000 },
{ value: 'crore', label: 'Crore', divisor: 10_000_000 }
];
unit: AmountUnit = 'lakh';
/** FD / RD / loan totals per bucket for the as-on date, from the server. */
breakdown: MaturityBreakdown | null = null;
loading = false;
loadError = '';
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
private loadSeq = 0;
constructor() {
this.watchSearch();
this.loadBreakdown();
}
// ── The configuration table ──
/** The lines of one table, headings included, in statement order. */
itemsOf(side: AlmSide): AlmItem[] {
return this.cfg.items.filter((i) => i.side === side);
}
/** Columns across a table: reference, line, rule, every bucket, total, and the row tools while editing. */
get columnCount(): number {
return 3 + this.cfg.buckets.length + 1 + (this.editMode ? 1 : 0);
}
/** Share of a line's balance that goes to one bucket, or null when none does. */
share(item: AlmItem, bucket: number): number | null {
const placed = item.placements.filter((p) => p.bucket === bucket);
if (!placed.length) {
return null;
}
const explicit = placed.filter((p) => p.pct !== null && p.pct !== undefined && `${p.pct}` !== '');
if (explicit.length) {
return explicit.reduce((sum, p) => sum + Number(p.pct), 0);
}
// No share written down: a line with one bucket puts the whole balance there.
return item.placements.length === 1 ? 100 : null;
}
/** Set the share going to one bucket; empty or zero takes the bucket out of the line. */
setShare(item: AlmItem, bucket: number, raw: number | string | null): void {
const value = raw === null || raw === '' ? 0 : Math.max(0, Math.min(100, Number(raw)));
const rest = item.placements.filter((p) => p.bucket !== bucket);
if (value > 0 && !Number.isNaN(value)) {
const kept = item.placements.find((p) => p.bucket === bucket);
rest.push({ bucket, pct: value, label: kept?.label });
}
item.placements = rest.sort((a, b) => a.bucket - b.bucket);
this.changed();
}
/**
* The amounts a due-date line gets from its accounts, in rupees; null while there is nothing to
* show (no source chosen, not loaded yet, or the server could not answer).
*/
autoAmounts(item: AlmItem): number[] | null {
return item.rule === 'maturity' ? this.itemAmounts(item) : null;
}
/**
* What a line has in each bucket, in rupees. Deposits and term loans come bucket by bucket from
* their own dates; cash-credit and NPA loans come as one outstanding figure that the line's own
* percentages divide. Null when the line has no accounts behind it, or nothing has loaded yet.
*/
itemAmounts(item: AlmItem | null): number[] | null {
return item?.source ? this.amountsOf(item.source, item) : null;
}
/**
* What one kind of account has in each bucket. For cash-credit and NPA loans the percentages come
* from the statement line that names that kind, and are zero until that line has them.
*/
private amountsOf(source: AlmSource, line: AlmItem | null): number[] | null {
if (!this.breakdown) {
return null;
}
if (SHARE_SOURCES.includes(source)) {
const outstanding = this.apartLine(source)?.outstanding ?? 0;
return this.cfg.buckets.map((_b, i) => (outstanding * (line ? (this.share(line, i) ?? 0) : 0)) / 100);
}
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS];
const groups = this.breakdown.groups.filter((g) => keys?.includes(g.key));
if (!groups.length) {
return null;
}
return this.cfg.buckets.map((_b, i) => groups.reduce((sum, g) => sum + (g.buckets[i] ?? 0), 0));
}
/** The loans the server reports apart from the date buckets: CC/OD, or NPA. */
private apartLine(source: AlmSource): { outstanding: number; accounts: number } | undefined {
const key = source === 'CCOD' ? 'REVOLVING' : 'NPA';
return this.breakdown?.notSlotted.find((n) => n.key === key);
}
/** Accounts behind a line, for the tooltip and the day view's total. */
autoAccounts(item: AlmItem | null): number {
const source = item?.source;
if (!source || !this.breakdown) {
return 0;
}
if (SHARE_SOURCES.includes(source)) {
return this.apartLine(source)?.accounts ?? 0;
}
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS] ?? [];
return this.breakdown.groups.filter((g) => keys.includes(g.key)).reduce((sum, g) => sum + g.accounts, 0);
}
autoTotal(amounts: number[]): number {
return amounts.reduce((sum, v) => sum + v, 0);
}
// ── Picking kinds of account ──
/** Whether the day-wise breakdown is by kind of account, or for individual accounts found by search. */
selectBy: 'types' | 'accounts' = 'types';
/** The kinds of account that can be picked, on the liability and the asset side. */
readonly pickTypes: { value: AlmSource; label: string; side: AlmSide }[] = [
{ value: 'FD', label: 'Fixed Deposits (FD)', side: 'liability' },
{ value: 'RD', label: 'Recurring Deposits (RD)', side: 'liability' },
{ value: 'LOAN', label: 'Term Loans', side: 'asset' },
{ value: 'CCOD', label: 'CC / OD Loans', side: 'asset' },
{ value: 'NPA', label: 'NPA Loans', side: 'asset' }
];
/** The kinds picked; any number of them, from either side. */
pickedTypes: AlmSource[] = [
'FD',
'RD'
];
get pickGroups(): { side: AlmSide; title: string; items: { value: AlmSource; label: string; side: AlmSide }[] }[] {
return [
{
side: 'liability',
title: 'Liabilities: deposits (customers)',
items: this.pickTypes.filter((t) => t.side === 'liability')
},
{ side: 'asset', title: 'Assets: loans (customers)', items: this.pickTypes.filter((t) => t.side === 'asset') }
];
}
setSelectBy(mode: 'types' | 'accounts'): void {
this.selectBy = mode;
this.reportShown = false;
}
isTypePicked(value: AlmSource): boolean {
return this.pickedTypes.includes(value);
}
/** Tick or untick a kind. Any number can be ticked together; a report already open is put away. */
toggleType(value: AlmSource): void {
this.pickedTypes = this.isTypePicked(value) ? this.pickedTypes.filter((v) => v !== value) : [
...this.pickedTypes,
value
];
this.reportShown = false;
}
selectAllTypes(): void {
this.pickedTypes = this.pickTypes.map((t) => t.value);
this.reportShown = false;
}
clearTypes(): void {
this.pickedTypes = [];
this.reportShown = false;
}
/** One column per kind picked: what it has in every bucket, and a note where something is still to be filled in. */
get typeColumns(): {
value: AlmSource;
label: string;
side: AlmSide;
amounts: number[];
total: number;
notice: string;
}[] {
return this.pickTypes
.filter((t) => this.isTypePicked(t.value))
.map((t) => {
const amounts =
this.amountsOf(t.value, this.cfg.items.find((i) => !i.header && i.source === t.value) ?? null) ??
this.cfg.buckets.map(() => 0);
const total = this.autoTotal(amounts);
const outstanding = SHARE_SOURCES.includes(t.value) ? (this.apartLine(t.value)?.outstanding ?? 0) : 0;
const notice =
outstanding > 0 && total === 0
? `The bucket-wise % for this kind are not filled in yet, so nothing is divided. Fill them in the Configuration table (outstanding is ${this.amountText(outstanding)}).`
: '';
return { value: t.value, label: t.label, side: t.side, amounts, total, notice };
});
}
/** One row per bucket across the picked kinds, with the liability and asset sides added up separately. */
get typeMatrix(): { label: string; days: string; dates: string; cells: number[]; liab: number; asset: number }[] {
const cols = this.typeColumns;
return this.windows.map((w, i) => {
const cells = cols.map((c) => c.amounts[i] ?? 0);
return {
label: this.cfg.buckets[i]?.label ?? w.label,
days: dayRangeText(w),
dates: this.dateRange(w),
cells,
liab: cols.reduce((sum, c, k) => sum + (c.side === 'liability' ? cells[k] : 0), 0),
asset: cols.reduce((sum, c, k) => sum + (c.side === 'asset' ? cells[k] : 0), 0)
};
});
}
get liabilityGrandTotal(): number {
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'liability' ? c.total : 0), 0);
}
get assetGrandTotal(): number {
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'asset' ? c.total : 0), 0);
}
get typeHasLiability(): boolean {
return this.typeColumns.some((c) => c.side === 'liability');
}
get typeHasAsset(): boolean {
return this.typeColumns.some((c) => c.side === 'asset');
}
// ── Picking accounts one by one ──
/** What is typed in the search box. */
searchText = '';
/** Which kind of account the search looks in: all, or one kind. */
searchKind: '' | 'FD' | 'RD' | 'LOAN' = '';
searchResults: AccountHit[] = [];
searching = false;
searchError = '';
/** The accounts picked so far. */
picked: AccountHit[] = [];
private searchTyped = new Subject<void>();
/** Whether the report of the picked accounts is on screen: only after Submit, and until the picks or the date change. */
reportShown = false;
report: SelectedReport | null = null;
reportLoading = false;
reportError = '';
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
private reportSeq = 0;
/** Set up the search so it waits for typing to pause, and a slow answer to an old search never replaces a newer one. */
private watchSearch(): void {
this.searchTyped
.pipe(
debounceTime(300),
switchMap(() =>
this.service.searchAccounts(this.searchText.trim(), this.searchKind).pipe(
catchError((err) => {
this.searchError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'Search failed. The backend needs a restart with the new endpoint.';
return of({ results: [] as AccountHit[] });
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((found) => {
this.searching = false;
this.searchResults = found.results;
});
}
/** Typing or changing the kind: search again once typing pauses. */
onSearchInput(): void {
this.searchError = '';
if (this.searchText.trim().length < 2) {
this.searchResults = [];
this.searching = false;
return;
}
this.searching = true;
this.searchTyped.next();
}
/** Whether an account is already picked. */
isPicked(hit: AccountHit): boolean {
return this.picked.some((p) => p.kind === hit.kind && p.id === hit.id);
}
pick(hit: AccountHit): void {
if (!this.isPicked(hit)) {
this.picked = [
...this.picked,
hit
];
this.reportShown = false;
}
}
unpick(hit: AccountHit): void {
this.picked = this.picked.filter((p) => !(p.kind === hit.kind && p.id === hit.id));
this.reportShown = false;
}
clearPicked(): void {
this.picked = [];
this.reportShown = false;
}
/** Show the day-wise breakdown of the picked accounts for the chosen date. */
submitReport(): void {
if (this.selectBy === 'types') {
if (this.pickedTypes.length) {
this.reportShown = true;
this.loadBreakdown();
}
return;
}
if (!this.picked.length) {
return;
}
this.reportShown = true;
this.loadReport();
}
/** Whether Submit has anything to submit. */
get canSubmit(): boolean {
return this.selectBy === 'types' ? this.pickedTypes.length > 0 : this.picked.length > 0;
}
/** Fetch the report of the picked accounts; also called again when the bucket setting changes under an open report. */
private loadReport(): void {
const seq = ++this.reportSeq;
this.reportError = '';
if (!parseIsoDate(this.asOnIso)) {
this.report = null;
this.reportError = 'The as-on date is not valid.';
return;
}
if (this.bucketSpecInvalid) {
this.report = null;
this.reportError = 'Only the last bucket may have no end; every other bucket needs an end.';
return;
}
this.reportLoading = true;
this.service
.getSelected(this.picked.map((p) => `${p.kind}:${p.id}`).join(','), {
asOnDate: this.asOnIso,
buckets: this.edgeSpec,
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
overdueOldBucket: this.cfg.overdue?.old ?? 2
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
if (seq === this.reportSeq) {
this.reportLoading = false;
this.report = result;
}
},
error: (err) => {
if (seq === this.reportSeq) {
this.reportLoading = false;
this.report = null;
this.reportError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'No data came back. The backend needs a restart with the new endpoint.';
}
}
});
}
/** A new as-on date means a new report: put the old one away until Submit is pressed again. */
onAsOnChange(): void {
this.reportShown = false;
this.loadBreakdown();
}
/** The report by bucket: what the picked accounts put in each one, with the days and dates of the bucket. */
get reportBuckets(): { label: string; days: string; dates: string; amount: number; count: number; pct: number }[] {
const rows = this.report?.rows ?? [];
const total = rows.reduce((sum, r) => sum + r.amount, 0);
return this.windows.map((w, i) => {
const inBucket = rows.filter((r) => r.bucket === i);
const amount = inBucket.reduce((sum, r) => sum + r.amount, 0);
return {
label: this.cfg.buckets[i]?.label ?? w.label,
days: dayRangeText(w),
dates: this.dateRange(w),
amount,
count: inBucket.length,
pct: total ? (amount / total) * 100 : 0
};
});
}
bucketName(index: number | null): string {
return index === null ? '' : (this.cfg.buckets[index]?.label ?? '');
}
// ── The bucket ranges, as a From / To table ──
/** Where a bucket starts, as the table writes it; null for the first, which always starts on day 1. */
fromOf(index: number): AlmSpan | null {
return fromSpanOf(this.cfg.buckets, index);
}
/** Change where a bucket starts. That is where the one before it ends, so that one moves too. */
setFrom(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
const current = this.fromOf(index);
if (!current) {
return;
}
const n = change.n !== undefined ? Number(change.n) : current.n;
this.applyRange(index - 1, () =>
setFromSpan(this.cfg.buckets, index, { n: n > 0 ? n : 1, unit: change.unit ?? current.unit })
);
}
/** Change where a bucket ends. */
setTo(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
const current = this.cfg.buckets[index]?.upTo;
if (!current) {
return;
}
const n = change.n !== undefined ? Number(change.n) : current.n;
this.applyRange(index, () => {
this.cfg.buckets[index].upTo = { n: n > 0 ? n : 1, unit: change.unit ?? current.unit };
});
}
/**
* Apply a change to the end of one bucket, and keep the headings of the two buckets it touches in
* step — but only a heading still worded the way the table would word it; one the bank has typed
* over is left alone.
*/
private applyRange(endingIndex: number, apply: () => void): void {
const touched = [
endingIndex,
endingIndex + 1
].filter((i) => i >= 0 && i < this.cfg.buckets.length);
const wasGenerated = touched.map((i) => this.cfg.buckets[i].label === bucketLabel(this.cfg.buckets, i));
apply();
touched.forEach((i, k) => {
if (wasGenerated[k]) {
this.cfg.buckets[i].label = bucketLabel(this.cfg.buckets, i);
}
});
this.bucketsChanged();
}
/** The end typed into the "add a bucket" form. */
newEnd: { n: number; unit: AlmUnit } = { n: 7, unit: 'day' };
/** Start, End, adding and removing buckets work only while this is on, so a stray click cannot change them. */
rangeEdit = false;
toggleRangeEdit(): void {
this.rangeEdit = !this.rangeEdit;
}
/** 'd', 'm' or 'y' — a unit the way the range table writes it. */
unitLetter(unit: AlmUnit): string {
return unit === 'day' ? 'd' : unit === 'month' ? 'm' : 'y';
}
/**
* Add a bucket ending where the form says. It lands in date order and takes the front part of the
* bucket it falls inside, so what was placed there stays with the later part; lines, the overdue
* rule and limits keep pointing at the buckets they pointed at.
*/
addBucket(): void {
const end: AlmSpan = { n: Math.max(1, Number(this.newEnd.n) || 1), unit: this.newEnd.unit };
const buckets = this.cfg.buckets;
const reference = parseIsoDate(this.asOnIso) ?? new Date();
const endTime = addSpan(reference, end).getTime();
if (buckets.some((b) => b.upTo && addSpan(reference, b.upTo).getTime() === endTime)) {
window.alert('A bucket already ends there.');
return;
}
let position = buckets.findIndex((b) => !b.upTo || addSpan(reference, b.upTo).getTime() > endTime);
if (position < 0) {
position = buckets.length;
}
const laterWasGenerated = position < buckets.length && buckets[position].label === bucketLabel(buckets, position);
this.shiftBucketRefs((i) => (i >= position ? i + 1 : i));
// A bucket put in front becomes the first and starts on day 1; the old first now starts where it ends.
if (position === 0 && buckets[0]) {
buckets[0].startSpan = undefined;
}
buckets.splice(position, 0, { label: '', hint: '', upTo: end });
buckets[position].label = bucketLabel(buckets, position);
if (laterWasGenerated && position + 1 < buckets.length) {
buckets[position + 1].label = bucketLabel(buckets, position + 1);
}
this.bucketsChanged();
}
/**
* Remove a bucket. Whatever was placed in it moves to the next one (to the one before, if it was
* the last, which then runs on without end), so no line loses its money.
*/
removeBucket(index: number): void {
const buckets = this.cfg.buckets;
if (buckets.length <= 2) {
return;
}
if (!window.confirm(`Remove the "${buckets[index].label}" bucket? Its money will move to the next bucket.`)) {
return;
}
const last = index === buckets.length - 1;
const target = last ? index - 1 : index + 1;
const targetWasGenerated = buckets[target].label === bucketLabel(buckets, target);
this.shiftBucketRefs((i) => {
const moved = i === index ? target : i;
return moved > index ? moved - 1 : moved;
});
const removedStart = buckets[index].startSpan;
buckets.splice(index, 1);
// Removing the first bucket makes the next one the first, which keeps the statement's start.
if (index === 0 && removedStart) {
buckets[0].startSpan = removedStart;
}
if (last) {
buckets[index - 1].upTo = null;
}
const now = last ? index - 1 : index;
if (targetWasGenerated) {
buckets[now].label = bucketLabel(buckets, now);
}
this.mergeShares();
this.bucketsChanged();
}
/** Re-point everything that names a bucket by its position: lines, the overdue rule, the limits. */
private shiftBucketRefs(map: (index: number) => number): void {
this.cfg.items.forEach((item) => item.placements.forEach((p) => (p.bucket = map(p.bucket))));
if (this.cfg.overdue) {
this.cfg.overdue.recent = map(this.cfg.overdue.recent);
this.cfg.overdue.old = map(this.cfg.overdue.old);
}
this.cfg.limits.forEach((l) => (l.bucket = map(l.bucket)));
}
/** After two buckets became one, a line's two shares there become one share, and a limit is kept once. */
private mergeShares(): void {
this.cfg.items.forEach((item) => {
const merged = new Map<number, AlmPlacement>();
for (const p of item.placements) {
const have = merged.get(p.bucket);
if (!have) {
merged.set(p.bucket, { ...p });
} else if (have.pct != null || p.pct != null) {
have.pct = (Number(have.pct) || 0) + (Number(p.pct) || 0);
}
}
item.placements = [...merged.values()].sort((a, b) => a.bucket - b.bucket);
});
const seen = new Set<number>();
this.cfg.limits = this.cfg.limits.filter((l) => !seen.has(l.bucket) && !!seen.add(l.bucket));
}
/** A bucket that ends before it starts, or no later than the one before. Empty when the range is fine. */
rangeProblem(index: number): string {
const w = this.windows[index];
if (!w || !w.to) {
return '';
}
return w.to < w.from ? 'End is before Start: keep it later than the end of the previous bucket.' : '';
}
/** Move a whole line to one bucket: its balance all goes there and nowhere else. */
setFixed(item: AlmItem, bucket: number): void {
const label = item.placements.find((p) => p.bucket === bucket)?.label;
item.placements = [{ bucket, pct: 100, label }];
this.changed();
}
/** What a line's shares add up to, or null for a line that follows due dates. */
rowTotal(item: AlmItem): number | null {
if (item.rule === 'maturity') {
return null;
}
return this.cfg.buckets.reduce((sum, _b, i) => sum + (this.share(item, i) ?? 0), 0);
}
rowState(item: AlmItem): RowState {
const total = this.rowTotal(item);
if (total === null) {
return 'spread';
}
if (total === 0) {
return 'todo';
}
return Math.abs(total - 100) < 0.005 ? 'ok' : 'bad';
}
/** Shares that do not make up the whole balance, over every line that is in the report. */
get problemCount(): number {
return this.cfg.items.filter(
(i) => !i.header && i.included !== false && [
'bad',
'todo'
].includes(this.rowState(i))
).length;
}
/** Changing a line's rule: due-date lines follow each account's date, the rest carry shares. */
ruleChanged(item: AlmItem, rule: AlmRule): void {
item.rule = rule;
item.spreads = rule === 'maturity';
if (rule === 'fixed') {
// A fixed line sits in one bucket: keep the one that held the most, or start in the first.
const biggest = [...item.placements].sort((a, b) => (Number(b.pct) || 0) - (Number(a.pct) || 0))[0];
item.placements = [{ bucket: biggest?.bucket ?? 0, pct: 100, label: biggest?.label }];
}
this.changed();
}
// ── Editing the lines themselves ──
toggleEdit(): void {
this.editMode = !this.editMode;
}
addLine(side: AlmSide): void {
const items = this.itemsOf(side);
const last = items[items.length - 1];
this.cfg.items.splice(this.cfg.items.indexOf(last) + 1, 0, {
id: `u${Date.now()}`,
side,
ref: '',
level: 0,
name: side === 'liability' ? 'Nayi liability' : 'Nayi asset',
rule: 'fixed',
reason: '',
spreads: false,
placements: [{ bucket: 0, pct: 100 }]
});
this.changed();
}
removeLine(item: AlmItem): void {
if (!window.confirm(`"${item.name}" hata dein?`)) {
return;
}
this.cfg.items = this.cfg.items.filter((i) => i.id !== item.id);
this.changed();
}
/** Open-ended means "and beyond" — only the last bucket should be. */
setOpenEnded(bucket: AlmBucket, open: boolean): void {
bucket.upTo = open ? null : { n: 5, unit: 'year' };
this.bucketsChanged();
}
/** Called after any edit — the change is kept right away, there is no separate save step. */
changed(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.cfg));
} catch {
/* private window or blocked storage — the edit still holds for this session */
}
}
reset(): void {
if (!window.confirm('All settings will go back to the RBI defaults and your changes will be lost. Continue?')) {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* nothing stored to remove */
}
this.cfg = defaultConfig();
this.loadBreakdown();
}
close(): void {
this.dialogRef.close();
}
// ── Days from the as-on date ──
/** Every bucket as dates, for the chosen as-on date. */
get windows(): BucketWindow[] {
const asOn = parseIsoDate(this.asOnIso);
return asOn ? bucketWindows(asOn, this.cfg.buckets) : [];
}
/** Day-by-day text for a window: 'Din 1 se 14'. */
dayRange(w: BucketWindow): string {
return dayRangeText(w);
}
dateRange(w: BucketWindow): string {
return w.to ? `${formatDate(w.from)} to ${formatDate(w.to)}` : `${formatDate(w.from)} onwards`;
}
/** What the day calculator says for the maturity typed in; null until both dates are valid. */
get dayCheck(): { days: number; bucket: string; due: boolean } | null {
const asOn = parseIsoDate(this.asOnIso);
const maturity = parseIsoDate(this.maturityIso);
if (!asOn || !maturity) {
return null;
}
const days = daysBetween(asOn, maturity);
return {
days,
due: days <= 0,
bucket: this.cfg.buckets[bucketIndexFor(asOn, maturity, this.cfg.buckets)]?.label ?? ''
};
}
// ── Live FD / RD / loan totals for the as-on date ──
/** The bank's bucket edges as the server reads them: '14d,28d,3m,6m,1y,3y,5y'. */
private get edgeSpec(): string {
const letter = { day: 'd', month: 'm', year: 'y' };
return this.cfg.buckets
.filter((b) => b.upTo)
.map((b) => `${b.upTo?.n}${letter[b.upTo?.unit ?? 'day']}`)
.join(',');
}
/** Only the last bucket may be open-ended, and every other one needs an end. */
private get bucketSpecInvalid(): boolean {
const last = this.cfg.buckets.length - 1;
return this.cfg.buckets.some((b, i) => !b.upTo && i < last) || !!this.cfg.buckets[last]?.upTo;
}
/** Fetch the fixed-term breakdown for the chosen as-on date and the current bucket setting. */
loadBreakdown(): void {
const seq = ++this.loadSeq;
this.loadError = '';
if (!parseIsoDate(this.asOnIso)) {
this.breakdown = null;
this.loadError = 'The as-on date is not valid.';
return;
}
if (this.bucketSpecInvalid) {
this.breakdown = null;
this.loadError = 'Only the last bucket may have no end; every other bucket needs an end.';
return;
}
this.loading = true;
this.service
.getBreakdown({
asOnDate: this.asOnIso,
buckets: this.edgeSpec,
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
overdueOldBucket: this.cfg.overdue?.old ?? 2
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
if (seq !== this.loadSeq) {
return;
}
this.loading = false;
this.breakdown = result;
},
error: (err) => {
if (seq !== this.loadSeq) {
return;
}
this.loading = false;
this.breakdown = null;
this.loadError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'No data came back. The backend needs a restart with the new endpoint.';
}
});
}
/** A bucket edge or the overdue rule changed: the server must slot everything again. */
bucketsChanged(): void {
this.changed();
this.loadBreakdown();
if (this.reportShown && this.selectBy === 'accounts') {
this.loadReport();
}
}
/** The unit as the statement's heading words it: 'Lakhs of Rupees'. */
get unitWord(): string {
return { rupee: 'Rupees', lakh: 'Lakhs of Rupees', crore: 'Crores of Rupees' }[this.unit];
}
/** An amount in rupees as the chosen unit; a dash when there is nothing in it. */
amountText(rupees: number): string {
if (!rupees) {
return '–';
}
const divisor = this.units.find((u) => u.value === this.unit)?.divisor ?? 1;
return (rupees / divisor).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** The saved edits if they are intact, otherwise RBI's defaults. */
private load(): AlmConfig {
const fallback = defaultConfig();
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') as AlmConfig | null;
if (
saved &&
Array.isArray(saved.buckets) &&
saved.buckets.length >= 2 &&
Array.isArray(saved.items) &&
Array.isArray(saved.limits)
) {
const overdue = (saved.overdue ??= fallback.overdue ?? { recent: 0, old: 2 });
// Buckets can be added and removed, so anything pointing past the last one is cut back.
const last = saved.buckets.length - 1;
overdue.recent = Math.min(overdue.recent, last);
overdue.old = Math.min(overdue.old, last);
saved.limits = saved.limits.filter((l) => l.bucket <= last);
saved.items.forEach((item) => {
item.placements = (item.placements ?? []).filter((p) => p.bucket <= last);
});
// Lines saved before due-date lines could name their accounts take the default's, by id.
saved.items.forEach((item) => {
if (item.source === undefined) {
item.source = fallback.items.find((d) => d.id === item.id)?.source ?? '';
}
});
return saved;
}
} catch {
/* unreadable — start from the defaults */
}
return fallback;
}
}
@@ -0,0 +1,463 @@
/**
* 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/.
*/
/**
* The Structural Liquidity Statement of an Urban Co-operative Bank (non-scheduled — 8 residual-maturity
* buckets, 1-14 days not split), laid out row for row like the statement the bank files: the same
* numbered Outflow and Inflow lines, the same buckets.
*
* Everything in this file is only the DEFAULT. It follows RBI's Urban Co-operative Banks (Asset
* Liability Management) circular of 17 September 2008 and the 2025 Directions, and where the bank's
* own filed statement slots a line differently (Interest Payable, Provisions, Other Liabilities) it
* follows the bank's statement. The screen works on a copy the bank can edit and falls back to these
* values on "Reset"; percentages in particular are a benchmark that a bank may replace with its own
* behavioural study approved by the Board / ALCO.
*/
export type AlmSide = 'liability' | 'asset';
/**
* How the bucket is decided.
* - fixed: always the same bucket(s).
* - maturity: follows the due date of each account/instalment, so it spreads over the buckets.
* - split: one balance divided between buckets by share.
* - behavioural: the bank's own past-data study decides.
*/
export type AlmRule = 'fixed' | 'maturity' | 'split' | 'behavioural';
/**
* Which accounts feed a due-date line on their own. Empty means nothing does yet and the line has to
* be filled by hand. FD_RD is the two deposit kinds added together, as RBI's "Term Deposits" head is.
*/
export type AlmSource = 'FD' | 'RD' | 'FD_RD' | 'LOAN' | 'CCOD' | 'NPA' | '';
export const SOURCE_OPTIONS: { value: AlmSource; label: string }[] = [
{ value: '', label: 'None (fill by hand)' },
{ value: 'FD_RD', label: 'FD + RD accounts (by maturity)' },
{ value: 'FD', label: 'FD accounts only (by maturity)' },
{ value: 'RD', label: 'RD accounts only (by maturity)' },
{ value: 'LOAN', label: 'Term Loan accounts (by instalment)' },
{ value: 'CCOD', label: 'CC / OD loan accounts (outstanding × %)' },
{ value: 'NPA', label: 'NPA loan accounts (outstanding × %)' }
];
/** Sources whose accounts give one outstanding figure that the line's own percentages then divide. */
export const SHARE_SOURCES: AlmSource[] = [
'CCOD',
'NPA'
];
export type AlmUnit = 'day' | 'month' | 'year';
/** How far out a bucket reaches, counted from the as-on date. */
export interface AlmSpan {
n: number;
unit: AlmUnit;
}
export interface AlmBucket {
/** The column heading, as the statement prints it. */
label: string;
/** One line a branch user can follow. */
hint: string;
/**
* Last day the bucket takes, counted from the as-on date. Calendar months and years are used
* as they are, so '3 months' from 30 June is 30 September. Null means no upper end.
*/
upTo: AlmSpan | null;
/**
* Only on the first bucket: where it starts, when that is not day 1. Every later bucket starts where
* the one before it ends. Money due before this start still counts in the first bucket, since
* nothing may fall outside the statement.
*/
startSpan?: AlmSpan;
}
export interface AlmPlacement {
/** Index into the bucket list. */
bucket: number;
/** Share of the balance that goes to this bucket, 0-100; blank when the whole line goes there. */
pct?: number | null;
/** Short name for the portion, e.g. 'Core'. */
label?: string;
}
export interface AlmItem {
id: string;
side: AlmSide;
/** The statement's own numbering: '1.', '3.', 'I)'. */
ref: string;
/** 0 for a numbered head, 1 for a line under a head. */
level: 0 | 1;
/** A heading only, such as '3. Deposits': it carries no figure of its own. */
header?: boolean;
/** Left out of the report when false; shown when absent or true. */
included?: boolean;
name: string;
rule: AlmRule;
/** Plain-language reason, in the words a branch manager would use. */
reason: string;
/** True when each account falls in the bucket of its own due date. */
spreads: boolean;
/** For a due-date line: the accounts whose maturities fill it. */
source?: AlmSource;
placements: AlmPlacement[];
}
/** Prudential cap on the net cumulative negative mismatch, as a % of cumulative outflow in that bucket. */
export interface AlmLimit {
bucket: number;
pct: number;
}
/**
* Where an instalment or receivable that is already due and unpaid is slotted — the bank's Overdue
* Receivables rule. Both are bucket positions: 'recent' for one that fell due less than a month ago,
* 'old' for anything older.
*/
export interface AlmOverdueRule {
recent: number;
old: number;
}
export interface AlmConfig {
buckets: AlmBucket[];
items: AlmItem[];
limits: AlmLimit[];
overdue?: AlmOverdueRule;
}
export const RULE_OPTIONS: { value: AlmRule; label: string; help: string }[] = [
{ value: 'fixed', label: 'Fixed', help: 'Always in the same bucket' },
{ value: 'maturity', label: 'By due date', help: 'Each account by its own maturity' },
{ value: 'split', label: 'Split', help: 'Balance divided by percentages' },
{ value: 'behavioural', label: 'By study', help: 'Percentages from the bank’s own study' }
];
/** Column headings as the filed statement prints them, with the span each one covers. */
export const DEFAULT_BUCKETS: AlmBucket[] = [
{ label: '1 to 14 days', hint: 'Needed straight away', upTo: { n: 14, unit: 'day' } },
{ label: '15 to 28 days', hint: 'After the next two weeks', upTo: { n: 28, unit: 'day' } },
{ label: '29 days and upto 3 months', hint: 'Within this quarter', upTo: { n: 3, unit: 'month' } },
{ label: 'Over 3 months and upto 6 months', hint: 'Next quarter', upTo: { n: 6, unit: 'month' } },
{ label: 'Over 6 months and upto 1 year', hint: 'Within the year', upTo: { n: 1, unit: 'year' } },
{ label: 'Over 1 year and upto 3 years', hint: 'Medium term', upTo: { n: 3, unit: 'year' } },
{ label: 'Over 3 years and upto 5 years', hint: 'Long term', upTo: { n: 5, unit: 'year' } },
{ label: 'Over 5 years', hint: 'Permanent items', upTo: null }
];
/** Non-scheduled UCB: negative mismatch in 1-14 and 15-28 days must stay within 20% of outflows. */
export const DEFAULT_LIMITS: AlmLimit[] = [
{ bucket: 0, pct: 20 },
{ bucket: 1, pct: 20 }
];
/** Buckets by position, so the lines below read as the bucket they mean. */
const B = { d14: 0, d28: 1, m3: 2, m6: 3, y1: 4, y3: 5, y5: 6, over5: 7 };
const whole = (bucket: number): AlmPlacement[] => [{ bucket, pct: 100 }];
type Seed = Omit<AlmItem, 'id' | 'spreads' | 'level'> & { level?: 0 | 1; spreads?: boolean };
const head = (side: AlmSide, ref: string, name: string): Seed => ({
side,
ref,
level: 0,
header: true,
name,
rule: 'fixed',
reason: '',
placements: []
});
const line = (
side: AlmSide,
ref: string,
name: string,
rule: AlmRule,
reason: string,
placements: AlmPlacement[] = [],
level: 0 | 1 = 1,
source: AlmSource = ''
): Seed => ({ side, ref, level, name, rule, reason, placements, spreads: rule === 'maturity', source });
const SEED: Seed[] = [
// ── OUTFLOWS (liabilities) ──
line(
'liability',
'1.',
'Capital',
'fixed',
'This is the bank’s own money and is never paid back, so it goes in the farthest bucket.',
whole(B.over5),
0
),
line(
'liability',
'2.',
'Reserves & Surplus',
'fixed',
'The bank’s own money, never paid back. Farthest bucket.',
whole(B.over5),
0
),
head('liability', '3.', 'Deposits'),
line(
'liability',
'I)',
'Current Deposits',
'split',
'A part can be withdrawn at any time (volatile), so it goes in 1-14 days. The rest is stable (core), so it goes in 1-3 years. Change the % from the bank’s own study.',
[
{ bucket: B.d14, pct: 10, label: 'Volatile' },
{ bucket: B.y3, pct: 90, label: 'Core' }
]
),
line(
'liability',
'II)',
'Saving Bank Deposits',
'split',
'Same as Current. RBI’s benchmark is about 10% volatile. Change the % from the bank’s own study.',
[
{ bucket: B.d14, pct: 10, label: 'Volatile' },
{ bucket: B.y3, pct: 90, label: 'Core' }
]
),
line(
'liability',
'III)',
'Term Deposits (FD and RD)',
'maturity',
'The maturity date of each FD and RD decides its bucket. An FD that is already due goes in the first bucket.',
[],
1,
'FD_RD'
),
line('liability', 'IV)', 'Certificates of Deposit', 'maturity', 'By maturity date.'),
head('liability', '4.', 'Borrowings'),
line('liability', 'I.', 'Call and short Notice', 'maturity', 'The bucket of the day it has to be repaid.'),
line('liability', 'II.', 'Inter-Bank (Term)', 'maturity', 'The bucket of the day it has to be repaid.'),
line('liability', 'III.', 'Refinances', 'maturity', 'The bucket of the day it has to be repaid.'),
line('liability', 'IV.', 'Others (specify)', 'maturity', 'The bucket of the day it has to be repaid.'),
head('liability', '5.', 'Other Liabilities & Provisions'),
line('liability', 'I.', 'DD / Bills Payable', 'fixed', 'These are paid quickly, so 1-14 days.', whole(B.d14)),
line('liability', 'II.', 'Branch Adjustments', 'fixed', 'Settled between branches, usually quickly.', whole(B.d14)),
line(
'liability',
'III.',
'Provisions',
'fixed',
'In your filed statement provisions are in 1-14 days. Change it according to the purpose of the provision.',
whole(B.d14)
),
line(
'liability',
'IV.',
'Other Liabilities',
'fixed',
'Amounts that are not to be paid out (like fees received in advance) go in the farthest bucket. Your filed statement has them in over 5 years.',
whole(B.over5)
),
line(
'liability',
'6.',
'Unavailed portion of Cash Credit / Overdraft / Demand Loan component of Working Capital',
'behavioural',
'How much of the limit customers will use comes from the bank’s past history. RBI: fill the % in the buckets within 12 months.',
[],
0
),
line(
'liability',
'7.',
'Letters of Credit / Guarantees',
'behavioural',
'How much of the LC / guarantee will devolve comes from the bank’s history, net of margin. Fill in the %.',
[],
0
),
line('liability', '8.', 'Repos', 'maturity', 'The bucket of the day it has to be repaid.', [], 0),
line('liability', '9.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
line('liability', '10.', 'Swaps (Sell/Buy/Maturing forwards)', 'maturity', 'By maturity date.', [], 0),
line(
'liability',
'11.',
'Interest Payable',
'fixed',
'Interest still to be paid as per the books. Your filed statement has it in 1-14 days. RBI: the bucket of the date it is due.',
whole(B.d14),
0
),
line(
'liability',
'12.',
'Others (specify)',
'fixed',
'Anything else. Your filed statement has it in over 5 years.',
whole(B.over5),
0
),
// ── INFLOWS (assets) ──
line(
'asset',
'1.',
'Cash',
'fixed',
'Cash is with the bank right now, so it is available straight away.',
whole(B.d14),
0
),
line(
'asset',
'2.',
'Balances with RBI',
'behavioural',
'Balance above the CRR/SLR requirement is in 1-14 days. The required part follows the maturity of the bank’s deposits (DTL). Fill in the %.',
[],
0
),
head('asset', '3.', 'Balances with other Banks'),
line(
'asset',
'I)',
'Current Account',
'fixed',
'What can be withdrawn goes in 1-14 days. RBI: the minimum balance that has to be kept goes in 1-3 years; split it if you want.',
whole(B.d14)
),
line(
'asset',
'II)',
'Money at Call and short Notice, Term Deposits and other placements with other banks including DCCBs and SCBs',
'maturity',
'By the maturity date of the FD / call money placed with the bank.'
),
line(
'asset',
'4.',
'Investments (including those under Repos but excluding Reverse Repos)',
'maturity',
'By the maturity of the bond / G-Sec, leaving out the part needed to maintain SLR.',
[],
0
),
head('asset', '5.', 'Advances (Performing)'),
line(
'asset',
'I.',
'Bills Purchased and Discounted (including bills under DUPN)',
'maturity',
'By the due date of the bill.'
),
line(
'asset',
'II.',
'Cash Credits, Overdrafts and Loans repayable on demand',
'behavioural',
'The part that comes back quickly goes in a short bucket; the part that always stays (core) goes in 1-3 years. Fill in the % from the bank’s study.',
[],
1,
'CCOD'
),
line(
'asset',
'III.',
'Term Loans',
'maturity',
'Each EMI goes in the bucket of its own due date, not the whole loan at once.',
[],
1,
'LOAN'
),
line(
'asset',
'6.',
'NPAs (Advances and Investments)',
'behavioural',
'Sub-standard NPA in 3-5 years; doubtful and loss in the farthest bucket (net of provision). Fill in the % as the bank sees fit.',
[],
0,
'NPA'
),
line(
'asset',
'7.',
'Fixed Assets',
'fixed',
'Things like buildings and furniture, which do not bring in money quickly.',
whole(B.over5),
0
),
head('asset', '8.', 'Other Assets'),
line(
'asset',
'I.',
'Branch Adjustments',
'fixed',
'Debit balance of branch adjustments, usually settled quickly.',
whole(B.d14)
),
line('asset', 'II.', 'Leased Assets', 'maturity', 'By the date of the lease’s interim cash flows.'),
line(
'asset',
'III.',
'Others',
'fixed',
'Things that are not real money (intangibles, non-cash) go in the farthest bucket. Your filed statement has them in over 5 years.',
whole(B.over5)
),
line('asset', '9.', 'Reverse Repos', 'maturity', 'The bucket of the day the money comes back.', [], 0),
line('asset', '10.', 'Swaps (Buy/Sell)/Maturing forwards', 'maturity', 'By maturity date.', [], 0),
line('asset', '11.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
line(
'asset',
'12.',
'Interest Receivable',
'fixed',
'Interest still to be received. Your filed statement has it in over 5 years. RBI: the bucket of the date it is due.',
whole(B.over5),
0
),
line(
'asset',
'13.',
'Export Refinance from RBI',
'fixed',
'Limit that has not been used yet can be drawn straight away.',
whole(B.d14),
0
),
line(
'asset',
'14.',
'Others (specify)',
'behavioural',
'Anything else. Fill in the % as the bank’s study gives.',
[],
0
)
];
/** A fresh, independent copy of the RBI defaults — callers are free to mutate it. */
export function defaultConfig(): AlmConfig {
return {
buckets: DEFAULT_BUCKETS.map((b) => ({ ...b })),
limits: DEFAULT_LIMITS.map((l) => ({ ...l })),
// RBI (2008 UCB circular): under a month overdue is expected within 14 days; older goes to 29 days-3 months.
overdue: { recent: 0, old: 2 },
items: SEED.map((s, i) => ({
...s,
level: s.level ?? 1,
id: `d${i + 1}`,
spreads: s.spreads ?? false,
placements: s.placements.map((p) => ({ ...p }))
}))
};
}
@@ -0,0 +1,209 @@
/**
* 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 { AlmBucket, AlmSpan } from './alm-bucket-breakdown.config';
/**
* The day arithmetic behind the Structural Liquidity Statement: how many days a maturity is from the
* as-on date, and which bucket that lands it in. The statement and this screen both use it, so what
* the screen shows is what the return will do.
*
* Dates are plain calendar days ('yyyy-MM-dd'), never timestamps, so the browser's time zone and
* daylight saving cannot move a maturity into the next bucket.
*/
const MS_PER_DAY = 86_400_000;
/** Where the first bucket starts unless told otherwise: the day after the as-on date. */
const DAY_ONE: AlmSpan = { n: 1, unit: 'day' };
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
/** One bucket laid over a calendar, for a given as-on date. */
export interface BucketWindow {
label: string;
/** First day the bucket takes. */
from: Date;
/** Last day the bucket takes, or null when it runs on without end. */
to: Date | null;
/** Days from the as-on date to the first / last day. */
fromDays: number;
toDays: number | null;
}
/** 'yyyy-MM-dd' to a local calendar date; null when it is not a real date. */
export function parseIsoDate(iso: string | null | undefined): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? '');
if (!m) {
return null;
}
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return date.getFullYear() === Number(m[1]) && date.getMonth() === Number(m[2]) - 1 && date.getDate() === Number(m[3])
? date
: null;
}
/** '30-Jun-2026'. */
export function formatDate(date: Date): string {
return `${String(date.getDate()).padStart(2, '0')}-${MONTHS[date.getMonth()]}-${date.getFullYear()}`;
}
/** Whole days from one calendar date to another; negative when `to` is earlier. */
export function daysBetween(from: Date, to: Date): number {
const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
return Math.round((b - a) / MS_PER_DAY);
}
/**
* The date a span reaches. Months and years are calendar months and years, and a day that does not
* exist in the target month falls back to that month's last day (31 Jan + 1 month = 28/29 Feb).
*/
export function addSpan(from: Date, span: AlmSpan): Date {
if (span.unit === 'day') {
return new Date(from.getFullYear(), from.getMonth(), from.getDate() + span.n);
}
const months = span.unit === 'year' ? span.n * 12 : span.n;
const target = new Date(from.getFullYear(), from.getMonth() + months, 1);
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
return new Date(target.getFullYear(), target.getMonth(), Math.min(from.getDate(), lastDay));
}
/**
* Index of the bucket a maturity falls in. A maturity on or before the as-on date is already due, so
* it is slotted in the first bucket rather than falling out of the statement.
*/
export function bucketIndexFor(asOn: Date, maturity: Date, buckets: AlmBucket[]): number {
if (!buckets.length || maturity <= asOn) {
return 0;
}
for (let i = 0; i < buckets.length; i++) {
const upTo = buckets[i].upTo;
if (!upTo || maturity <= addSpan(asOn, upTo)) {
return i;
}
}
return buckets.length - 1;
}
/** Each bucket as a run of calendar days from the as-on date, in bucket order. */
export function bucketWindows(asOn: Date, buckets: AlmBucket[]): BucketWindow[] {
let from = addSpan(asOn, fromSpanOf(buckets, 0) ?? DAY_ONE);
return buckets.map((b) => {
const to = b.upTo ? addSpan(asOn, b.upTo) : null;
const window: BucketWindow = {
label: b.label,
from,
to,
fromDays: daysBetween(asOn, from),
toDays: to ? daysBetween(asOn, to) : null
};
if (to) {
from = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1);
}
return window;
});
}
/** 'Days 1 to 14', 'Days 29 to 92', 'Day 1827 onwards'. */
export function dayRangeText(w: BucketWindow): string {
return w.toDays === null ? `Day ${w.fromDays} onwards` : `Days ${w.fromDays} to ${w.toDays}`;
}
// ── The bucket ranges as a From / To table ──
/** '14 d', '3 m', '1 y' — how a span is written in the From / To table. */
export function spanText(span: AlmSpan): string {
return `${span.n} ${span.unit === 'day' ? 'd' : span.unit === 'month' ? 'm' : 'y'}`;
}
/** '14 days', '1 month', '5 years' — a span in words, as the statement's headings put it. */
function spanWords(span: AlmSpan): string {
const word = span.unit === 'day' ? 'day' : span.unit === 'month' ? 'month' : 'year';
return `${span.n} ${word}${span.n === 1 ? '' : 's'}`;
}
/**
* Where bucket `index` starts, as it is written in the table: the first starts on day 1, a bucket
* after one that ends in days starts the day after (14 d then 15 d), and one after a month or year
* starts "over" it (3 m then 3 m). Null for the first bucket, whose start is fixed.
*/
export function fromSpanOf(buckets: AlmBucket[], index: number): AlmSpan | null {
if (index === 0) {
return { ...(buckets[0]?.startSpan ?? DAY_ONE) };
}
const previous = buckets[index - 1].upTo;
if (!previous) {
return null;
}
return previous.unit === 'day' ? { n: previous.n + 1, unit: 'day' } : { ...previous };
}
/** Move the start of bucket `index`, which is the end of the one before it. */
export function setFromSpan(buckets: AlmBucket[], index: number, from: AlmSpan): void {
const n = Number(from.n) || 1;
if (index === 0) {
// Day 1 is the ordinary start, so it is stored as no override at all.
buckets[0].startSpan = n === 1 && from.unit === 'day' ? undefined : { n, unit: from.unit };
return;
}
buckets[index - 1].upTo = from.unit === 'day' ? { n: Math.max(1, n - 1), unit: 'day' } : { n, unit: from.unit };
}
/**
* The column heading a range deserves, worded the way the filed statement words them:
* '1 to 14 days', '29 days and upto 3 months', 'Over 3 months and upto 6 months', 'Over 5 years'.
*/
export function bucketLabel(buckets: AlmBucket[], index: number): string {
const to = buckets[index]?.upTo ?? null;
const previous = index > 0 ? buckets[index - 1].upTo : null;
if (index === 0) {
const start = buckets[0].startSpan;
if (start) {
if (start.unit === 'day') {
return !to
? `Over ${spanWords(start)}`
: to.unit === 'day'
? `${start.n} to ${to.n} days`
: `${start.n} days and upto ${spanWords(to)}`;
}
return to ? `Over ${spanWords(start)} and upto ${spanWords(to)}` : `Over ${spanWords(start)}`;
}
if (to?.unit === 'day' && to.n === 1) {
return 'Next day';
}
return !to ? 'Over 0 days' : to.unit === 'day' ? `1 to ${to.n} days` : `1 day and upto ${spanWords(to)}`;
}
if (!previous) {
return buckets[index].label;
}
if (previous.unit === 'day') {
const from = previous.n + 1;
if (!to) {
return `Over ${spanWords(previous)}`;
}
if (to.unit === 'day' && to.n === from) {
return `Day ${from}`;
}
return to.unit === 'day' ? `${from} to ${to.n} days` : `${from} days and upto ${spanWords(to)}`;
}
return to ? `Over ${spanWords(previous)} and upto ${spanWords(to)}` : `Over ${spanWords(previous)}`;
}
@@ -0,0 +1,125 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** One product's total, split by bucket, in rupees. */
export interface MaturityGroup {
key: string;
label: string;
side: 'liability' | 'asset';
accounts: number;
total: number;
buckets: number[];
}
/** Loans reported apart from the date buckets, with the reason they are not slotted by date. */
export interface NotSlotted {
key: string;
label: string;
accounts: number;
outstanding: number;
why: string;
}
export interface MaturityBreakdown {
asOnDate: string;
bucketCount: number;
groups: MaturityGroup[];
notSlotted: NotSlotted[];
notes: string[];
}
/** What the screen asks the server for: the date, the bank's own bucket edges and its overdue rule. */
export interface MaturityQuery {
asOnDate: string;
/** Edges like '14d,28d,3m,6m,1y,3y,5y'. */
buckets: string;
overdueRecentBucket: number;
overdueOldBucket: number;
}
/** One account found by a search, to be picked. */
export interface AccountHit {
/** FD, RD or LOAN. */
kind: 'FD' | 'RD' | 'LOAN';
id: number;
accountNo: string;
customer: string | null;
product: string;
/** Maturity date of a deposit; null for a loan. */
date: string | null;
/** Deposit balance, or loan principal outstanding. */
amount: number;
}
/** One deposit, or one unpaid loan instalment, of the accounts picked. */
export interface AccountRow {
id: number;
accountNo: string;
customer: string | null;
product: string;
kind: string;
/** Loan instalment number, for term loans. */
installment?: number;
/** Maturity date of a deposit, or due date of an instalment. */
date: string | null;
/** Days from the as-on date to that date; negative once it has passed. */
days: number | null;
status: string;
/** Position of the bucket it falls in. */
bucket: number | null;
amount: number;
/** Why the row needs a second look, for an NPA or a cash-credit / overdraft loan. */
note?: string | null;
}
export interface SelectedReport {
asOnDate: string;
bucketCount: number;
count: number;
total: number;
rows: AccountRow[];
}
/** Fixed-term deposits and loans of the bank's customers, slotted into residual-maturity buckets. */
@Injectable({ providedIn: 'root' })
export class AlmMaturityService {
private http = inject(HttpClient);
getBreakdown(query: MaturityQuery): Observable<MaturityBreakdown> {
const params = new HttpParams()
.set('asOnDate', query.asOnDate)
.set('buckets', query.buckets)
.set('overdueRecentBucket', query.overdueRecentBucket)
.set('overdueOldBucket', query.overdueOldBucket);
return this.http.get<MaturityBreakdown>('/almreturn/maturity-breakdown', { params });
}
/** Find accounts by account number or customer name; at least two characters. A kind of FD, RD or LOAN narrows it. */
searchAccounts(q: string, kind: string): Observable<{ results: AccountHit[] }> {
let params = new HttpParams().set('q', q);
if (kind) {
params = params.set('kind', kind);
}
return this.http.get<{ results: AccountHit[] }>('/almreturn/maturity-breakdown/search', { params });
}
/** The day-wise breakdown of the picked accounts, given as KIND:id pairs like FD:12,RD:5,LOAN:33. */
getSelected(accounts: string, query: MaturityQuery): Observable<SelectedReport> {
const params = new HttpParams()
.set('accounts', accounts)
.set('asOnDate', query.asOnDate)
.set('buckets', query.buckets)
.set('overdueRecentBucket', query.overdueRecentBucket)
.set('overdueOldBucket', query.overdueOldBucket);
return this.http.get<SelectedReport>('/almreturn/maturity-breakdown/selected', { params });
}
}
@@ -15,7 +15,7 @@
RBI Liabilities Register
</div>
<h2 class="dialog-title">Demand &amp; Time Liabilities</h2>
<p class="dialog-sub">NDTL &middot; CRR/SLR Computation Base &middot; As per RBI Master Circular</p>
<p class="dialog-sub">What the bank owes, and what it must hold against it</p>
</div>
<div class="header-right">
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
@@ -45,36 +45,39 @@
</div>
</div>
<!-- Summary Cards -->
<!-- What the liabilities are, and what they come to. Four cards rather than five: the CRR
requirement that used to sit here is on the compliance bar below, beside what is held
against it, where it answers a question rather than repeating one. -->
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</div>
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
<div class="sc-label">Demand liabilities</div>
<div class="sc-value" [title]="money(totalDemand)">{{ tile(totalDemand) }}</div>
<div class="sc-sub">Repayable on demand</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</div>
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
<div class="sc-label">Time liabilities</div>
<div class="sc-value" [title]="money(totalTime)">{{ tile(totalTime) }}</div>
<div class="sc-sub">Repayable on maturity</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
<div class="sc-label">Held with other banks</div>
<!-- Scaled on the tile, exact on hover: the card is read for order of magnitude, but the
figure behind it should never be more than a pointer away. -->
<div class="sc-value" [title]="money(totalBankingAssets)">
{{ tile(totalBankingAssets) }}
</div>
<!-- These assets net against what the bank OWES other banks, not against the whole base, and
the netting floors at zero — so where they exceed what is owed, nothing comes off at all.
Said plainly, because "deducted from NDTL" was being printed on a card where no
deduction had been made. -->
<div class="sc-sub" [class.dn]="bankingAssetsNet">
{{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
</div>
<div class="sc-change dn">&#8595; Deducted from NDTL</div>
</div>
<div class="summary-card sc-ndtl">
<div class="sc-label">Net Demand &amp; Time Liabilities (NDTL)</div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
(3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
<div class="sc-label">NDTL &mdash; the base</div>
<div class="sc-value" [title]="money(ndtl)">{{ tile(ndtl) }}</div>
<div class="sc-sub">Demand + time, net of bank balances</div>
</div>
</div>
@@ -94,26 +97,33 @@
<span class="tab-pill pill-time">02</span> Time Liabilities
</button>
<button class="tab-btn" [class.active]="activeTab === 'assets'" (click)="activeTab = 'assets'">
<span class="tab-pill pill-asset">03</span> Assets with Banking System
<span class="tab-pill pill-asset">03</span> Held with other banks
</button>
<button class="tab-btn" [class.active]="activeTab === 'slr'" (click)="activeTab = 'slr'">
<span class="tab-pill pill-slr">04</span> SLR &mdash; Liquid Assets
<span class="tab-pill pill-slr">04</span> SLR assets
</button>
<!-- Where the reserve is actually held, and what it holds TODAY. The four tabs above report the
fortnight close the return is made up to; this one answers the question a treasury desk
asks every morning — as things stand now, are we short, and which account do we cover it
into. A close two weeks old cannot say that, because the balances have since moved. -->
<button class="tab-btn" [class.active]="activeTab === 'held'" (click)="activeTab = 'held'">
<span class="tab-pill pill-held">05</span> Our accounts today
</button>
</div>
<!-- Demand Liabilities Table -->
@if (activeTab === 'demand') {
<div class="section-desc">Amounts payable on demand &mdash; RBI Schedule II</div>
<div class="section-desc">Money the bank must repay whenever a customer asks for it.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Liability Item</th>
<th>Account Code</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th>Item</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -167,17 +177,17 @@
<!-- Time Liabilities Table -->
@if (activeTab === 'time') {
<div class="section-desc">Amounts payable after a specified period &mdash; RBI Schedule III</div>
<div class="section-desc">Money the bank repays only when the deposit matures.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Liability Item</th>
<th>Account Code</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th>Item</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -231,17 +241,20 @@
<!-- Assets with Banking System Table -->
@if (activeTab === 'assets') {
<div class="section-desc">Deducted from gross liabilities to arrive at NDTL &mdash; RBI Master Circular</div>
<div class="section-desc">
What the bank keeps with other banks. This comes off what it owes them — and only off that, so where it is the
larger of the two the base is left as it is.
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Asset Item</th>
<th>Account Code</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th>Item</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -275,7 +288,12 @@
<tfoot>
<tr>
<td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
<strong>Total Assets with Banking System</strong>
<span class="deduct-tag">{{
bankingAssetsNet
? '( Deducted from NDTL )'
: '( Netted against item I, which is smaller — no deduction )'
}}</span>
</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
@@ -289,20 +307,18 @@
<!-- SLR - Liquid Assets Table -->
@if (activeTab === 'slr') {
<div class="section-desc">
SLR = (Liquid Assets / NDTL) &times; 100 &mdash; RBI Sec 24(2A) of Banking Regulation Act, 1949
</div>
<div class="section-desc">Cash, bank balances and securities the bank holds against its SLR requirement.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Liquid Asset</th>
<th>Account Code</th>
<th>Item</th>
<th>Category</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -348,119 +364,141 @@
</table>
</div>
<!-- SLR Formula -->
<!-- How the ratio is struck. The "× 100" box that stood here was a term whose value was 100 and
whose label was 100 — it took a quarter of the row to say that a fraction is written as a
percentage. -->
<div class="formula-block slr-formula">
<div class="formula-title">SLR Computation &mdash; (Liquid Assets / NDTL) &times; 100</div>
<div class="formula-title">How the SLR ratio is worked out</div>
<div class="formula-row">
<div class="formula-term ft-slr">
<div class="formula-term-label">Liquid Assets</div>
<div class="formula-term-val">&#8377;13,238.80</div>
<div class="formula-term-sub">6 asset categories</div>
<div class="formula-term-label">Assets we hold</div>
<div class="formula-term-val">{{ money(totalLiquidAssets) }}</div>
<div class="formula-term-sub">{{ liquidAssets.length }} categories</div>
</div>
<div class="formula-op">&divide;</div>
<div class="formula-term ft-ndtl">
<div class="formula-term-label">NDTL</div>
<div class="formula-term-val">&#8377;69,221.30</div>
<div class="formula-term-sub">Net Demand &amp; Time</div>
</div>
<div class="formula-op">&times;</div>
<div class="formula-term" style="border-color: #e2e8f0; background: #f8fafc">
<div class="formula-term-label" style="color: #64748b">100</div>
<div class="formula-term-val" style="color: #334155">100</div>
<div class="formula-term-sub">&nbsp;</div>
<div class="formula-term-label">NDTL base</div>
<div class="formula-term-val">{{ money(baseNdtl) }}</div>
<div class="formula-term-sub">as at {{ baseDateStr || '—' }}</div>
</div>
<div class="formula-op equals">=</div>
<div class="formula-term ft-slr-result">
<div class="formula-term-label">Actual SLR</div>
<div class="formula-term-label">SLR held</div>
<div class="formula-term-val">{{ slrActualPercent }}%</div>
<div class="formula-term-sub">Min required: {{ slrRate }}%</div>
<div class="formula-term-sub">minimum {{ slrRate }}%</div>
</div>
</div>
</div>
<!-- SLR Status Bar -->
<div class="slr-status-bar">
<div class="slr-status-left">
<div class="slr-status-title">SLR Compliance Status</div>
<div class="slr-status-desc">
Actual: {{ slrActualPercent }}% &middot; Required: {{ slrRate }}.00% &middot; NDTL: &#8377;{{
ndtl | number: '1.2-2'
}}
Cr
</div>
</div>
<div class="slr-status-calcs">
<div class="slr-calc">
<div class="slr-calc-label">SLR Required ({{ slrRate }}%)</div>
<div class="slr-calc-val">&#8377;{{ slrRequired | number: '1.2-2' }} Cr</div>
</div>
<div class="slr-calc">
<div class="slr-calc-label">Liquid Assets Held</div>
<div class="slr-calc-val">&#8377;{{ totalLiquidAssets | number: '1.2-2' }} Cr</div>
</div>
<div class="slr-calc">
<div class="slr-calc-label">Surplus</div>
<div class="slr-calc-val surplus">&#8377;{{ slrSurplus | number: '1.2-2' }} Cr</div>
</div>
</div>
</div>
<!-- The SLR compliance bar that used to sit here said the same three figures as the row above
it and the bar below it — required, held, surplus, three times on one screen. It is gone;
the compliance bar at the foot carries both ratios for every tab. -->
}
<!-- NDTL Formula Block -->
<div class="formula-block">
<div class="formula-title">
NDTL Computation &mdash; Formula Breakdown &middot; Data as of {{ reportingDateStr }}
<!-- Required against held, for both ratios. Always on screen, under whichever tab is open:
whether the bank is short is the one thing a reader must not have to go looking for. -->
<div class="crr-bar">
<div class="crr-text">
<div class="crr-title">Are we meeting the requirement?</div>
<div class="crr-desc">
Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }}
</div>
</div>
<div class="formula-row">
<div class="formula-term ft-demand">
<div class="formula-term-label">Demand Liabilities</div>
<div class="formula-term-val">&#8377;{{ totalDemand | number: '1.2-2' }}</div>
<div class="formula-term-sub">16 line items</div>
</div>
<div class="formula-op">+</div>
<div class="formula-term ft-time">
<div class="formula-term-label">Time Liabilities</div>
<div class="formula-term-val">&#8377;{{ totalTime | number: '1.2-2' }}</div>
<div class="formula-term-sub">13 line items</div>
</div>
<div class="formula-op minus">&minus;</div>
<div class="formula-term ft-asset">
<div class="formula-term-label">Assets with Banking</div>
<div class="formula-term-val">&#8377;{{ totalBankingAssets | number: '1.2-2' }}</div>
<div class="formula-term-sub">3 line items</div>
</div>
<div class="formula-op equals">=</div>
<div class="formula-term ft-ndtl">
<div class="formula-term-label">NDTL</div>
<div class="formula-term-val">&#8377;{{ ndtl | number: '1.2-2' }}</div>
<div class="formula-term-sub">CRR &amp; SLR base</div>
<!-- Required AND held, side by side. The requirement alone answers half the question: a screen
headed "Reserve Requirements" that states ₹2.10 Cr without saying what is against it sends
a reader to another tab to find out whether the bank is short — which is the one thing they
opened this bar to learn. -->
<div class="crr-calcs">
@for (r of positionRatios(); track r.name) {
<div class="crr-calc" [class.crr-calc--short]="!r.met">
<!-- A ratio with no rate on record is said to have none. It used to print "SLR @ null%" and
a surplus of nothing, which reads as a requirement met rather than one not set. -->
<div class="crr-calc-label">{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}</div>
<div class="crr-calc-val">{{ money(r.required) }}</div>
<div class="crr-calc-held">
held {{ money(r.maintained) }}
<span [class.crr-short]="!r.met">
&middot; {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }}
</span>
</div>
</div>
}
<div class="crr-calc">
<div class="crr-calc-label">Total Reserve</div>
<div class="crr-calc-val">{{ money(totalReserve) }}</div>
<div class="crr-calc-held">CRR + SLR required</div>
</div>
</div>
</div>
<!-- CRR/SLR Bar -->
<div class="crr-bar">
<div class="crr-text">
<div class="crr-title">CRR &amp; SLR Reserve Requirements &mdash; RBI Computation on NDTL</div>
<div class="crr-desc">
Based on NDTL of &#8377;{{ ndtl | number: '1.2-2' }} Cr &middot; Data as of {{ reportingDateStr }} (45-day lag)
</div>
<!-- Reserve accounts, as they stand today -->
@if (activeTab === 'held') {
<div class="section-desc">
Where the cash reserve is held, with each account's balance on the business date
<strong>{{ position?.onDate || '—' }}</strong
>. The requirement below is charged on <strong>{{ position?.baseDate || '—' }}</strong
>, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
</div>
<div class="crr-calcs">
<div class="crr-calc">
<div class="crr-calc-label">CRR @ {{ crrRate }}.00%</div>
<div class="crr-calc-val">&#8377;{{ crrRequired | number: '1.2-2' }} Cr</div>
@if (position) {
<!-- Required against held, for each ratio. Stated before the accounts because a desk reads
the gap first and only then asks which account to move money into. -->
<div class="held-ratios">
@for (r of positionRatios(); track r.name) {
<div class="held-ratio" [class.held-ratio--short]="!r.met">
<div class="held-ratio__name">{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}</div>
<div class="held-ratio__grid">
<span>Required</span><strong>{{ money(r.required) }}</strong> <span>Maintained</span
><strong>{{ money(r.maintained) }}</strong>
<span>{{ r.met ? 'Surplus' : 'Shortfall' }}</span>
<strong [class.held-short]="!r.met">{{ money(r.met ? r.surplus : r.shortfall) }}</strong>
</div>
</div>
}
</div>
<div class="crr-calc">
<div class="crr-calc-label">SLR @ {{ slrRate }}.00%</div>
<div class="crr-calc-val">&#8377;{{ slrRequired | number: '1.2-2' }} Cr</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Account</th>
<th>Form 1 head</th>
<th class="right">Balance today</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
@for (a of position.accounts; track a.glAccountId) {
<tr>
<td class="sl">{{ $index + 1 }}</td>
<td>
<div class="item-name">{{ a.name }}</div>
<div class="item-sub mono">{{ a.glCode }}</div>
</td>
<td>
{{ a.item }} &middot; {{ a.head }}
<!-- An account the return does not read is named as such. It can still be moved
into — the money is real — but adding it to what is maintained would have
this screen claim a compliance the filed form does not. -->
@if (!a.countsTowardMaintained) {
<span class="held-chip">not counted on the return</span>
}
</td>
<td class="right amt">{{ money(a.balance) }}</td>
<td class="right">
<button class="held-move" (click)="openTransfer(a)">
<fa-icon icon="exchange-alt"></fa-icon>
Transfer
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="crr-calc">
<div class="crr-calc-label">Total Reserve</div>
<div class="crr-calc-val">&#8377;{{ totalReserve | number: '1.2-2' }} Cr</div>
</div>
</div>
</div>
}
}
<!-- Footer -->
<div class="dialog-footer">
@@ -158,7 +158,10 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
// ===== Summary Cards =====
.summary-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
// Five cards only while each can still hold its figure. A seventy-crore NDTL printed to the
// paise needs room, and fixed fifths cut it off at the card edge — the figures were there and
// unreadable. Below that width the row wraps to fewer, wider cards instead.
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
padding: 20px 28px;
}
@@ -167,8 +170,12 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
background: $surface;
border: 1px solid $border;
border-radius: 12px;
padding: 16px 18px;
// 18px of side padding on a fifth of the row left the figure barely more than half the card.
padding: 16px 14px;
position: relative;
// A grid item will not shrink below its content by default, which is how a long figure pushed
// its own card wider than its column and off the edge of the dialog.
min-width: 0;
overflow: hidden;
transition: box-shadow 0.2s;
@@ -210,16 +217,29 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
font-size: 10px;
color: $ink3;
font-family: $mono;
letter-spacing: 1px;
// Tightened from 1px: on a five-up row the tracking alone pushed "NET DEMAND & TIME LIABILITIES
// (NDTL)" onto a third line and squeezed the figure beneath it.
letter-spacing: 0.4px;
text-transform: uppercase;
margin-bottom: 6px;
// Wraps rather than clipping — a heading that reads "ASSETS WITH BANKIN" names nothing.
overflow-wrap: anywhere;
}
.sc-value {
font-size: 20px;
// Sized to the card rather than fixed: a figure that outgrows its box is worse than a smaller
// one, because the digits that fall off the right are the ones that distinguish it. Floors at
// 14px so a long figure shrinks rather than wraps mid-number.
font-size: clamp(14px, 1.45vw, 20px);
font-weight: 600;
color: $ink;
font-family: $mono;
// Figures are read by lining their digits up; a proportional fallback would stagger them.
font-variant-numeric: tabular-nums;
// A rupee figure never breaks across lines — "₹6,35,05," over "967.50" reads as two numbers.
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cr {
@@ -232,12 +252,16 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
.sc-change {
font-size: 11px;
margin-top: 4px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.sc-sub {
font-size: 11px;
color: $ink3;
margin-top: 4px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.up {
@@ -869,3 +893,112 @@ $slr-b: #bae6fd;
line-height: 1.6;
background: #fafafa;
}
/* ── Reserve accounts, as they stand today ── */
.pill-held {
background: #0b5394;
color: #fff;
}
/* Required against held, per ratio — read before the accounts, because a desk asks how big the
gap is before asking which account to cover it from. */
.held-ratios {
display: flex;
gap: 16px;
padding: 16px 20px 0;
flex-wrap: wrap;
}
.held-ratio {
flex: 1 1 260px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-left: 4px solid #137333;
border-radius: 8px;
background: #f8faf9;
}
/* A ratio that is not met is the thing this tab exists to surface. */
.held-ratio--short {
border-left-color: #c5221f;
background: #fdf6f6;
}
.held-ratio__name {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #5f6368;
margin-bottom: 10px;
}
.held-ratio__grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 14px;
font-size: 13px;
span {
color: #5f6368;
}
strong {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.held-short {
color: #c5221f;
}
/* An account the filed return does not read. Named rather than hidden: the money is real and can
be moved, but it settles nothing on the form. */
.held-chip {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background: #fff4e5;
color: #b06000;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.held-move {
padding: 5px 12px;
border: 1px solid #0b5394;
border-radius: 6px;
background: #fff;
color: #0b5394;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
&:hover {
background: #0b5394;
color: #fff;
}
}
/* What is held against each requirement, under the requirement itself. */
.crr-calc-held {
margin-top: 4px;
font-size: 11px;
color: $ink3;
white-space: nowrap;
}
/* A ratio that is not met — on a compliance bar this is the one thing that must not be missed. */
.crr-calc--short {
border-color: $red;
}
.crr-short,
.sc-short {
color: $red;
font-weight: 600;
}
@@ -8,12 +8,14 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatDialog, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { ReserveTransferDialogComponent } from './reserve-transfer-dialog.component';
/** One account under a head, at the base date and the reporting date. */
interface CrrSlrRow {
@@ -31,6 +33,47 @@ interface CrrSlrRow {
status: string;
}
/** One reserve account, with what it holds on the business date. */
interface ReserveAccount {
glAccountId: number;
glCode: string;
name: string;
balance: number;
/** The Form 1 item the account answers — V, VI(a), VI(b), VI(c). */
item: string;
head: string;
/**
* Whether the filed return reads this balance.
*
* An account the bank holds but has not mapped is still somewhere money can be moved, so it is
* listed — but counting it toward what is maintained would have this screen claim a compliance
* the return does not.
*/
countsTowardMaintained: boolean;
}
/** One ratio's standing: what it needs, what it has, and which way the gap runs. */
interface ReserveRatio {
required: number;
maintained: number;
surplus: number;
/** Positive — a shortfall is an amount to cover, not a negative surplus. */
shortfall: number;
met: boolean;
}
/** The reserve position as it stands on the business date. */
interface ReservePosition {
onDate: string;
/** The close the requirement is charged on — a fortnight back, by RBI's lag rule. */
baseDate: string;
crrPercentage: number | null;
slrPercentage: number | null;
crr: ReserveRatio;
slr: ReserveRatio;
accounts: ReserveAccount[];
}
@Component({
selector: 'mifosx-crr-slr-dialog',
standalone: true,
@@ -49,8 +92,18 @@ interface CrrSlrRow {
export class CrrSlrDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
activeTab: 'demand' | 'time' | 'assets' | 'slr' | 'held' = 'demand';
/**
* The reserve position on the business date — null until it loads, or if it cannot be read.
*
* Separate from the return the other tabs show: those report a fortnight close, this reports
* where the money is now.
*/
position: ReservePosition | null = null;
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
@@ -82,7 +135,23 @@ export class CrrSlrDialogComponent implements OnInit {
totalDemand: number | null = null;
totalTime: number | null = null;
totalBankingAssets: number | null = null;
/**
* Whether item III was actually deducted from the base.
*
* It nets against item I rather than against the whole base, and the netting floors at zero — so
* a bank holding more with other banks than it owes them has nothing deducted at all.
*/
bankingAssetsNet = false;
/** Item IV(a) — the bank's own net liabilities on the date being reported. */
ndtl: number | null = null;
/**
* Item IV(b) — the base a fortnight back, which both ratios are actually charged on.
*
* A separate figure from `ndtl` above and not interchangeable with it: the requirement is struck
* on this one, so a ratio or a tile that quotes the other states a percentage of something the
* requirement was never computed from.
*/
baseNdtl: number | null = null;
crrRate: number | null = null;
slrRate: number | null = null;
crrRequired: number | null = null;
@@ -98,7 +167,7 @@ export class CrrSlrDialogComponent implements OnInit {
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
*/
crore(value: number | null): string {
rupees(value: number | null): string {
if (value === null) {
return '—';
}
@@ -106,9 +175,19 @@ export class CrrSlrDialogComponent implements OnInit {
}
ngOnInit(): void {
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
const toCrore = (v: number | null | undefined): number | null =>
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
// Today's position, alongside the return below. The two answer different questions and are
// fetched separately: the return reports a fortnight close, this reports the balances as they
// stand on the business date, which is what a desk can still act on.
this.rbi.getReservePosition().subscribe({
next: (data: ReservePosition) => (this.position = data),
// A position that cannot be read leaves the other four tabs working. The tab simply shows
// nothing rather than taking the whole dialog down with it.
error: () => (this.position = null)
});
// Figures stay in rupees. They were rounded to crore until now, which on a bank this size threw
// away everything that tells one fortnight from the next — a seventy-crore NDTL and the one a
// fortnight later both read "₹70 Cr".
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
// at the month end — and this screen should show the close the bank last filed, not a position
@@ -139,27 +218,66 @@ export class CrrSlrDialogComponent implements OnInit {
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
// than recomputed, so this screen and the filed form cannot come to disagree.
const lines: any[] = data?.partA?.lines ?? [];
// Matched without regard to case: the form prints its heads in capitals — "(A) DEMAND
// LIABILITIES :" — and matching them as written left every tile reading a dash while the
// figures sat in the response.
// Matched without regard to case, and only against lines that actually carry figures. The
// form heads item I with "(a) Demand Liabilities:" — a caption with no values — directly
// above item II's "(A) DEMAND LIABILITIES :", which holds the figure. Matching on text
// alone found the caption first and read a dash off it.
const at = (startsWith: string): number | null => {
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
const want = startsWith.toUpperCase();
const line = lines.find(
(l: any) =>
Array.isArray(l?.values) &&
String(l?.label ?? '')
.toUpperCase()
.startsWith(want)
);
return line?.values?.[latest] ?? null;
};
this.totalDemand = toCrore(at('(A) Demand liabilities'));
this.totalTime = toCrore(at('(B) Time liabilities'));
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
// not the bank's liabilities on the day being reported.
this.ndtl = toCrore(at('IV. b)'));
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
// Item II's own two lines, matched including the trailing colon the form prints. Item I
// carries a "(b) Time Liabilities to the banking system" of its own — a different figure,
// and the one a looser match found first.
this.totalDemand = at('(A) DEMAND LIABILITIES :');
this.totalTime = at('(B) TIME LIABILITIES :');
this.totalBankingAssets = at('TOTAL OF III');
// Whether item III actually came off the base. The form nets it against item I — the
// liabilities OWED TO the banking system — and floors that netting at zero: where the
// bank's assets with other banks exceed what it owes them, the excess is NOT deducted and
// the base is item II alone. Stating "deducted from NDTL" in that case names a subtraction
// the return did not make, and a reader checking the arithmetic finds it does not work.
const liabilitiesToBanks = at('TOTAL OF I');
this.bankingAssetsNet =
liabilitiesToBanks !== null &&
this.totalBankingAssets !== null &&
liabilitiesToBanks > this.totalBankingAssets;
// Two different figures, and the screen needs both.
//
// IV(a) is the bank's OWN net liabilities on the reporting date — what the demand, time and
// banking-asset tiles add up to, so it is the one the formula row must end at. IV(b) is the
// base a fortnight back, which is what the requirement is charged on.
//
// Showing IV(b) as the result of the formula was arithmetic that did not work: three terms
// struck on one date reaching a figure struck on another, off by whatever the liabilities
// had moved in between.
this.ndtl = at('IV. a)');
this.baseNdtl = at('IV. b)');
this.crrRequired = data?.partB?.required?.[latest] ?? null;
this.slrRequired = data?.partC?.required?.[latest] ?? null;
this.totalLiquidAssets = data?.partC?.maintained?.[latest] ?? null;
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
}
if (this.ndtl) {
// Struck on the base, not on today's own liabilities: the requirement below it is charged
// on the base, and a ratio measured against a different denominator from the one the
// requirement uses cannot be compared with it — "33.62% against a minimum of 18%" would be
// two percentages of two different things.
if (this.baseNdtl) {
this.slrActualPercent =
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.baseNdtl) * 100).toFixed(2);
}
if (this.crrRequired !== null && this.slrRequired !== null) {
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
@@ -209,13 +327,20 @@ export class CrrSlrDialogComponent implements OnInit {
// The form's own wording, with its numbering trimmed off the front — the table has a column
// of its own for that.
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
code: line.configKey ?? '—',
opening: this.crore(opening),
current: this.crore(current),
// The form's own numbering — I(a)(ii), II(A), VII(c) — rather than the configuration key
// behind it. A reader checking this screen against the filed return looks for the item
// number; FORM1_I_A_II_OTHER_DEMAND_BS is the name of a database row and means nothing to
// them.
sub: '',
code: (label.match(/^\s*\(?([IVXivx]+|[A-Za-z0-9]+)\)?[.)]?/) ?? [
,
'—'
])[1] ?? '—',
opening: this.rupees(opening),
current: this.rupees(current),
openingValue: opening,
currentValue: current,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`,
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
status: line.configured === false ? 'Not mapped' : 'Active'
@@ -235,7 +360,7 @@ export class CrrSlrDialogComponent implements OnInit {
*/
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
return values.length ? this.rupees(values.reduce((a, b) => a + b, 0)) : '—';
}
/** The movement across a tab, between the two closes the form reports. */
@@ -246,7 +371,7 @@ export class CrrSlrDialogComponent implements OnInit {
return '—';
}
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
return `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`;
}
/** Fills the four tabs from one reading of the form. */
@@ -257,16 +382,148 @@ export class CrrSlrDialogComponent implements OnInit {
};
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
// maps a new account to a head gets it on the right tab without this screen being changed.
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
// III — what the banking system owes this bank, which nets off the liability base.
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
// Item II only — what the bank owes its own depositors. Item I's lines are worded the same way
// but are owed to the banking system, and the form nets them against item III rather than
// adding them here, so a tab headed "payable on demand" that carried both would double-count
// the one and misstate the other.
const toBankingSystem = (l: string): boolean => has(l, 'to the banking system', 'banking system');
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit') && !toBankingSystem(l));
this.timeLiabilities = this.rowsFrom(
lines,
latest,
(l) => has(l, 'time liabilit') && !has(l, 'demand and time') && !toBankingSystem(l)
);
// V, VI and VII — the cash, balances and securities the two ratios are met in.
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
// III — what the banking system owes this bank, which nets off the liability base. Matched on
// the words the form actually prints: its item III(a) reads "TOTAL OF CREDIT BALANCES IN
// CURRENT ACCOUNT maintained with S.B.I...", which the shorter phrases this looked for never
// appeared in, so the tab came out empty while its total showed a figure.
this.bankingAssets = this.rowsFrom(
lines,
latest,
(l) =>
has(
l,
'credit balances in current account',
'other assets with the banking system',
'balances in all accounts',
'money at call',
'advances & any other'
) &&
// Item I(a)(i) is worded almost identically but is the opposite side of the ledger: what
// the banking system holds WITH this bank, a liability. On a tab headed "deducted from
// gross liabilities" it would be read as an asset and netted the wrong way.
!has(l, 'maintained with the co-operative bank by state')
);
// V, VI and VII — the cash, balances and securities the two ratios are met in. Item I's own
// SBI line is excluded: it is a liability TO the banking system, and listing it among the
// assets held against a ratio reads as something the bank can count toward it.
this.liquidAssets = this.rowsFrom(
lines,
latest,
(l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co.op.bank', 'co-operative bank', 'govt. security') &&
!has(l, 'maintained with the co-operative bank by state')
);
}
/**
* The two ratios as rows, so the template states them once rather than twice over.
*/
positionRatios(): {
name: string;
rate: number | null;
required: number;
maintained: number;
surplus: number;
shortfall: number;
met: boolean;
}[] {
if (!this.position) {
return [];
}
return [
{ name: 'CRR', rate: this.position.crrPercentage, ...this.position.crr },
{ name: 'SLR', rate: this.position.slrPercentage, ...this.position.slr }
];
}
/**
* A reserve figure in rupees, to the paisa.
*
* Not rounded like the table cells above: a desk covering a shortfall transfers an exact amount, and
* "₹0.37 Cr" is not an amount anyone can send.
*/
money(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* A figure for a summary tile, scaled so it fits however large the bank grows.
*
* Tiles are read for order of magnitude, so they carry whole rupees up to a crore and switch to
* crore above it: ₹70,10,45,062 is nineteen characters and overflows its card, where ₹70.10 Cr
* is seven and says the same thing to anyone glancing at it. The exact amount, to the paise, is
* on the tables below and on the reserve-accounts tab — which is where anyone acting on a figure
* rather than scanning it goes.
*
* Scaled rather than merely shrunk, because a fixed format only ever fits the balances the bank
* happens to hold today. This one holds at four or five characters whether the figure is lakhs
* or lakh-crore, so a bank that grows ten-fold does not come back to a row of clipped tiles.
*/
tile(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
// Indian scale: a crore is 1e7, a lakh 1e5. Below a lakh the whole rupee figure is already
// short enough that scaling it would hide detail for nothing.
if (abs >= 1e7) {
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
}
if (abs >= 1e5) {
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
}
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
}
/**
* Move money into or out of a reserve account.
*
* The entry posts through the platform's own journal command, so it goes to maker-checker like
* any other entry against the ledger — the money is real, and a transfer that settles a statutory
* requirement is not a thing one person should be able to post alone.
*/
openTransfer(account: ReserveAccount): void {
const ref = this.dialog.open(ReserveTransferDialogComponent, {
data: {
from: account,
// Only the reserve accounts. A transfer out of the cash reserve into something that does
// not count toward it would quietly reduce what is maintained, and this screen exists to
// keep that figure up.
accounts: this.position?.accounts ?? [],
// The entry posts against the user's own office — the one whose books hold these accounts.
// Falls back to head office, which Fineract seeds as 1, rather than failing to open.
officeId: this.auth.getCredentials()?.officeId ?? 1,
currencyCode: 'INR'
},
autoFocus: false
});
ref.afterClosed().subscribe((posted: boolean) => {
if (posted) {
// The balances on screen are now a step behind the ledger. Read again rather than adjusting
// them here: the entry may have gone to maker-checker and not yet moved anything, and a
// screen that assumed it had would show a position the bank is not in.
this.rbi.getReservePosition().subscribe({
next: (data: ReservePosition) => (this.position = data),
error: () => undefined
});
}
});
}
close(): void {
@@ -0,0 +1,124 @@
<!--
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="rt">
<div class="rt-head">
<h2 class="rt-title">Transfer between reserve accounts</h2>
<button class="rt-x" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- The two accounts, the way a desk thinks about them: money leaves one and arrives in the
other. The ledger's debit and credit are worked out when the entry posts, because asking a
treasury user which side to credit in order to move their own money is asking them to do the
ledger's job. -->
<div class="rt-from">
<div class="rt-field-label">From</div>
<div class="rt-acct">
<div class="rt-acct-name">{{ from.name }}</div>
<div class="rt-acct-sub">{{ from.glCode }} &middot; {{ from.item }} {{ from.head }}</div>
</div>
<div class="rt-bal">
<span class="rt-bal-label">Balance</span>
<span class="rt-bal-val">{{ money(from.balance) }}</span>
</div>
</div>
<div class="rt-arrow"><fa-icon icon="arrow-down"></fa-icon></div>
<div class="rt-row">
<label class="rt-field-label" for="rt-to">To</label>
<select id="rt-to" class="rt-input" [(ngModel)]="toAccountId">
<option [ngValue]="null">Choose the account to receive it</option>
@for (a of destinations; track a.glAccountId) {
<option [ngValue]="a.glAccountId">{{ a.glCode }} &middot; {{ a.name }} &mdash; {{ money(a.balance) }}</option>
}
</select>
</div>
<div class="rt-row">
<label class="rt-field-label" for="rt-amt">Amount to transfer</label>
<div class="rt-amount-wrap">
<span class="rt-rupee">&#8377;</span>
<input
id="rt-amt"
class="rt-input rt-amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
<!-- An account cannot send more than it holds: the ledger would carry a negative balance on a
bank account, which is not a position that exists. Said as the amount is typed rather than
on submit, so the figure is corrected before anyone commits to it. -->
@if (overdrawn) {
<div class="rt-warn">More than {{ from.name }} holds. It has {{ money(from.balance) }}.</div>
}
</div>
<!-- What each account would hold once this posts. A transfer is made to reach a balance, so the
balance it reaches is the thing worth showing before it is sent. -->
@if (to && (amount ?? 0) > 0 && !overdrawn) {
<div class="rt-after">
<div class="rt-after-row">
<span>{{ from.name }}</span>
<span
>{{ money(from.balance) }} &rarr; <strong>{{ money(fromAfter) }}</strong></span
>
</div>
<div class="rt-after-row">
<span>{{ to.name }}</span>
<span
>{{ money(to.balance) }} &rarr; <strong>{{ money(toAfter) }}</strong></span
>
</div>
</div>
}
<div class="rt-row">
<label class="rt-field-label" for="rt-ref">Reference <span class="rt-opt">optional</span></label>
<input id="rt-ref" class="rt-input" type="text" placeholder="NEFT / RTGS reference" [(ngModel)]="reference" />
</div>
<div class="rt-row">
<label class="rt-field-label" for="rt-note">Note <span class="rt-opt">optional</span></label>
<input
id="rt-note"
class="rt-input"
type="text"
placeholder="Why the money is being moved"
[(ngModel)]="comments"
/>
</div>
<!-- What this does and does not do. The entry is the bank's record of the movement; the money
reaches the other bank when the desk sends the NEFT or RTGS. A screen that posted silently
would leave someone believing the funds had moved. -->
<div class="rt-note">
<fa-icon icon="info-circle"></fa-icon>
This records the movement in the ledger and goes for approval. Send the NEFT or RTGS separately &mdash; the money
does not move until you do.
</div>
@if (error) {
<div class="rt-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ error }}
</div>
}
<div class="rt-actions">
<button mat-button (click)="close()">Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Posting…' : 'Post transfer' }}
</button>
</div>
</div>
@@ -0,0 +1,223 @@
/**
* 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/.
*/
$ink: #202124;
$ink2: #3c4043;
$ink3: #5f6368;
$border: #e2e8f0;
$primary: #0b5394;
$red: #c5221f;
$amber: #b06000;
$surface: #fff;
$mono: 'Roboto Mono', 'Courier New', monospace;
.rt {
padding: 20px 24px 16px;
min-width: 420px;
max-width: 520px;
}
.rt-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
}
.rt-title {
margin: 0;
font-size: 17px;
font-weight: 600;
color: $ink;
}
.rt-x {
border: none;
background: none;
color: $ink3;
cursor: pointer;
padding: 2px 6px;
&:hover {
color: $ink;
}
}
.rt-field-label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: $ink3;
margin-bottom: 6px;
}
.rt-opt {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: $ink3;
opacity: 0.75;
}
// The source account — fixed, since the dialog is opened from its row, so it reads as a statement
// rather than as another field to fill in.
.rt-from {
padding: 12px 14px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
}
.rt-acct-name {
font-size: 14px;
font-weight: 600;
color: $ink;
}
.rt-acct-sub {
font-size: 11px;
color: $ink3;
font-family: $mono;
margin-top: 2px;
}
.rt-bal {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed $border;
}
.rt-bal-label {
font-size: 11px;
color: $ink3;
}
.rt-bal-val {
font-family: $mono;
font-size: 14px;
font-weight: 600;
color: $ink;
font-variant-numeric: tabular-nums;
}
.rt-arrow {
text-align: center;
color: $primary;
margin: 10px 0;
}
.rt-row {
margin-bottom: 14px;
}
.rt-input {
width: 100%;
box-sizing: border-box;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-size: 14px;
color: $ink2;
background: $surface;
&:focus {
outline: none;
border-color: $primary;
}
}
// The amount carries the rupee inside the field, so the figure is read as money without the sign
// being something anyone has to type.
.rt-amount-wrap {
position: relative;
}
.rt-rupee {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: $ink3;
font-size: 14px;
}
.rt-amount {
padding-left: 26px;
font-family: $mono;
font-variant-numeric: tabular-nums;
}
.rt-warn {
margin-top: 6px;
font-size: 12px;
color: $red;
}
// What each account would hold once this posts — a transfer is made to reach a balance, so the
// balance it reaches belongs on screen before it is sent.
.rt-after {
padding: 10px 14px;
margin-bottom: 14px;
border: 1px solid #cfe3d4;
border-radius: 8px;
background: #f1f8f3;
font-size: 12px;
}
.rt-after-row {
display: flex;
justify-content: space-between;
gap: 16px;
color: $ink2;
& + & {
margin-top: 5px;
}
strong {
font-family: $mono;
font-variant-numeric: tabular-nums;
}
}
// What the entry does and does not do: the money moves when the desk sends the NEFT, not when this
// posts, and a dialog that stayed silent would leave someone believing otherwise.
.rt-note {
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
background: #fff8e6;
color: $amber;
font-size: 12px;
line-height: 1.45;
}
.rt-error {
display: flex;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #fdecea;
color: $red;
font-size: 12px;
line-height: 1.45;
}
.rt-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
@@ -0,0 +1,166 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
/** A reserve account as the position screen lists it. */
export interface TransferAccount {
glAccountId: number;
glCode: string;
name: string;
balance: number;
item: string;
head: string;
}
/** What the dialog is opened with: the account clicked, and everything it may move to. */
export interface ReserveTransferData {
from: TransferAccount;
accounts: TransferAccount[];
/** The office the entry posts against, and the date it posts on. */
officeId: number;
currencyCode: string;
}
/**
* Moves money between the accounts the cash reserve is held in.
*
* The entry posts through the platform's own journal command, so it goes to maker-checker like any
* other entry against the ledger. That is deliberate: the amounts settle a statutory requirement,
* and a transfer that changes whether the bank is compliant is not something one person should be
* able to post alone.
*
* It records the movement; it does not make it. The money reaches the other bank when the treasury
* desk sends the NEFT or RTGS, and this entry is the bank's own record of having done so — which is
* why the dialog says as much before it posts.
*/
@Component({
selector: 'mifosx-reserve-transfer-dialog',
standalone: true,
templateUrl: './reserve-transfer-dialog.component.html',
styleUrls: ['./reserve-transfer-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
FaIconComponent
]
})
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;
/** Everything it can move to: the other reserve accounts, never itself. */
readonly destinations: TransferAccount[];
toAccountId: number | null = null;
amount: number | null = null;
reference = '';
comments = '';
submitting = false;
error = '';
constructor() {
this.from = this.data.from;
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
}
/** The account chosen to receive the money, while one is chosen. */
get to(): TransferAccount | null {
return this.destinations.find((a) => a.glAccountId === this.toAccountId) ?? null;
}
/** What the source would hold once this posts — the figure that decides whether it may. */
get fromAfter(): number {
return this.from.balance - (this.amount ?? 0);
}
get toAfter(): number {
return (this.to?.balance ?? 0) + (this.amount ?? 0);
}
/**
* Whether the amount can actually be sent.
*
* An account cannot send more than it holds: the ledger would carry a negative balance on a cash
* or bank account, which is not a position that exists.
*/
get overdrawn(): boolean {
return this.amount !== null && this.amount > this.from.balance;
}
get canSubmit(): boolean {
return !this.submitting && this.toAccountId !== null && (this.amount ?? 0) > 0 && !this.overdrawn;
}
money(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* Posts the transfer as a single journal entry.
*
* These are asset accounts, so the one money LEAVES is credited and the one it arrives in is
* debited — the opposite of how the screen words it, and deliberately so: a treasury user thinks
* "send from here, receive there", and making them think in debits and credits to move their own
* money would be asking them to do the ledger's job.
*/
submit(): void {
if (!this.canSubmit || !this.to) {
return;
}
this.submitting = true;
this.error = '';
// The reserve endpoint, not the manual journal entry one: that is turned off at the HTTP edge
// on this installation. The sides are named from and to, as a desk thinks of them; the debit
// and credit are worked out on the server, where the accounts can also be checked.
const transfer = {
fromGlAccountId: this.from.glAccountId,
toGlAccountId: this.to.glAccountId,
amount: this.amount as number,
officeId: this.data.officeId,
reference: this.reference || undefined,
comments:
this.comments || `Reserve transfer: ${this.from.glCode} ${this.from.name} to ${this.to.glCode} ${this.to.name}`
};
this.rbi.transferReserve(transfer).subscribe({
next: () => this.dialogRef.close(true),
error: (err: any) => {
this.submitting = false;
// The platform's own message, not a rewritten one: it names the rule that stopped the
// entry — a closed accounting period, a disabled account — and a generic "could not post"
// sends the user to look for a fault that has already been explained.
this.error =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.error?.errors?.[0]?.defaultUserMessage ||
'The transfer could not be posted.';
}
});
}
close(): void {
this.dialogRef.close(false);
}
}
@@ -38,12 +38,18 @@
>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div>
@if (crrSlr.crrGap) {
<div class="metric-gap" [class.metric-gap--short]="crrSlr.crrGap.short">{{ crrSlr.crrGap.text }}</div>
}
<div class="metric-row">
<span class="metric-label"
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div>
@if (crrSlr.slrGap) {
<div class="metric-gap" [class.metric-gap--short]="crrSlr.slrGap.short">{{ crrSlr.slrGap.text }}</div>
}
</div>
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
says so. Where it cannot compute, the card says that instead of passing the bank. -->
@@ -899,3 +899,17 @@ $purple: #7c3aed;
text-align: center;
}
}
// What is held against a requirement, under the requirement itself. Surplus is quiet; a shortfall is
// red, because on a compliance card it is the one line that must not be read past.
.metric-gap {
margin: -2px 0 8px;
text-align: right;
font-size: 11px;
color: #137333;
}
.metric-gap--short {
color: #c5221f;
font-weight: 700;
}
+48 -5
View File
@@ -61,8 +61,19 @@ export class DashboardComponent implements OnInit {
slrRate: number | null;
crrRequired: string;
slrRequired: string;
/** Surplus, or the shortfall, per ratio. A shortfall is its own amount, not a negative surplus. */
crrGap: { text: string; short: boolean } | null;
slrGap: { text: string; short: boolean } | null;
status: 'ok' | 'short' | 'unknown';
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
} = {
crrRate: null,
slrRate: null,
crrRequired: '—',
slrRequired: '—',
crrGap: null,
slrGap: null,
status: 'unknown'
};
/** Array of all user activities */
userActivity: string[];
@@ -93,8 +104,27 @@ export class DashboardComponent implements OnInit {
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
private loadCrrSlr(): void {
const crore = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
// Rupees, not crore. Rounding to crore on a bank this size collapsed the figures to "₹2 Cr"
// and "₹13 Cr" — two requirements a crore apart both read the same, and a tile a treasury desk
// glances at said nothing it could act on.
// Scaled to the Indian crore/lakh so the tile holds its figure however large the bank grows.
// Plain rupees ran to nineteen characters on a seventy-crore requirement and overflowed the
// card; rounding to crore outright collapsed two requirements a crore apart to the same "₹2
// Cr". This keeps four or five characters and two decimals of precision at any scale.
const money = (v: number | null | undefined): string => {
if (v === null || v === undefined) {
return '—';
}
const abs = Math.abs(v);
const sign = v < 0 ? '-' : '';
if (abs >= 1e7) {
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
}
if (abs >= 1e5) {
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
}
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
};
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
@@ -107,8 +137,8 @@ export class DashboardComponent implements OnInit {
}
this.crrSlr.crrRate = data?.crrPercentage ?? null;
this.crrSlr.slrRate = data?.slrPercentage ?? null;
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
this.crrSlr.crrRequired = money(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = money(data?.partC?.required?.[latest]);
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
// bank short on SLR is not within limits because its cash reserve happens to be ample.
@@ -116,6 +146,19 @@ export class DashboardComponent implements OnInit {
const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest];
// Each ratio's own standing, under its requirement: surplus where it is met, and the
// shortfall — in red, named as one — where it is not. A single "Shortfall" tag said the bank
// was short without saying which ratio or by how much, which is the first thing asked.
const gap = (held: number | null | undefined, need: number | null | undefined) => {
if (held === null || held === undefined || need === null || need === undefined) {
return null;
}
const short = held < need;
return { text: `${short ? 'Short' : 'Surplus'} ${money(Math.abs(held - need))}`, short };
};
this.crrSlr.crrGap = gap(crrHeld, crrNeed);
this.crrSlr.slrGap = gap(slrHeld, slrNeed);
if ([
crrHeld,
crrNeed,
File diff suppressed because it is too large Load Diff
@@ -108,7 +108,6 @@
.km-main {
padding: 20px 24px;
max-width: 1400px;
}
.km-page-head {
@@ -1778,3 +1777,7 @@
border-radius: 2px;
}
}
.km-rep {
margin-top: 24px;
}
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;
}
@@ -0,0 +1,15 @@
/**
* 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 { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
@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'));
}
}
+1
View File
@@ -967,6 +967,7 @@ export const REPORT_META: ReportMeta[] = [
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
{ id: 'sma', group: 'classification', label: 'SMA Report' },
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
{ id: 'cc-recovery', group: 'recovery-mgmt', label: 'Cash Credit (CC) Recovery Management' },
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
@@ -1400,6 +1400,184 @@
</div>
}
<!-- ===================== CASH CREDIT (CC) RECOVERY MANAGEMENT ===================== -->
@if (activeReport === 'cc-recovery') {
<div class="pg-card">
<div class="pos-section-label">
Cash Credit (CC) Recovery Management — Drawing Power &amp; Overdrawn-Day Tracking
</div>
<p class="pg-sub npa-report-note">
Cash Credit is a revolving working-capital facility secured against stock / receivables. Unlike a Term Loan, it
isn't checked against a fixed instalment — it is checked against its <strong>Drawing Power (DP)</strong>, and
tracked by how many continuous <strong>Days Overdrawn</strong> ("Out of Order") it has run — crossing 90 days
turns it <strong>NPA</strong>.
</p>
<div class="npa-glossary">
<div class="npa-glossary-card npa-glossary-card--dp">
<span class="npa-glossary-card__icon"><fa-icon icon="wallet"></fa-icon></span>
<div class="npa-glossary-card__term">Drawing Power (DP)</div>
<p class="npa-glossary-card__desc">
The maximum amount the borrower may actually draw against the sanctioned CC limit — fixed from the value of
stock / book-debts declared in the latest stock statement. Can be lower than the sanctioned limit.
</p>
</div>
<div class="npa-glossary-card npa-glossary-card--ooo">
<span class="npa-glossary-card__icon"><fa-icon icon="hourglass-half"></fa-icon></span>
<div class="npa-glossary-card__term">Out of Order</div>
<p class="npa-glossary-card__desc">
A CC account turns "Out of Order" once its outstanding continuously stays above the limit/DP, or adequate
credits stop coming in, for 90 days — the CC-specific overdue trigger, in place of a missed EMI.
</p>
</div>
<div class="npa-glossary-card npa-glossary-card--sma">
<span class="npa-glossary-card__icon"><fa-icon icon="flag"></fa-icon></span>
<div class="npa-glossary-card__term">Days Overdrawn</div>
<p class="npa-glossary-card__desc">
The running count of continuous days an account has stayed Out of Order — the number a recovery desk
actually watches day to day, instead of a classification label. A rising count is the early-warning sign.
</p>
</div>
<div class="npa-glossary-card npa-glossary-card--npa">
<span class="npa-glossary-card__icon"><fa-icon icon="ban"></fa-icon></span>
<div class="npa-glossary-card__term">NPA (Non-Performing Asset)</div>
<p class="npa-glossary-card__desc">
Once Out of Order crosses 90 days, the CC account slips to NPA — Sub-Standard, then Doubtful-1/2/3, then
Loss Asset as it ages further.
</p>
</div>
</div>
</div>
<div class="pg-card">
<div class="inv-kpis npa-kpis--ratio npa-kpis--compact">
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--neutral">
<span class="npa-kpi__icon"><fa-icon icon="wallet"></fa-icon></span>
<span class="inv-kpi__label">Total CC Accounts</span>
<span class="inv-kpi__value">{{ ccAccounts.length }}</span>
<span class="inv-kpi__sub">₹{{ ccTotalSanctioned | number: '1.0-0' }} sanctioned</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--warn">
<span class="npa-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></span>
<span class="inv-kpi__label">Overdrawn (1–90 Days)</span>
<span class="inv-kpi__value" [class.npa-warn]="ccOverdrawnCount > 0">{{ ccOverdrawnCount }}</span>
<span class="inv-kpi__sub">Out of Order, not yet NPA</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--npa">
<span class="npa-kpi__icon"><fa-icon icon="ban"></fa-icon></span>
<span class="inv-kpi__label">NPA (90+ Days Overdrawn)</span>
<span class="inv-kpi__value npa-warn">{{ ccNpaCount }}</span>
<span class="inv-kpi__sub">₹{{ ccTotalOutstanding | number: '1.0-0' }} total outstanding</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--info">
<span class="npa-kpi__icon"><fa-icon icon="money-bill-wave"></fa-icon></span>
<span class="inv-kpi__label">Interest Paid (This Cycle)</span>
<span class="inv-kpi__value">₹{{ ccInterestPaidThisCycle | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">of ₹{{ ccInterestDueThisCycle | number: '1.0-0' }} due</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--positive">
<span class="npa-kpi__icon"><fa-icon icon="hand-holding-usd"></fa-icon></span>
<span class="inv-kpi__label">Total Recovered</span>
<span class="inv-kpi__value">₹{{ ccTotalRecovered | number: '1.0-0' }}</span>
<span class="inv-kpi__sub"
>{{ ccRecoveryPct | number: '1.1-1' }}% of ₹{{ ccTotalSanctioned | number: '1.0-0' }} sanctioned</span
>
</div>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">
Cash Credit Accounts — Limit, DP &amp; Classification ({{ ccOutOfOrderCount }} of {{ ccAccounts.length }} Out of
Order)
</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th class="num">Sanctioned Limit</th>
<th class="num">Drawing Power (DP)</th>
<th class="num">Outstanding</th>
<th class="num col-out">Pending to Recover</th>
<th class="num col-out">Excess Over DP</th>
<th class="num">Interest Due (This Cycle)</th>
<th class="num col-in">Interest Paid (This Cycle)</th>
<th class="num col-in">Total Recovered</th>
<th class="num">Days Overdrawn</th>
<th>Status</th>
<th>Recovery Officer</th>
</tr>
</thead>
<tbody>
@for (a of paged(ccAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td class="num">₹{{ a.sanctionedAmount | number: '1.0-0' }}</td>
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-in]="actualThisMonth(a) > 0" [class.col-out]="actualThisMonth(a) === 0">
₹{{ actualThisMonth(a) | number: '1.0-0' }}
</td>
<td class="num col-in">₹{{ recovered(a) | number: '1.0-0' }}</td>
<td class="num">{{ isNpaStageOf(a) || overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!isNpaStageOf(a) && !overdrawnBreachOf(a)"
[class.npa-badge--sma1]="!isNpaStageOf(a) && overdrawnBreachOf(a)"
[class.npa-badge--substandard]="isNpaStageOf(a)"
>
{{ isNpaStageOf(a) ? 'NPA' : overdrawnBreachOf(a) ? 'Overdrawn' : 'Regular' }}
</span>
</td>
<td>{{ a.recoveryOfficer }}</td>
</tr>
} @empty {
<tr>
<td colspan="14" class="pg-empty">No Cash Credit accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (ccAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(ccAccounts) }}–{{ rangeEnd(ccAccounts) }} of {{ ccAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, ccAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(ccAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(ccAccounts)"
(click)="goToPage(currentPage + 1, ccAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
@if (activeReport === 'agri-gold-value') {
<div class="pg-card">

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