Merge branch 'feature-demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into feature-demo

This commit is contained in:
2026-10-19 15:41:08 +05:30
50 changed files with 8510 additions and 2266 deletions
+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,747 @@
<!--
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') }}
<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>Required</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
@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 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') }}
<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>Required</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
@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 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,546 @@
/**
* 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';
}
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.';
}
});
}
}
+4
View File
@@ -74,10 +74,12 @@ 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';
@@ -191,10 +193,12 @@ export function HttpLoaderFactory(http: HttpClient) {
ReconciliationModule,
LoanOriginationModule,
CreditBureauModule,
ApiManagementModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
ElectionModule,
LoanEligibilityModule,
SponsorDDModule,
SponsorDDReconciliationModule,
AppRoutingModule,
+14 -40
View File
@@ -407,9 +407,14 @@
<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" [matMenuTriggerFor]="creditBureauMenu" *mifosxHasPermission="'MENU_CREDIT_BUREAU'">
<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
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
@@ -512,10 +517,15 @@
><span class="ni-short">Loans</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="creditBureauMenu" *mifosxHasPermission="'MENU_CREDIT_BUREAU'">
<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>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
@@ -1671,42 +1681,6 @@
</div>
</mat-menu>
<mat-menu #creditBureauMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
<!-- Enquiry -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bureau Enquiry</div>
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/dashboard">
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Dashboard
</button>
<button
mat-menu-item
tabindex="0"
routerLink="/credit-bureau/new-enquiry"
*mifosxHasPermission="'CREATE_CICENQUIRY'"
>
<fa-icon icon="search" class="menu-item-icon"></fa-icon> New Enquiry (CIBIL / CIC)
</button>
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/enquiries">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Enquiry History &amp; Reports
</button>
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/consents">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Consent Register
</button>
</div>
<!-- Setup -->
<div class="deposit-menu-group">
<div class="deposit-group-title">CIC Setup</div>
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/bureaus">
<fa-icon icon="shield-alt" class="menu-item-icon"></fa-icon> CIC Whitelist &amp; Configuration
</button>
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/api-mapping">
<fa-icon icon="project-diagram" class="menu-item-icon"></fa-icon> API Mapping
</button>
</div>
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
<!-- KYC Operations -->
@@ -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,41 @@
</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, SMS, WhatsApp …)</span>
</span>
</button>
<button mat-menu-item class="ai-card" routerLink="/credit-bureau/bureaus" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="id-card"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">CIC Whitelist &amp; Configuration</span>
<span class="ai-card-d">Choose and connect CIBIL, Experian, Equifax, CRIF</span>
</span>
</button>
<button mat-menu-item class="ai-card" routerLink="/credit-bureau/api-mapping" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="project-diagram"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">CIC API Mapping</span>
<span class="ai-card-d">Map CBS fields to the bureau's request and response</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);
}
}
@@ -9,13 +9,14 @@
<div class="cic-page">
<div class="cic-head">
<div>
<h1>CIC API Mapping</h1>
<h1>CIC API Mapping — Enquiry Alerts</h1>
<p>
Map CBS data to each bureau's request, and the bureau's response back to the score and report — no code change
needed.
How the CBS asks each bureau for enquiry alerts, and how the bureau's answer is read into our alerts (customer,
loan type, amount, enquiry date …) — no code change needed.
</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/credit-bureau/dashboard">← Dashboard</a>
<a class="cic-btn" routerLink="/credit-bureau/bureaus">CIC Configuration</a>
<button
class="cic-btn primary"
@@ -39,10 +40,12 @@
</select>
</div>
<div class="cic-field">
<label>Enquiry type</label>
<label>API</label>
<select [(ngModel)]="endpointType" (ngModelChange)="load()">
@for (t of catalog?.endpointTypes ?? []; track t) {
<option [value]="t">{{ t === 'CONSUMER_ENQUIRY' ? 'Consumer enquiry' : 'Commercial enquiry' }}</option>
<option [value]="t">
{{ t === 'ENQUIRY_ALERT' ? 'Enquiry alerts (our customer enquired elsewhere)' : t }}
</option>
}
</select>
</div>
@@ -77,7 +80,8 @@
@if (bureau?.environment === 'SIMULATOR') {
<div class="cic-alert info">
In SIMULATOR mode the built-in test bureau answers in the shape of the seeded generic mapping. When moving to the
bureau's UAT / PRODUCTION, change the paths below to match the bureau's own API specification.
bureau's UAT / PRODUCTION, change the paths below to the bureau's own API specification and check them with Test
Response.
</div>
}
@@ -87,29 +91,124 @@
@if (endpoint && !loading) {
<div class="cic-tabs">
<button [class.active]="tab === 'request'" (click)="tab = 'request'">
Request mapping ({{ requestRows.length }})
</button>
<button [class.active]="tab === 'response'" (click)="tab = 'response'">
Response mapping ({{ responseRows.length }})
</button>
<button [class.active]="tab === 'request'" (click)="tab = 'request'">
Request mapping ({{ requestRows.length }})
</button>
<button [class.active]="tab === 'endpoint'" (click)="tab = 'endpoint'">Endpoint, headers &amp; body</button>
<button [class.active]="tab === 'preview'" (click)="tab = 'preview'">Preview (dry run)</button>
<button [class.active]="tab === 'test'" (click)="tab = 'test'">Test response</button>
<button [class.active]="tab === 'preview'" (click)="tab = 'preview'">Preview request</button>
</div>
<!-- REQUEST -->
@if (tab === 'request') {
<!-- RESPONSE -->
@if (tab === 'response') {
<div class="cic-card">
<h2>
CBS field → bureau request
<span class="cic-card-sub">Paths like <code>consumer.ids[0].number</code> build nested JSON and arrays</span>
Bureau response → CBS alert
<span class="cic-card-sub"
>"alerts", "errorCode" and "errorMessage" are read from the whole response; every other field from each
alert</span
>
</h2>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th style="width: 34px">#</th>
<th style="min-width: 200px">CBS field</th>
<th style="min-width: 240px">Bureau response JSON path</th>
<th style="min-width: 220px">CBS alert field</th>
<th style="min-width: 150px">Transform</th>
<th style="min-width: 110px">Default</th>
<th style="width: 110px"></th>
</tr>
</thead>
<tbody>
@for (r of responseRows; track $index; let i = $index) {
<tr>
<td class="muted">{{ i + 1 }}</td>
<td>
<input
class="cic-mono"
[(ngModel)]="r.sourceField"
(ngModelChange)="markDirty()"
[placeholder]="isRoot(r.targetField) ? 'data.alerts' : 'enquiry.amount'"
/>
</td>
<td>
<input
class="cic-mono"
[(ngModel)]="r.targetField"
(ngModelChange)="markDirty()"
list="cic-result-fields"
placeholder="amount"
/>
<div class="muted" style="font-size: 10px">
{{ isRoot(r.targetField) ? 'whole response' : 'each alert' }}
</div>
</td>
<td>
<select [(ngModel)]="r.transform" (ngModelChange)="markDirty()">
@for (t of transformList; track t.key) {
<option [value]="t.key">{{ t.label }}</option>
}
</select>
</td>
<td><input [(ngModel)]="r.defaultValue" (ngModelChange)="markDirty()" /></td>
<td style="white-space: nowrap">
<button class="cic-btn sm" (click)="move('response', i, -1)" [disabled]="i === 0">↑</button>
<button
class="cic-btn sm"
(click)="move('response', i, 1)"
[disabled]="i === responseRows.length - 1"
>
↓
</button>
<button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<datalist id="cic-result-fields">
@for (f of resultFieldList; track f.key) {
<option [value]="f.key">{{ f.label }}</option>
}
</datalist>
<div class="cic-actions" style="margin-top: 12px">
<button class="cic-btn" (click)="addRow('response')">+ Add response field</button>
<span class="muted" style="font-size: 12px">
For dates the transform names the format the bureau sends (e.g. Date ddMMyyyy). The customer is matched by
PAN, then Aadhaar (with date of birth or name), then our customer reference.
</span>
</div>
<div class="cic-grid cols-3" style="margin-top: 14px">
@for (f of resultFieldList; track f.key) {
<div style="font-size: 12px">
<code>{{ f.key }}</code> <span class="muted">— {{ f.label }}</span>
</div>
}
</div>
</div>
}
<!-- REQUEST -->
@if (tab === 'request') {
<div class="cic-card">
<h2>
CBS value → bureau request
<span class="cic-card-sub"
>Paths like <code>criteria.fromDate</code> build nested JSON; <code>[0]</code> builds arrays</span
>
</h2>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th style="width: 34px">#</th>
<th style="min-width: 220px">CBS value</th>
<th style="min-width: 220px">Bureau JSON path</th>
<th style="min-width: 160px">Transform</th>
<th style="min-width: 120px">Fixed / default value</th>
@@ -134,7 +233,7 @@
class="cic-mono"
[(ngModel)]="r.targetField"
(ngModelChange)="markDirty()"
placeholder="consumer.name.firstName"
placeholder="criteria.fromDate"
/>
</td>
<td>
@@ -181,88 +280,7 @@
<div class="cic-actions" style="margin-top: 12px">
<button class="cic-btn" (click)="addRow('request')">+ Add request field</button>
<span class="muted" style="font-size: 12px"
>A mandatory field missing on the customer stops the enquiry before the bureau is called.</span
>
</div>
</div>
}
<!-- RESPONSE -->
@if (tab === 'response') {
<div class="cic-card">
<h2>
Bureau response → CBS result
<span class="cic-card-sub"
>"score" drives the score shown everywhere; "hit" = false marks a no-hit; "errorCode" marks a failure</span
>
</h2>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th style="width: 34px">#</th>
<th style="min-width: 240px">Bureau response JSON path</th>
<th style="min-width: 220px">CBS result field</th>
<th style="min-width: 150px">Transform</th>
<th style="min-width: 110px">Default</th>
<th style="width: 110px"></th>
</tr>
</thead>
<tbody>
@for (r of responseRows; track $index; let i = $index) {
<tr>
<td class="muted">{{ i + 1 }}</td>
<td>
<input
class="cic-mono"
[(ngModel)]="r.sourceField"
(ngModelChange)="markDirty()"
placeholder="report.score.value"
/>
</td>
<td>
<input
class="cic-mono"
[(ngModel)]="r.targetField"
(ngModelChange)="markDirty()"
list="cic-result-fields"
placeholder="score"
/>
</td>
<td>
<select [(ngModel)]="r.transform" (ngModelChange)="markDirty()">
@for (t of transformList; track t.key) {
<option [value]="t.key">{{ t.label }}</option>
}
</select>
</td>
<td><input [(ngModel)]="r.defaultValue" (ngModelChange)="markDirty()" /></td>
<td style="white-space: nowrap">
<button class="cic-btn sm" (click)="move('response', i, -1)" [disabled]="i === 0">↑</button>
<button
class="cic-btn sm"
(click)="move('response', i, 1)"
[disabled]="i === responseRows.length - 1"
>
↓
</button>
<button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<datalist id="cic-result-fields">
@for (f of resultFieldList; track f.key) {
<option [value]="f.key">{{ f.label }}</option>
}
</datalist>
<div class="cic-actions" style="margin-top: 12px">
<button class="cic-btn" (click)="addRow('response')">+ Add response field</button>
<span class="muted" style="font-size: 12px"
>Standard result fields: {{ resultFieldList.length }}; any other simple name is kept as an extra
field.</span
>A mandatory value that is empty stops the fetch before the bureau is called.</span
>
</div>
</div>
@@ -287,7 +305,7 @@
class="cic-mono"
[(ngModel)]="endpoint.path"
(ngModelChange)="markDirty()"
placeholder="/consumer/enquiry"
placeholder="/monitoring/enquiry-alerts"
/>
</div>
<div class="cic-field">
@@ -309,7 +327,7 @@
></textarea>
<span class="hint"
>Placeholders like <code>{{ '{{' }}bureau.memberCode{{ '}}' }}</code> or
<code>{{ '{{' }}enquiry.reference{{ '}}' }}</code> are filled in. Authentication headers are added
<code>{{ '{{' }}fetch.reference{{ '}}' }}</code> are filled in. Authentication headers are added
automatically.</span
>
@if (jsonErrors.headers) {
@@ -333,46 +351,100 @@
</div>
}
<!-- TEST RESPONSE -->
@if (tab === 'test') {
<div class="cic-card">
<h2>
Test response
<span class="cic-card-sub"
>reads a bureau answer through the saved response mapping and matches customers — nothing is saved</span
>
</h2>
<div class="cic-field">
<label
>Bureau response (JSON) — paste a sample from the bureau's specification, or leave empty for a simulator
sample</label
>
<textarea class="cic-mono" rows="12" [(ngModel)]="sampleText"></textarea>
</div>
<div class="cic-actions" style="margin-top: 12px">
<button class="cic-btn primary" (click)="runTest()" [disabled]="testing">
{{ testing ? 'Reading…' : 'Read through mapping' }}
</button>
<button class="cic-btn" (click)="sampleText = ''; testResult = null">Clear</button>
</div>
</div>
@if (testResult) {
@if (testResult.errorCode) {
<div class="cic-alert error">Bureau error {{ testResult.errorCode }}: {{ testResult.errorMessage }}</div>
}
@if (!testResult.alertListFound) {
<div class="cic-alert warn">
No alert list was found at the path mapped to "alerts". Check that row of the response mapping.
</div>
}
<div class="cic-card">
<h2>{{ testResult.count }} alert(s) read</h2>
@if (testResult.items?.length) {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Alert id</th>
<th>Customer (bureau)</th>
<th>PAN</th>
<th>Date</th>
<th>Loan type</th>
<th class="num">Amount</th>
<th>Match</th>
</tr>
</thead>
<tbody>
@for (it of testResult.items; track $index) {
<tr>
<td class="cic-mono">{{ it.alertRef }}</td>
<td>{{ it.customerName }}</td>
<td class="cic-mono">{{ it.pan || it.aadhaar || it.customerRef }}</td>
<td>{{ it.enquiryDate }}</td>
<td>{{ it.loanType }}</td>
<td class="num">{{ it.amount | number: '1.0-0' }}</td>
<td>
@if (it.ownBank) {
<span class="cic-badge">Own bank — skipped</span>
} @else if (it.clientId) {
<span class="cic-badge good">{{ matchLabels[it.matchType] }}</span> {{ it.clientName }}
} @else {
<span class="cic-badge bad">Not matched</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
<!-- PREVIEW -->
@if (tab === 'preview') {
<div class="cic-card">
<h2>
Preview
<span class="cic-card-sub"
>builds the request from the saved mapping for a real customer — nothing is sent</span
>
</h2>
<h2>Preview <span class="cic-card-sub">builds the request from the saved mapping — nothing is sent</span></h2>
<div class="cic-grid cols-4" style="align-items: end">
<div class="cic-field" style="grid-column: span 2">
<label>Customer</label>
<div style="display: flex; gap: 8px">
<input
[value]="previewClient ? previewClient.name + ' (#' + previewClient.id + ')' : ''"
placeholder="Search a customer"
readonly
/>
<button class="cic-btn sm" (click)="pickPreviewClient()">Search</button>
</div>
<div class="cic-field">
<label>Alerts from</label>
<input type="date" [(ngModel)]="previewFrom" />
</div>
<div class="cic-field">
<label>Amount (₹)</label>
<input type="number" [(ngModel)]="previewAmount" />
<label>Alerts to</label>
<input type="date" [(ngModel)]="previewTo" />
</div>
<div class="cic-field">
<label>Purpose</label>
<select [(ngModel)]="previewPurpose">
@for (p of purposes; track p.code) {
<option [value]="p.code">{{ p.label }}</option>
}
</select>
<div class="muted" style="font-size: 12px; grid-column: span 2">
Empty = the bureau's look-back window up to today.
</div>
</div>
<button
class="cic-btn primary"
style="margin-top: 12px"
(click)="runPreview()"
[disabled]="previewing || !previewClient"
>
<button class="cic-btn primary" style="margin-top: 12px" (click)="runPreview()" [disabled]="previewing">
{{ previewing ? 'Building…' : 'Build preview' }}
</button>
</div>
@@ -380,20 +452,18 @@
@if (previewResult) {
@if (previewResult.missingMandatory?.length) {
<div class="cic-alert error">
This customer would be refused — missing mandatory fields:
The fetch would be refused — mandatory values are empty:
<ul>
@for (m of previewResult.missingMandatory; track m) {
<li>{{ m }}</li>
}
</ul>
</div>
} @else {
<div class="cic-alert success">All mandatory fields are present for this customer.</div>
}
<div class="cic-split">
<div class="cic-card">
<h2>Request body <span class="cic-card-sub">sensitive values masked</span></h2>
<pre class="cic-code">{{ prettyBody(previewResult.body) }}</pre>
<h2>Request body</h2>
<pre class="cic-code">{{ prettyJson(previewResult.body) }}</pre>
</div>
<div class="cic-card">
<h2>Call</h2>
@@ -402,6 +472,8 @@
<dd>{{ previewResult.method }}</dd>
<dt>URL</dt>
<dd class="cic-mono">{{ previewResult.url }}</dd>
<dt>Window</dt>
<dd>{{ previewResult.fromDate }} → {{ previewResult.toDate }}</dd>
<dt>Auth</dt>
<dd>{{ previewResult.authentication }}</dd>
@for (h of headerEntries(previewResult.headers); track h.k) {
@@ -9,20 +9,16 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ActivatedRoute, 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 { CicBureau, CicCatalog, CicEndpoint, CicMapping, CicService, cicError } from '../cic.service';
import { CIC_MATCH_LABELS, CicBureau, CicCatalog, CicEndpoint, CicMapping, CicService, cicError } from '../cic.service';
/**
* The per-bureau API mapping, maintained from the UI: endpoint (method, path, headers, base body), REQUEST rows that
* put a CBS field into the bureau's request at a JSON path, and RESPONSE rows that read the bureau's answer into the
* CBS result (score, accounts, overdue ...). A dry-run preview shows the exact request for a real customer.
* The per-bureau API mapping of the enquiry-alert call, maintained from the UI: endpoint (method, path, headers, base
* body), REQUEST rows that put the fetch window and the bank's identity into the bureau's request, and RESPONSE rows
* that read the bureau's alert list and each alert's fields (customer, lender, loan type, amount ...). A dry-run
* preview shows the request; Test Response reads a sample answer through the mapping without saving anything.
*/
@Component({
selector: 'mifosx-cic-api-mapping',
@@ -31,7 +27,6 @@ import { CicBureau, CicCatalog, CicEndpoint, CicMapping, CicService, cicError }
CommonModule,
FormsModule,
RouterModule,
MatDialogModule,
HasPermissionDirective
],
templateUrl: './cic-api-mapping.component.html',
@@ -39,18 +34,17 @@ import { CicBureau, CicCatalog, CicEndpoint, CicMapping, CicService, cicError }
})
export class CicApiMappingComponent implements OnInit {
private cic = inject(CicService);
private dialog = inject(MatDialog);
private route = inject(ActivatedRoute);
catalog: CicCatalog | null = null;
bureaus: CicBureau[] = [];
bureauId: number | null = null;
endpointType = 'CONSUMER_ENQUIRY';
endpointType = 'ENQUIRY_ALERT';
endpoint: CicEndpoint | null = null;
requestRows: CicMapping[] = [];
responseRows: CicMapping[] = [];
tab: 'request' | 'response' | 'endpoint' | 'preview' = 'request';
tab: 'request' | 'response' | 'endpoint' | 'preview' | 'test' = 'response';
loading = false;
saving = false;
@@ -60,16 +54,21 @@ export class CicApiMappingComponent implements OnInit {
jsonErrors: { headers?: string; body?: string } = {};
// preview
previewClient: { id: number; name: string } | null = null;
previewAmount = 100000;
previewPurpose = 'NEW_LOAN';
previewFrom = '';
previewTo = '';
previewResult: any = null;
previewing = false;
// test response
sampleText = '';
testResult: any = null;
testing = false;
cbsFieldList: { key: string; label: string }[] = [];
resultFieldList: { key: string; label: string }[] = [];
transformList: { key: string; label: string }[] = [];
purposes: { code: string; label: string }[] = [];
readonly matchLabels = CIC_MATCH_LABELS;
ngOnInit(): void {
this.cic.catalog().subscribe({
@@ -93,12 +92,6 @@ export class CicApiMappingComponent implements OnInit {
label
]) => ({ key, label })
);
this.purposes = Object.entries(c.purposes).map(
([
code,
label
]) => ({ code, label })
);
},
error: (e) => (this.error = cicError(e))
});
@@ -117,6 +110,10 @@ export class CicApiMappingComponent implements OnInit {
return this.bureaus.find((b) => b.id === this.bureauId);
}
isRoot(target: string): boolean {
return (this.catalog?.rootFields ?? []).includes(target);
}
load(): void {
if (!this.bureauId) return;
if (this.dirty && !confirm('Discard unsaved mapping changes?')) return;
@@ -124,14 +121,11 @@ export class CicApiMappingComponent implements OnInit {
this.error = null;
this.info = null;
this.previewResult = null;
this.testResult = null;
this.cic.endpoint(this.bureauId, this.endpointType).subscribe({
next: (e) => {
this.endpoint = { ...e, headersJson: this.prettyJson(e.headersJson), baseBody: this.prettyJson(e.baseBody) };
this.requestRows = e.requestMappings.map((m) => ({ ...m }));
this.responseRows = e.responseMappings.map((m) => ({ ...m }));
this.apply(e);
this.loading = false;
this.dirty = false;
this.jsonErrors = {};
},
error: (err) => {
this.loading = false;
@@ -140,6 +134,14 @@ export class CicApiMappingComponent implements OnInit {
});
}
private apply(e: CicEndpoint): void {
this.endpoint = { ...e, headersJson: this.prettyJson(e.headersJson), baseBody: this.prettyJson(e.baseBody) };
this.requestRows = e.requestMappings.map((m) => ({ ...m }));
this.responseRows = e.responseMappings.map((m) => ({ ...m }));
this.dirty = false;
this.jsonErrors = {};
}
markDirty(): void {
this.dirty = true;
}
@@ -235,11 +237,8 @@ export class CicApiMappingComponent implements OnInit {
.subscribe({
next: (e) => {
this.saving = false;
this.dirty = false;
this.endpoint = { ...e, headersJson: this.prettyJson(e.headersJson), baseBody: this.prettyJson(e.baseBody) };
this.requestRows = e.requestMappings.map((m) => ({ ...m }));
this.responseRows = e.responseMappings.map((m) => ({ ...m }));
this.info = `${this.bureau?.code} ${this.endpointType} mapping saved (${this.requestRows.length} request, ${this.responseRows.length} response fields).`;
this.apply(e);
this.info = `${this.bureau?.code} enquiry-alert mapping saved (${this.requestRows.length} request, ${this.responseRows.length} response fields).`;
},
error: (err) => {
this.saving = false;
@@ -248,30 +247,20 @@ export class CicApiMappingComponent implements OnInit {
});
}
pickPreviewClient(): void {
this.dialog
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((r: CustomerSearchResult | undefined) => {
if (r?.clientId) this.previewClient = { id: r.clientId, name: r.displayName };
});
private requireSaved(): boolean {
if (this.dirty) {
this.error = 'Save the mapping first — preview and test use the saved mapping.';
return false;
}
return true;
}
runPreview(): void {
if (!this.bureauId || !this.previewClient) return;
if (this.dirty) {
this.error = 'Save the mapping first — the preview uses the saved mapping.';
return;
}
if (!this.bureauId || !this.requireSaved()) return;
this.previewing = true;
this.error = null;
this.cic
.preview(this.bureauId, {
clientId: this.previewClient.id,
enquiryType: this.endpointType,
purpose: this.previewPurpose,
loanAmount: this.previewAmount
})
.preview(this.bureauId, { fromDate: this.previewFrom || undefined, toDate: this.previewTo || undefined })
.subscribe({
next: (r) => {
this.previewing = false;
@@ -284,6 +273,23 @@ export class CicApiMappingComponent implements OnInit {
});
}
runTest(): void {
if (!this.bureauId || !this.requireSaved()) return;
this.testing = true;
this.error = null;
this.cic.parseTest(this.bureauId, this.sampleText.trim() || null).subscribe({
next: (r) => {
this.testing = false;
this.testResult = r;
if (r.sampleResponse && !this.sampleText.trim()) this.sampleText = this.prettyJson(r.sampleResponse);
},
error: (e) => {
this.testing = false;
this.error = cicError(e, 'Test failed.');
}
});
}
prettyJson(text?: string | null): string {
if (!text) return '{}';
try {
@@ -293,10 +299,6 @@ export class CicApiMappingComponent implements OnInit {
}
}
prettyBody(text?: string | null): string {
return this.prettyJson(text);
}
headerEntries(h: Record<string, string> | null | undefined): { k: string; v: string }[] {
return Object.entries(h ?? {}).map(
([
@@ -11,11 +11,12 @@
<div>
<h1>CIC Whitelist &amp; Configuration</h1>
<p>
Choose which Credit Information Companies this bank may enquire, and how each is reached. Only whitelisted
bureaus appear on the enquiry screen.
Choose which Credit Information Companies this bank receives enquiry alerts from, and how each is reached. Only
whitelisted bureaus are fetched.
</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/credit-bureau/dashboard">← Dashboard</a>
<a class="cic-btn" routerLink="/credit-bureau/api-mapping">API Mapping</a>
</div>
</div>
@@ -76,10 +77,26 @@
</dd>
<dt>Endpoints</dt>
<dd>{{ b.activeEndpoints }} active</dd>
<dt>Report reuse</dt>
<dd>{{ b.reportValidityDays ? 'within ' + b.reportValidityDays + ' days' : 'always pull fresh' }}</dd>
<dt>Charge / enquiry</dt>
<dd>₹{{ b.enquiryCharge | number: '1.0-2' }}</dd>
<dt>Alert fetch</dt>
<dd>
{{ b.alertAutoFetch ? 'Automatic, every ' + b.alertFetchIntervalMin + ' min' : 'Manual only' }}
<span class="muted">· looks back {{ b.alertLookbackDays }} days</span>
</dd>
<dt>Last fetch</dt>
<dd>
@if (b.lastFetchOn) {
<span
class="cic-badge"
[class.good]="b.lastFetchStatus === 'SUCCESS'"
[class.bad]="b.lastFetchStatus === 'FAILED'"
>{{ b.lastFetchStatus }}</span
>
<span class="muted" style="font-size: 12px"> {{ b.lastFetchOn | slice: 0 : 16 }}</span>
<div class="muted" style="font-size: 12px">{{ b.lastFetchMessage }}</div>
} @else {
<span class="muted">never</span>
}
</dd>
<dt>Last test</dt>
<dd>
@if (b.lastTestStatus) {
@@ -95,8 +112,13 @@
<span class="muted">never</span>
}
</dd>
<dt>Enquiries (30d)</dt>
<dd>{{ b.enquiriesLast30Days }}</dd>
<dt>Alerts (30d)</dt>
<dd>
{{ b.alertsLast30Days }}
@if (b.newAlerts) {
<span class="cic-badge bad">{{ b.newAlerts }} new</span>
}
</dd>
</dl>
<div class="cic-actions" style="margin-top: 14px">
@@ -109,6 +131,16 @@
>
{{ testing === b.id ? 'Testing…' : 'Test Connection' }}
</button>
@if (b.whitelisted) {
<button
class="cic-btn"
(click)="fetchNow(b)"
[disabled]="fetching === b.id"
*mifosxHasPermission="'FETCH_CICALERT'"
>
{{ fetching === b.id ? 'Fetching…' : 'Fetch alerts now' }}
</button>
}
<a class="cic-btn" routerLink="/credit-bureau/api-mapping" [queryParams]="{ bureauId: b.id }">API Mapping</a>
</div>
</div>
@@ -116,10 +148,11 @@
</div>
<div class="cic-alert info" style="margin-top: 16px">
<b>Going live with a bureau:</b> 1) get the bank's member code, API URL and credentials from the bureau; 2)
Configure them here (environment UAT first); 3) adapt the request / response fields under API Mapping to the
bureau's specification and use Preview; 4) Test Connection and run a test enquiry; 5) switch to PRODUCTION and
whitelist it.
<b>Going live with a bureau:</b> 1) subscribe to the bureau's enquiry-alert (portfolio monitoring) service and get
the bank's member code, API URL and credentials; 2) Configure them here (environment UAT first); 3) adapt the
request / response fields under API Mapping to the bureau's specification and check them with Preview and Test
Response; 4) Test Connection and Fetch alerts now; 5) switch to PRODUCTION and whitelist it. Alerts then arrive
automatically.
</div>
@if (editing) {
@@ -206,20 +239,32 @@
<input type="number" min="1000" max="300000" [(ngModel)]="form.timeoutMs" />
</div>
<div class="cic-field">
<label>Reuse a report for (days)</label>
<input type="number" min="0" max="365" [(ngModel)]="form.reportValidityDays" />
<span class="hint">0 = always pull a fresh report. Reuse avoids a second hard enquiry and its charge.</span>
<label>Fetch alerts automatically every (minutes)</label>
<input
type="number"
min="15"
max="1440"
[(ngModel)]="form.alertFetchIntervalMin"
[disabled]="!form.alertAutoFetch"
/>
<label style="display: flex; gap: 6px; align-items: center; font-weight: 500; font-size: 12px">
<input type="checkbox" [(ngModel)]="form.alertAutoFetch" style="width: auto" /> Fetch automatically
(15–1440 min)
</label>
</div>
<div class="cic-field">
<label>Charge per enquiry (₹)</label>
<input type="number" min="0" [(ngModel)]="form.enquiryCharge" />
<label>Look back (days)</label>
<input type="number" min="1" max="90" [(ngModel)]="form.alertLookbackDays" />
<span class="hint"
>Each fetch asks for alerts of this many past days; ones already received are skipped.</span
>
</div>
<div class="cic-field" style="grid-column: 1 / -1">
<label>Description</label>
<input [(ngModel)]="form.description" />
</div>
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px">
<input type="checkbox" [(ngModel)]="form.isDefault" /> Default bureau on the enquiry screen
<input type="checkbox" [(ngModel)]="form.isDefault" /> Default bureau (listed first)
</label>
</div>
<div class="foot">
@@ -15,8 +15,8 @@ import { HasPermissionDirective } from 'app/directives/has-permission/has-permis
import { CicBureau, CicCatalog, CicService, cicError } from '../cic.service';
/**
* Which CICs the bank may enquire (whitelist), and how each is reached: environment, URL, member code,
* authentication and credentials (stored encrypted, never shown back), timeouts, report reuse window and charge.
* Which CICs the bank receives enquiry alerts from (whitelist), and how each is reached: environment, URL, member
* code, authentication and credentials (stored encrypted, never shown back), timeout, and the alert fetch schedule.
*/
@Component({
selector: 'mifosx-cic-bureau-config',
@@ -44,6 +44,7 @@ export class CicBureauConfigComponent implements OnInit {
saving = false;
testing: number | null = null;
toggling: number | null = null;
fetching: number | null = null;
readonly authLabels: Record<string, string> = {
NONE: 'None (IP whitelisting / mutual TLS)',
@@ -83,7 +84,7 @@ export class CicBureauConfigComponent implements OnInit {
next: (u) => {
this.toggling = null;
this.replace(u);
this.info = `${u.code} is now ${u.whitelisted ? 'whitelisted — enquiries can be run against it' : 'not whitelisted'}.`;
this.info = `${u.code} is now ${u.whitelisted ? 'whitelisted — its enquiry alerts will be fetched' : 'not whitelisted'}.`;
},
error: (e) => {
this.toggling = null;
@@ -110,8 +111,9 @@ export class CicBureauConfigComponent implements OnInit {
tokenUrl: b.tokenUrl ?? '',
clientId: b.clientId ?? '',
timeoutMs: b.timeoutMs,
reportValidityDays: b.reportValidityDays,
enquiryCharge: b.enquiryCharge,
alertAutoFetch: b.alertAutoFetch,
alertFetchIntervalMin: b.alertFetchIntervalMin,
alertLookbackDays: b.alertLookbackDays,
isDefault: b.isDefault
};
}
@@ -154,6 +156,24 @@ export class CicBureauConfigComponent implements OnInit {
});
}
fetchNow(b: CicBureau): void {
this.fetching = b.id;
this.error = null;
this.info = null;
this.cic.fetch(b.id).subscribe({
next: (r) => {
this.fetching = null;
if (r.status === 'SUCCESS') this.info = `${b.code}: ${r.message}`;
else this.error = `${b.code}: ${r.message}`;
this.load();
},
error: (e) => {
this.fetching = null;
this.error = cicError(e, 'Fetch failed.');
}
});
}
needsSecret(auth: string): boolean {
return auth !== 'NONE';
}
@@ -1,253 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>Consent Register</h1>
<p>Borrowers' consents to pull their credit report. No enquiry runs without an active, unexpired consent.</p>
</div>
<div class="cic-actions">
<button class="cic-btn primary" (click)="startCapture()" *mifosxHasPermission="'CREATE_CICCONSENT'">
Record Consent
</button>
</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
@if (capturing) {
<div class="cic-card">
<h2>Record a consent <button class="cic-btn sm" (click)="capturing = false">Close</button></h2>
<div class="cic-grid cols-3">
<div class="cic-field">
<label>Customer *</label>
<div style="display: flex; gap: 8px; align-items: center">
<input
[value]="formClient ? formClient.name + ' (#' + formClient.id + ')' : ''"
placeholder="Search a customer"
readonly
/>
<button class="cic-btn sm" (click)="pickClient()">Search</button>
</div>
</div>
<div class="cic-field">
<label>Applies to</label>
<select [(ngModel)]="form.bureauId">
<option value="">Any credit bureau</option>
@for (b of bureaus; track b.id) {
<option [value]="b.id">Only {{ b.code }}</option>
}
</select>
</div>
<div class="cic-field">
<label>Purpose</label>
<select [(ngModel)]="form.purpose">
@for (p of purposes; track p.code) {
<option [value]="p.code">{{ p.label }}</option>
}
</select>
</div>
<div class="cic-field">
<label>Consent mode</label>
<select [(ngModel)]="form.consentMode">
<option value="SIGNED_FORM">Signed consent form</option>
<option value="OTP">OTP verified</option>
<option value="DIGITAL_ACCEPTANCE">Digital acceptance</option>
</select>
</div>
@if (form.consentMode === 'OTP') {
<div class="cic-field">
<label>OTP / transaction reference *</label>
<input [(ngModel)]="form.otpReference" />
</div>
} @else {
<div class="cic-field">
<label>Form / document reference {{ form.consentMode === 'SIGNED_FORM' ? '*' : '' }}</label>
<input [(ngModel)]="form.documentRef" />
</div>
}
<div class="cic-field">
<label>Valid for (days)</label>
<input type="number" min="1" max="365" [(ngModel)]="form.validityDays" />
</div>
<div class="cic-field">
<label>Loan application no. (optional)</label>
<input [(ngModel)]="form.applicationNo" />
</div>
</div>
<div class="cic-field" style="margin-top: 12px">
<label>Consent declaration</label>
<textarea rows="4" [(ngModel)]="form.consentText"></textarea>
</div>
<label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; margin: 12px 0">
<input type="checkbox" [(ngModel)]="form.customerDeclarationAccepted" style="margin-top: 3px" />
<span>The customer has read the declaration above and given consent in the selected mode.</span>
</label>
<button
class="cic-btn primary"
(click)="save()"
[disabled]="saving || !formClient || !form.customerDeclarationAccepted"
>
{{ saving ? 'Saving…' : 'Record Consent' }}
</button>
</div>
}
<div class="cic-card">
<div class="cic-grid cols-3" style="align-items: end; margin-bottom: 14px">
<div class="cic-field">
<label>Search</label>
<input [(ngModel)]="filter.search" placeholder="Customer, consent ref or application" (keyup.enter)="apply()" />
</div>
<div class="cic-field">
<label>Status</label>
<select [(ngModel)]="filter.status" (ngModelChange)="apply()">
<option value="">All</option>
<option value="ACTIVE">Active</option>
<option value="EXPIRED">Expired</option>
<option value="REVOKED">Revoked</option>
</select>
</div>
<div class="cic-actions">
<button class="cic-btn" (click)="apply()">Apply</button>
</div>
</div>
@if (loading) {
<div class="cic-empty">Loading…</div>
} @else if (!items.length) {
<div class="cic-empty">No consent recorded yet.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Consent ref</th>
<th>Customer</th>
<th>Bureau</th>
<th>Purpose</th>
<th>Mode</th>
<th>Valid</th>
<th>Status</th>
<th>Captured by</th>
<th></th>
</tr>
</thead>
<tbody>
@for (c of items; track c.id) {
<tr>
<td class="cic-mono">{{ c.consentRef }}</td>
<td>
{{ c.clientName }} <span class="muted">#{{ c.clientId }}</span>
@if (c.applicationNo) {
<div class="muted" style="font-size: 11px">App {{ c.applicationNo }}</div>
}
</td>
<td>{{ c.bureauCode }}</td>
<td>{{ c.purpose }}</td>
<td>{{ c.consentMode }}</td>
<td class="muted">{{ c.validFrom }} → {{ c.validUntil }}</td>
<td>
<span class="cic-badge" [class]="'cic-badge ' + tone(c.status)">{{ c.status }}</span>
</td>
<td>
{{ c.capturedByName }}
<div class="muted" style="font-size: 11px">{{ c.capturedOn | slice: 0 : 16 }}</div>
</td>
<td style="white-space: nowrap">
<button class="cic-btn sm" (click)="viewing = c">View</button>
@if (c.status === 'ACTIVE') {
<button
class="cic-btn sm danger"
(click)="revoking = c; revokeReason = ''"
*mifosxHasPermission="'REVOKE_CICCONSENT'"
>
Revoke
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="cic-pager">
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
<div 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>
</div>
</div>
}
</div>
@if (revoking) {
<div class="cic-modal-backdrop" (click)="revoking = null">
<div class="cic-modal" (click)="$event.stopPropagation()">
<h3>Revoke consent {{ revoking.consentRef }}</h3>
<p class="muted" style="font-size: 13px">
{{ revoking.clientName }} — no further enquiry can run under this consent. Enquiries already made are not
affected.
</p>
<div class="cic-field">
<label>Reason *</label>
<textarea
rows="3"
[(ngModel)]="revokeReason"
placeholder="e.g. customer withdrew consent in writing"
></textarea>
</div>
<div class="foot">
<button class="cic-btn" (click)="revoking = null">Cancel</button>
<button class="cic-btn danger" (click)="confirmRevoke()" [disabled]="!revokeReason.trim()">Revoke</button>
</div>
</div>
</div>
}
@if (viewing) {
<div class="cic-modal-backdrop" (click)="viewing = null">
<div class="cic-modal" (click)="$event.stopPropagation()">
<h3>Consent {{ viewing.consentRef }}</h3>
<dl class="cic-kv">
<dt>Customer</dt>
<dd>{{ viewing.clientName }} (#{{ viewing.clientId }})</dd>
<dt>Bureau</dt>
<dd>{{ viewing.bureauCode }}</dd>
<dt>Purpose / mode</dt>
<dd>{{ viewing.purpose }} · {{ viewing.consentMode }}</dd>
<dt>Document / OTP ref</dt>
<dd>{{ viewing.documentRef || viewing.otpReference || '—' }}</dd>
<dt>Valid</dt>
<dd>{{ viewing.validFrom }} → {{ viewing.validUntil }}</dd>
<dt>Status</dt>
<dd>{{ viewing.status }}{{ viewing.revokedReason ? ' — ' + viewing.revokedReason : '' }}</dd>
<dt>Captured</dt>
<dd>
{{ viewing.capturedOn | slice: 0 : 19 }} by {{ viewing.capturedByName }} ({{
viewing.ipAddress || 'IP n/a'
}})
</dd>
</dl>
<div class="cic-field" style="margin-top: 12px">
<label>Declaration</label>
<div style="font-size: 13px; background: #f8fafc; border-radius: 8px; padding: 10px">
{{ viewing.consentText }}
</div>
</div>
<div class="foot"><button class="cic-btn" (click)="viewing = null">Close</button></div>
</div>
</div>
}
</div>
@@ -1,187 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { CommonModule } from '@angular/common';
import { Component, OnInit, 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 { CicBureau, CicCatalog, CicConsent, CicService, cicError } from '../cic.service';
/** Register of borrower consents to a credit bureau pull: capture, view, revoke. */
@Component({
selector: 'mifosx-cic-consents',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatDialogModule,
HasPermissionDirective
],
templateUrl: './cic-consents.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicConsentsComponent implements OnInit {
private cic = inject(CicService);
private dialog = inject(MatDialog);
catalog: CicCatalog | null = null;
bureaus: CicBureau[] = [];
purposes: { code: string; label: string }[] = [];
items: CicConsent[] = [];
total = 0;
loading = false;
error: string | null = null;
info: string | null = null;
filter = { search: '', status: '' };
offset = 0;
readonly limit = 20;
// capture
capturing = false;
saving = false;
form = this.blankForm();
formClient: { id: number; name: string } | null = null;
// revoke / view
revoking: CicConsent | null = null;
revokeReason = '';
viewing: CicConsent | null = null;
ngOnInit(): void {
this.cic.catalog().subscribe({
next: (c) => {
this.catalog = c;
this.purposes = Object.entries(c.purposes).map(
([
code,
label
]) => ({ code, label })
);
this.form.consentText = c.defaultConsentText;
},
error: (e) => (this.error = cicError(e))
});
this.cic.bureaus().subscribe({ next: (b) => (this.bureaus = b), error: () => (this.bureaus = []) });
this.load();
}
private blankForm() {
return {
bureauId: '' as any,
applicationNo: '',
purpose: 'NEW_LOAN',
consentMode: 'SIGNED_FORM',
documentRef: '',
otpReference: '',
validityDays: 30,
consentText: this.catalog?.defaultConsentText ?? '',
customerDeclarationAccepted: false
};
}
load(): void {
this.loading = true;
this.error = null;
this.cic.consents({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe({
next: (p) => {
this.items = p.pageItems;
this.total = p.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = cicError(e, 'Could not load consents.');
this.loading = false;
}
});
}
apply(): void {
this.offset = 0;
this.load();
}
page(delta: number): void {
this.offset = Math.max(0, this.offset + delta * this.limit);
this.load();
}
tone(status: string): string {
return status === 'ACTIVE' ? 'good' : status === 'EXPIRED' ? 'warn' : 'bad';
}
startCapture(): void {
this.capturing = true;
this.form = this.blankForm();
this.formClient = null;
this.info = null;
}
pickClient(): void {
this.dialog
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((r: CustomerSearchResult | undefined) => {
if (r?.clientId) this.formClient = { id: r.clientId, name: r.displayName };
});
}
save(): void {
if (!this.formClient) return;
this.saving = true;
this.error = null;
this.cic
.createConsent({
clientId: this.formClient.id,
bureauId: this.form.bureauId ? Number(this.form.bureauId) : null,
applicationNo: this.form.applicationNo || null,
purpose: this.form.purpose,
consentMode: this.form.consentMode,
documentRef: this.form.documentRef || null,
otpReference: this.form.otpReference || null,
validityDays: this.form.validityDays,
consentText: this.form.consentText,
customerDeclarationAccepted: this.form.customerDeclarationAccepted
})
.subscribe({
next: (c) => {
this.saving = false;
this.capturing = false;
this.info = `Consent ${c.consentRef} recorded for ${c.clientName}, valid until ${c.validUntil}.`;
this.apply();
},
error: (e) => {
this.saving = false;
this.error = cicError(e, 'Could not record the consent.');
}
});
}
confirmRevoke(): void {
if (!this.revoking) return;
const target = this.revoking;
this.cic.revokeConsent(target.id, this.revokeReason).subscribe({
next: () => {
this.info = `Consent ${target.consentRef} revoked.`;
this.revoking = null;
this.revokeReason = '';
this.load();
},
error: (e) => (this.error = cicError(e, 'Could not revoke the consent.'))
});
}
}
@@ -9,161 +9,246 @@
<div class="cic-page">
<div class="cic-head">
<div>
<h1>Credit Bureau Dashboard</h1>
<h1>Credit Bureau — Customer Enquiry Alerts</h1>
<p>
Enquiries to the Credit Information Companies (CIBIL, Experian, Equifax, CRIF High Mark) over the last 30 days.
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">
<button class="cic-btn" (click)="load()" [disabled]="loading">Refresh</button>
<a class="cic-btn primary" routerLink="/credit-bureau/new-enquiry">New Enquiry</a>
<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. Whitelist a bureau from the AI / Intelligence Modules
menu → API Management → CIC Whitelist &amp; Configuration.
</div>
}
@if (loading && !data) {
<div class="cic-card cic-empty">Loading…</div>
}
@if (data) {
@if (whitelistedCount === 0) {
<div class="cic-alert warn">
No credit bureau is whitelisted yet, so no enquiry can be run.
<a routerLink="/credit-bureau/bureaus">Open CIC Whitelist &amp; Configuration</a> to enable one.
</div>
}
<div class="cic-grid cols-4" style="margin-bottom: 16px">
<div class="cic-stat">
<div class="k">Enquiries today</div>
<div class="v">{{ data.enquiriesToday }}</div>
<div class="s">{{ data.enquiriesLast30Days }} in the last 30 days</div>
<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">Success rate</div>
<div class="v">{{ successRate }}%</div>
<div class="s">{{ statusCount('FAILED') }} failed · {{ statusCount('NO_HIT') }} no-hit</div>
<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">Average score</div>
<div class="v" [class]="'tone-' + scoreBand(data.averageScoreLast30Days).tone">
{{ data.averageScoreLast30Days ? (data.averageScoreLast30Days | number: '1.0-0') : '—' }}
</div>
<div class="s">{{ scoreBand(data.averageScoreLast30Days).label }}</div>
<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">Active consents</div>
<div class="v">{{ data.activeConsents }}</div>
<div class="s">
Charges ₹{{ data.chargesLast30Days | number: '1.0-2' }} · {{ statusCount('REUSED') }} reports reused
</div>
<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>
<div class="cic-split">
<div class="cic-card">
<h2>
Bureaus <span class="cic-card-sub">{{ whitelistedCount }} of {{ data.bureaus.length }} whitelisted</span>
</h2>
<!-- 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>Whitelisted</th>
<th>Environment</th>
<th class="num">Enquiries (30d)</th>
<th class="num">Failed</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (b of data.bureaus; track b.id) {
<tr>
@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>{{ b.code }}</b>
@if (b.isDefault) {
<span class="cic-badge brand">Default</span>
}
<div class="muted" style="font-size: 12px">{{ b.name }}</div>
<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.good]="b.whitelisted">{{ b.whitelisted ? 'Yes' : 'No' }}</span>
<span class="cic-badge" [class]="'cic-badge ' + statusTone(a.status)">{{ a.status }}</span>
</td>
<td>
<span
class="cic-badge"
[class.warn]="b.environment === 'SIMULATOR'"
[class.ok]="b.environment === 'UAT'"
[class.good]="b.environment === 'PRODUCTION'"
>{{ b.environment }}</span
>
</td>
<td class="num">{{ b.enquiries }}</td>
<td class="num" [class.tone-bad]="b.failed > 0">{{ b.failed }}</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>Score spread <span class="cic-card-sub">successful enquiries, 30 days</span></h2>
@for (band of data.scoreBandsLast30Days; track band.label) {
<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>{{ band.label }}</span
><b>{{ band.count }}</b>
<span>{{ g.label }}</span>
<span class="muted">{{ g.count }} · ₹{{ g.amount | number: '1.0-0' }}</span>
</div>
<div class="cic-bar"><div [style.width.%]="(band.count / maxBand) * 100"></div></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>
<div class="cic-card">
<!-- Bureau fetch status -->
<div class="cic-card" style="margin-top: 16px">
<h2>
Recent enquiries
<a class="cic-btn sm" routerLink="/credit-bureau/enquiries">View all</a>
Alert feed from each CIC
<a class="cic-btn sm" routerLink="/credit-bureau/fetch-log">Fetch log</a>
</h2>
@if (!data.recent?.length) {
<div class="cic-empty">No enquiry yet.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Reference</th>
<th>Customer</th>
<th>Bureau</th>
<th>Status</th>
<th class="num">Score</th>
<th>By</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (e of data.recent; track e.id) {
<tr class="clickable" (click)="open(e.id)">
<td class="cic-mono">{{ e.enquiryRef }}</td>
<td>{{ e.clientName }}</td>
<td>{{ e.bureauCode }}</td>
<td>
<span class="cic-badge" [class]="'cic-badge ' + statusTone(e.status)">{{ e.status }}</span>
</td>
<td class="num">
<b [class]="'tone-' + scoreBand(e.score).tone">{{ e.score ?? '—' }}</b>
</td>
<td>{{ e.requestedByName }}</td>
<td class="muted">{{ e.requestedOn | slice: 0 : 16 }}</td>
</tr>
<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
}
</tbody>
</table>
</div>
}
</div>
@if (b.lastFetchMessage) {
<div class="s" style="font-size: 11px">{{ b.lastFetchMessage }}</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>
}
</div>
}
</div>
</div>
}
</div>
@if (openAlertId) {
<mifosx-cic-alert-detail
[alertId]="openAlertId"
(closed)="openAlertId = null"
(changed)="onChanged()"
></mifosx-cic-alert-detail>
}
@@ -8,30 +8,44 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { CicService, cicError, cicScoreBand } from '../cic.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 overview: volumes, outcomes, score spread and the state of each bureau. */
/**
* 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. The other pages (fetch log, CIC configuration, API
* mapping) are reached from the buttons at the top.
*/
@Component({
selector: 'mifosx-cic-dashboard',
standalone: true,
imports: [
CommonModule,
RouterModule
FormsModule,
RouterModule,
HasPermissionDirective,
CicAlertDetailComponent
],
templateUrl: './cic-dashboard.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicDashboardComponent implements OnInit {
private cic = inject(CicService);
private router = inject(Router);
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 scoreBand = cicScoreBand;
readonly statusTone = cicStatusTone;
ngOnInit(): void {
this.load();
@@ -40,7 +54,7 @@ export class CicDashboardComponent implements OnInit {
load(): void {
this.loading = true;
this.error = null;
this.cic.dashboard().subscribe({
this.cic.dashboard(this.days).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
@@ -52,32 +66,54 @@ export class CicDashboardComponent implements OnInit {
});
}
statusCount(status: string): number {
return Number(this.data?.byStatusLast30Days?.[status] ?? 0);
get whitelisted(): any[] {
return (this.data?.bureaus ?? []).filter((b: any) => b.whitelisted);
}
get successRate(): number {
const total = Number(this.data?.enquiriesLast30Days ?? 0);
if (!total) return 0;
return Math.round(((this.statusCount('SUCCESS') + this.statusCount('REUSED')) / total) * 100);
/** 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);
}
get maxBand(): number {
return Math.max(1, ...(this.data?.scoreBandsLast30Days ?? []).map((b: any) => Number(b.count)));
max(list: any[] | undefined, key: string): number {
return Math.max(1, ...(list ?? []).map((x) => Number(x[key] ?? 0)));
}
get whitelistedCount(): number {
return (this.data?.bureaus ?? []).filter((b: any) => b.whitelisted).length;
pct(value: any, list: any[] | undefined, key: string): number {
return Math.round((Number(value ?? 0) / this.max(list, key)) * 100);
}
statusTone(status: string): string {
return status === 'SUCCESS' ? 'good' : status === 'REUSED' ? 'ok' : status === 'NO_HIT' ? 'warn' : 'bad';
onChanged(): void {
this.load();
}
open(id: number): void {
this.router.navigate([
'/credit-bureau/enquiries',
id
]);
fetchTone(status?: string): string {
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : '';
}
}
@@ -1,96 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>Enquiry {{ enquiry?.enquiryRef ?? '' }}</h1>
<p>{{ enquiry?.clientName }} · {{ enquiry?.bureauName ?? enquiry?.bureauCode }}</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/credit-bureau/enquiries">Back to history</a>
<button class="cic-btn" (click)="print()" [disabled]="!enquiry">Print</button>
@if (enquiry) {
<a
class="cic-btn primary"
routerLink="/credit-bureau/new-enquiry"
[queryParams]="{
clientId: enquiry.clientId,
amount: enquiry.loanAmount,
applicationNo: enquiry.applicationNo || null
}"
>Enquire again</a
>
}
</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
@if (enquiry) {
<div class="cic-card">
<dl class="cic-kv">
<dt>Customer</dt>
<dd>
{{ enquiry.clientName }} <span class="muted">(client #{{ enquiry.clientId }})</span>
</dd>
<dt>Bureau / environment</dt>
<dd>{{ enquiry.bureauCode }} · {{ enquiry.environment }}</dd>
<dt>Type / purpose</dt>
<dd>{{ enquiry.enquiryType }} · {{ enquiry.purpose }}</dd>
<dt>Amount</dt>
<dd>₹{{ enquiry.loanAmount | number: '1.0-2' }}</dd>
<dt>Loan application</dt>
<dd>{{ enquiry.applicationNo || '—' }}</dd>
<dt>Consent</dt>
<dd class="cic-mono">{{ enquiry.consentRef || '—' }}</dd>
@if (enquiry.reusedFromRef) {
<dt>Reused report of</dt>
<dd class="cic-mono">{{ enquiry.reusedFromRef }}</dd>
}
<dt>Charge</dt>
<dd>₹{{ enquiry.chargeAmount ?? 0 | number: '1.0-2' }}</dd>
<dt>Requested</dt>
<dd>{{ enquiry.requestedOn | slice: 0 : 19 }} by {{ enquiry.requestedByName }}</dd>
<dt>Response time</dt>
<dd>
{{ enquiry.durationMs ?? 0 }} ms
@if (enquiry.httpStatus) {
· HTTP {{ enquiry.httpStatus }}
}
</dd>
</dl>
</div>
<div class="cic-tabs">
<button [class.active]="tab === 'report'" (click)="tab = 'report'">Report</button>
<button [class.active]="tab === 'request'" (click)="tab = 'request'">Request sent (masked)</button>
<button [class.active]="tab === 'response'" (click)="tab = 'response'">Raw bureau response</button>
</div>
@if (tab === 'report') {
<mifosx-cic-report-view [enquiry]="enquiry"></mifosx-cic-report-view>
}
@if (tab === 'request') {
<div class="cic-card">
<h2>
Request <span class="cic-card-sub">PAN, mobile and e-mail are masked; credentials are never stored</span>
</h2>
<pre class="cic-code">{{ pretty(enquiry.requestMasked) }}</pre>
</div>
}
@if (tab === 'response') {
<div class="cic-card">
<h2>Raw response <span class="cic-card-sub">as returned by the bureau</span></h2>
<pre class="cic-code">{{ pretty(enquiry.responseRaw) }}</pre>
</div>
}
}
</div>
@@ -1,56 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { CicEnquiry, CicService, cicError } from '../cic.service';
import { CicReportViewComponent } from '../cic-report-view/cic-report-view.component';
/** One enquiry in full: report, audit trail, the masked request sent and the bureau's raw response. */
@Component({
selector: 'mifosx-cic-enquiry-detail',
standalone: true,
imports: [
CommonModule,
RouterModule,
CicReportViewComponent
],
templateUrl: './cic-enquiry-detail.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicEnquiryDetailComponent implements OnInit {
private cic = inject(CicService);
private route = inject(ActivatedRoute);
enquiry: CicEnquiry | null = null;
error: string | null = null;
tab: 'report' | 'request' | 'response' = 'report';
ngOnInit(): void {
const id = Number(this.route.snapshot.paramMap.get('id'));
this.cic.enquiry(id).subscribe({
next: (e) => (this.enquiry = e),
error: (e) => (this.error = cicError(e, 'Could not load the enquiry.'))
});
}
pretty(text?: string | null): string {
if (!text) return '—';
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
print(): void {
window.print();
}
}
@@ -1,127 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>Enquiry History</h1>
<p>Every credit bureau enquiry made from this bank, with its outcome and score.</p>
</div>
<div class="cic-actions">
<button class="cic-btn" (click)="exportCsv()" [disabled]="!items.length">Export CSV</button>
<a class="cic-btn primary" routerLink="/credit-bureau/new-enquiry">New Enquiry</a>
</div>
</div>
<div class="cic-card">
<div class="cic-grid cols-4" style="align-items: end">
<div class="cic-field">
<label>Search</label>
<input [(ngModel)]="filter.search" placeholder="Customer, reference or application" (keyup.enter)="apply()" />
</div>
<div class="cic-field">
<label>Bureau</label>
<select [(ngModel)]="filter.bureauId">
<option value="">All</option>
@for (b of bureaus; track b.id) {
<option [value]="b.id">{{ b.code }}</option>
}
</select>
</div>
<div class="cic-field">
<label>Status</label>
<select [(ngModel)]="filter.status">
<option value="">All</option>
@for (s of statuses; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<div class="cic-grid cols-2" style="gap: 8px">
<div class="cic-field">
<label>From</label>
<input type="date" [(ngModel)]="filter.fromDate" />
</div>
<div class="cic-field">
<label>To</label>
<input type="date" [(ngModel)]="filter.toDate" />
</div>
</div>
</div>
<div class="cic-actions" style="margin-top: 12px">
<button class="cic-btn primary" (click)="apply()">Apply</button>
<button class="cic-btn" (click)="reset()">Reset</button>
</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
<div class="cic-card">
@if (loading) {
<div class="cic-empty">Loading…</div>
} @else if (!items.length) {
<div class="cic-empty">No enquiry matches these filters.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Reference</th>
<th>Customer</th>
<th>Bureau</th>
<th>Purpose</th>
<th class="num">Amount</th>
<th>Status</th>
<th class="num">Score</th>
<th>Requested by</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (e of items; track e.id) {
<tr class="clickable" (click)="open(e)">
<td class="cic-mono">
{{ e.enquiryRef }}
@if (e.environment === 'SIMULATOR') {
<span class="cic-badge warn" title="Answered by the simulator">SIM</span>
}
</td>
<td>
{{ e.clientName }}
@if (e.applicationNo) {
<div class="muted" style="font-size: 11px">App {{ e.applicationNo }}</div>
}
</td>
<td>{{ e.bureauCode }}</td>
<td>{{ e.purpose }}</td>
<td class="num">₹{{ e.loanAmount | number: '1.0-0' }}</td>
<td>
<span class="cic-badge" [class]="'cic-badge ' + statusTone(e.status)">{{ e.status }}</span>
</td>
<td class="num">
<b [class]="'tone-' + scoreBand(e.score).tone">{{ e.score ?? '—' }}</b>
</td>
<td>{{ e.requestedByName }}</td>
<td class="muted">{{ e.requestedOn | slice: 0 : 16 }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="cic-pager">
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
<div 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>
</div>
</div>
}
</div>
</div>
@@ -1,137 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { CicBureau, CicEnquiry, CicService, cicError, cicScoreBand } from '../cic.service';
/** Every bureau enquiry made, with filters, paging and CSV export. */
@Component({
selector: 'mifosx-cic-enquiry-history',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
],
templateUrl: './cic-enquiry-history.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicEnquiryHistoryComponent implements OnInit {
private cic = inject(CicService);
private router = inject(Router);
bureaus: CicBureau[] = [];
items: CicEnquiry[] = [];
total = 0;
loading = false;
error: string | null = null;
filter = { search: '', bureauId: '', status: '', fromDate: '', toDate: '' };
offset = 0;
readonly limit = 20;
readonly statuses = [
'SUCCESS',
'NO_HIT',
'FAILED',
'REUSED'
];
readonly scoreBand = cicScoreBand;
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.enquiries({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe({
next: (p) => {
this.items = p.pageItems;
this.total = p.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = cicError(e, 'Could not load enquiries.');
this.loading = false;
}
});
}
apply(): void {
this.offset = 0;
this.load();
}
reset(): void {
this.filter = { search: '', bureauId: '', status: '', fromDate: '', toDate: '' };
this.apply();
}
page(delta: number): void {
this.offset = Math.max(0, this.offset + delta * this.limit);
this.load();
}
open(e: CicEnquiry): void {
this.router.navigate([
'/credit-bureau/enquiries',
e.id
]);
}
statusTone(status: string): string {
return status === 'SUCCESS' ? 'good' : status === 'REUSED' ? 'ok' : status === 'NO_HIT' ? 'warn' : 'bad';
}
/** CSV of the current page, for audit / MIS. */
exportCsv(): void {
const head = [
'Reference',
'Customer',
'Client id',
'Bureau',
'Type',
'Purpose',
'Amount',
'Status',
'Score',
'Requested by',
'Requested on'
];
const rows = this.items.map((e) => [
e.enquiryRef,
e.clientName,
e.clientId,
e.bureauCode,
e.enquiryType,
e.purpose,
e.loanAmount,
e.status,
e.score ?? '',
e.requestedByName,
e.requestedOn
]);
const csv = [
head,
...rows
]
.map((r) => r.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url;
a.download = `cic-enquiries-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
@@ -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';
}
}
@@ -1,297 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cic-page">
<div class="cic-head">
<div>
<h1>New Credit Bureau Enquiry</h1>
<p>
Pull a customer's credit report from a whitelisted Credit Information Company, under their recorded consent.
</p>
</div>
<div class="cic-actions">
<a class="cic-btn" routerLink="/credit-bureau/enquiries">Enquiry History</a>
</div>
</div>
<div class="cic-steps">
<div class="step" [class.done]="step > 1" [class.current]="step === 1"><i>1</i>Customer</div>
<div class="step" [class.done]="step > 2" [class.current]="step === 2"><i>2</i>Consent</div>
<div class="step" [class.done]="!!result" [class.current]="step === 3 && !result"><i>3</i>Enquiry</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
<div class="cic-split">
<div>
<!-- 1. Customer -->
<div class="cic-card">
<h2>
1. Customer
@if (client) {
<button class="cic-btn sm" (click)="clearClient()">Change</button>
}
</h2>
@if (!client) {
<div class="cic-grid cols-2" style="align-items: end">
<div class="cic-field">
<label>Client id</label>
<input type="number" [(ngModel)]="clientIdInput" placeholder="e.g. 12" (keyup.enter)="loadClientById()" />
</div>
<div class="cic-actions">
<button class="cic-btn" (click)="loadClientById()" [disabled]="!clientIdInput">Load</button>
<button class="cic-btn primary" (click)="openSearch()">Search Customer</button>
</div>
</div>
} @else {
<div style="display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap">
<div>
<div style="font-size: 16px; font-weight: 700">{{ client.name }}</div>
<div class="muted" style="font-size: 12px">
Client #{{ client.id }}
@if (client.office) {
· {{ client.office }}
}
</div>
</div>
@if (loadingProfile) {
<span class="muted">Loading…</span>
}
</div>
@if (profile) {
@if (missingEssentials.length) {
<div class="cic-alert warn" style="margin-top: 12px">
Missing on the customer record: <b>{{ missingEssentials.join(', ') }}</b
>. Bureaus match a customer on these — update the customer's KYC before enquiring, or the enquiry will
be refused.
</div>
}
<details style="margin-top: 12px">
<summary class="muted" style="cursor: pointer; font-size: 12px">Data that will be sent (masked)</summary>
<dl class="cic-kv" style="margin-top: 10px">
@for (r of dataRows(); track r.label) {
<dt>{{ r.label }}</dt>
<dd>{{ r.value ?? '—' }}</dd>
}
</dl>
</details>
}
}
</div>
<!-- 2. Consent -->
@if (client && profile) {
<div class="cic-card">
<h2>2. Customer consent</h2>
@if (profile.validConsent) {
<div class="cic-alert success" style="margin-bottom: 8px">
Consent <b>{{ profile.validConsent.consentRef }}</b> ({{ profile.validConsent.consentMode }}) on record,
valid until <b>{{ profile.validConsent.validUntil }}</b
>.
</div>
@if (!showConsentForm) {
<button class="cic-btn sm" (click)="showConsentForm = true">Record a new consent</button>
}
} @else {
<div class="cic-alert warn">
No valid consent for this customer{{ selectedBureau ? ' and ' + selectedBureau.code : '' }}. RBI requires
the borrower's consent before a credit report is pulled — record it below.
</div>
}
@if (showConsentForm) {
<div class="cic-grid cols-2">
<div class="cic-field">
<label>Consent mode</label>
<select [(ngModel)]="consent.consentMode">
<option value="SIGNED_FORM">Signed consent form</option>
<option value="OTP">OTP verified</option>
<option value="DIGITAL_ACCEPTANCE">Digital acceptance</option>
</select>
</div>
@if (consent.consentMode === 'SIGNED_FORM') {
<div class="cic-field">
<label>Form / document reference *</label>
<input [(ngModel)]="consent.documentRef" placeholder="Form no. or uploaded document name" />
</div>
} @else if (consent.consentMode === 'OTP') {
<div class="cic-field">
<label>OTP / transaction reference *</label>
<input [(ngModel)]="consent.otpReference" placeholder="OTP log or transaction id" />
</div>
} @else {
<div class="cic-field">
<label>Reference (optional)</label>
<input [(ngModel)]="consent.documentRef" placeholder="Channel / session reference" />
</div>
}
<div class="cic-field">
<label>Valid for (days)</label>
<input type="number" min="1" max="365" [(ngModel)]="consent.validityDays" />
</div>
<div class="cic-field">
<label>Applies to</label>
<select [(ngModel)]="consent.bureauScope">
<option value="ANY">Any credit bureau</option>
<option value="THIS" [disabled]="!selectedBureau">
Only {{ selectedBureau?.code ?? 'the selected bureau' }}
</option>
</select>
</div>
</div>
<div class="cic-field" style="margin-top: 12px">
<label>Consent declaration</label>
<textarea [(ngModel)]="consent.consentText" rows="4"></textarea>
</div>
<label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; margin: 12px 0">
<input type="checkbox" [(ngModel)]="consent.customerDeclarationAccepted" style="margin-top: 3px" />
<span>The customer has read the declaration above and given consent in the selected mode.</span>
</label>
<div class="cic-actions">
<button
class="cic-btn primary"
(click)="saveConsent()"
[disabled]="savingConsent || !consent.customerDeclarationAccepted"
>
{{ savingConsent ? 'Saving…' : 'Record Consent' }}
</button>
@if (profile.validConsent) {
<button class="cic-btn" (click)="showConsentForm = false">Cancel</button>
}
</div>
}
</div>
}
<!-- 3. Enquiry -->
@if (client && profile) {
<div class="cic-card">
<h2>3. Enquiry</h2>
<div class="cic-grid cols-2">
<div class="cic-field">
<label>Credit bureau (whitelisted)</label>
<select [(ngModel)]="bureauId" (ngModelChange)="onBureauChange()">
@for (b of bureaus; track b.id) {
<option [ngValue]="b.id">
{{ b.code }} — {{ b.name }}{{ b.environment === 'SIMULATOR' ? ' (simulator)' : '' }}
</option>
}
</select>
@if (selectedBureau) {
<span class="hint">
{{ selectedBureau.environment }} · reuses a report up to {{ selectedBureau.reportValidityDays }} days
old · charge ₹{{ selectedBureau.enquiryCharge | number: '1.0-2' }}
</span>
}
</div>
<div class="cic-field">
<label>Enquiry type</label>
<select [(ngModel)]="enquiryType">
@for (t of catalog?.endpointTypes ?? []; track t) {
<option [value]="t">
{{ t === 'CONSUMER_ENQUIRY' ? 'Consumer (individual)' : 'Commercial (business)' }}
</option>
}
</select>
</div>
<div class="cic-field">
<label>Purpose</label>
<select [(ngModel)]="purpose">
@for (p of purposes; track p.code) {
<option [value]="p.code">{{ p.label }}</option>
}
</select>
</div>
<div class="cic-field">
<label>Loan / credit amount (₹) *</label>
<input type="number" min="1" [(ngModel)]="loanAmount" placeholder="e.g. 500000" />
</div>
<div class="cic-field">
<label>Loan application no. (optional)</label>
<input [(ngModel)]="applicationNo" placeholder="Links the enquiry to an application" />
</div>
<div class="cic-field" style="justify-content: flex-end">
<label style="display: flex; gap: 8px; align-items: center; font-weight: 500">
<input type="checkbox" [(ngModel)]="forceFresh" style="width: auto" />
Pull a fresh report (don't reuse a recent one)
</label>
</div>
</div>
<div class="cic-actions" style="margin-top: 14px">
<button
class="cic-btn primary"
(click)="runEnquiry()"
[disabled]="running || !bureauId || !loanAmount || !profile.validConsent"
>
{{ running ? 'Contacting bureau…' : 'Pull Credit Report' }}
</button>
@if (!profile.validConsent) {
<span class="muted" style="font-size: 12px">Record the customer's consent first.</span>
}
</div>
</div>
}
</div>
<!-- Result / recent -->
<div>
@if (result) {
<div class="cic-card" style="padding-bottom: 6px">
<h2>
Result
<a class="cic-btn sm" [routerLink]="['/credit-bureau/enquiries', result.id]">Full details</a>
</h2>
</div>
<mifosx-cic-report-view [enquiry]="result"></mifosx-cic-report-view>
} @else if (profile?.recentEnquiries?.length) {
<div class="cic-card">
<h2>Previous enquiries of this customer</h2>
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
<th>Reference</th>
<th>Bureau</th>
<th>Status</th>
<th class="num">Score</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (e of profile.recentEnquiries; track e.id) {
<tr class="clickable" [routerLink]="['/credit-bureau/enquiries', e.id]">
<td class="cic-mono">{{ e.enquiryRef }}</td>
<td>{{ e.bureauCode }}</td>
<td>
<span class="cic-badge">{{ e.status }}</span>
</td>
<td class="num">{{ e.score ?? '—' }}</td>
<td class="muted">{{ e.requestedOn | slice: 0 : 16 }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} @else {
<div class="cic-card cic-empty">
The credit report appears here once the enquiry is run.
<div style="margin-top: 8px; font-size: 12px">
Bureaus in <b>SIMULATOR</b> mode answer with a realistic test report — no real bureau is contacted and no
charge applies.
</div>
</div>
}
</div>
</div>
</div>
@@ -1,260 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ActivatedRoute, RouterModule } from '@angular/router';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { CicBureau, CicCatalog, CicEnquiry, CicService, cicError } from '../cic.service';
import { CicReportViewComponent } from '../cic-report-view/cic-report-view.component';
/**
* Pull a credit report: pick the customer, make sure their bureau consent is on record (capture it here if not),
* choose a whitelisted bureau, purpose and amount, and run the enquiry.
*/
@Component({
selector: 'mifosx-cic-new-enquiry',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatDialogModule,
CicReportViewComponent
],
templateUrl: './cic-new-enquiry.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicNewEnquiryComponent implements OnInit {
private cic = inject(CicService);
private dialog = inject(MatDialog);
private route = inject(ActivatedRoute);
catalog: CicCatalog | null = null;
bureaus: CicBureau[] = [];
purposes: { code: string; label: string }[] = [];
clientIdInput: number | null = null;
client: { id: number; name: string; mobile?: string | null; office?: string | null } | null = null;
profile: any = null;
loadingProfile = false;
bureauId: number | null = null;
enquiryType = 'CONSUMER_ENQUIRY';
purpose = 'NEW_LOAN';
loanAmount: number | null = null;
applicationNo = '';
forceFresh = false;
showConsentForm = false;
consent = {
consentMode: 'SIGNED_FORM',
documentRef: '',
otpReference: '',
validityDays: 30,
bureauScope: 'ANY',
customerDeclarationAccepted: false,
consentText: ''
};
savingConsent = false;
running = false;
result: CicEnquiry | null = null;
error: string | null = null;
info: string | null = null;
ngOnInit(): void {
this.cic.catalog().subscribe({
next: (c) => {
this.catalog = c;
this.purposes = Object.entries(c.purposes).map(
([
code,
label
]) => ({ code, label })
);
this.consent.consentText = c.defaultConsentText;
},
error: (e) => (this.error = cicError(e))
});
this.cic.bureaus(true).subscribe({
next: (b) => {
this.bureaus = b;
this.bureauId = (b.find((x) => x.isDefault) ?? b[0])?.id ?? null;
if (!b.length) {
this.error =
'No credit bureau is whitelisted for this bank yet. Ask an administrator to whitelist one under CIC Configuration.';
}
this.reloadProfile();
},
error: (e) => (this.error = cicError(e))
});
// Deep link from another screen (e.g. a loan application): /credit-bureau/new-enquiry?clientId=..&amount=..&applicationNo=..
const q = this.route.snapshot.queryParamMap;
if (q.get('applicationNo')) this.applicationNo = q.get('applicationNo')!;
if (q.get('amount')) this.loanAmount = Number(q.get('amount'));
if (q.get('clientId')) {
this.clientIdInput = Number(q.get('clientId'));
this.loadClientById();
}
}
get selectedBureau(): CicBureau | undefined {
return this.bureaus.find((b) => b.id === this.bureauId);
}
get step(): number {
if (!this.client) return 1;
if (!this.profile?.validConsent) return 2;
return 3;
}
/** Data the default mapping cannot work without; the server checks the real mapping on submit. */
get missingEssentials(): string[] {
const d = this.profile?.data ?? {};
const missing: string[] = [];
if (!d['client.pan']) missing.push('PAN');
if (!d['client.dob']) missing.push('Date of birth');
if (!d['client.firstName'] && !d['client.fullName']) missing.push('Name');
return missing;
}
openSearch(): void {
this.dialog
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((r: CustomerSearchResult | undefined) => {
if (!r?.clientId) return;
this.client = { id: r.clientId, name: r.displayName, mobile: r.mobileNo, office: r.officeName };
this.clientIdInput = r.clientId;
this.resetOutcome();
this.reloadProfile();
});
}
loadClientById(): void {
if (!this.clientIdInput) return;
this.client = { id: Number(this.clientIdInput), name: `Client #${this.clientIdInput}` };
this.resetOutcome();
this.reloadProfile();
}
reloadProfile(): void {
if (!this.client) return;
this.loadingProfile = true;
this.cic.clientProfile(this.client.id, this.bureauId).subscribe({
next: (p) => {
this.profile = p;
this.loadingProfile = false;
const name = p?.data?.['client.fullName'];
if (name && this.client) this.client.name = name;
if (this.client && p?.data?.['client.branch']) this.client.office = p.data['client.branch'];
this.showConsentForm = !p?.validConsent;
},
error: (e) => {
this.error = cicError(e, 'Could not load the customer.');
this.profile = null;
this.client = null;
this.loadingProfile = false;
}
});
}
onBureauChange(): void {
this.resetOutcome();
this.reloadProfile();
}
saveConsent(): void {
if (!this.client) return;
this.error = null;
this.savingConsent = true;
this.cic
.createConsent({
clientId: this.client.id,
bureauId: this.consent.bureauScope === 'THIS' ? this.bureauId : null,
applicationNo: this.applicationNo || null,
purpose: this.purpose,
consentMode: this.consent.consentMode,
documentRef: this.consent.documentRef || null,
otpReference: this.consent.otpReference || null,
validityDays: this.consent.validityDays,
consentText: this.consent.consentText,
customerDeclarationAccepted: this.consent.customerDeclarationAccepted
})
.subscribe({
next: (c) => {
this.savingConsent = false;
this.info = `Consent ${c.consentRef} recorded, valid until ${c.validUntil}.`;
this.consent.customerDeclarationAccepted = false;
this.reloadProfile();
},
error: (e) => {
this.savingConsent = false;
this.error = cicError(e, 'Could not record the consent.');
}
});
}
runEnquiry(): void {
if (!this.client || !this.bureauId) return;
this.error = null;
this.info = null;
this.result = null;
this.running = true;
this.cic
.enquire({
clientId: this.client.id,
bureauId: this.bureauId,
enquiryType: this.enquiryType,
purpose: this.purpose,
loanAmount: this.loanAmount,
applicationNo: this.applicationNo || null,
consentId: this.profile?.validConsent?.id ?? null,
forceFresh: this.forceFresh
})
.subscribe({
next: (r) => {
this.running = false;
this.result = r;
this.reloadProfile();
},
error: (e) => {
this.running = false;
this.error = cicError(e, 'The enquiry could not be run.');
}
});
}
clearClient(): void {
this.client = null;
this.profile = null;
this.clientIdInput = null;
this.resetOutcome();
}
private resetOutcome(): void {
this.result = null;
this.error = null;
this.info = null;
}
dataRows(): { label: string; value: any }[] {
const d = this.profile?.data ?? {};
const fields = this.catalog?.cbsFields ?? {};
return Object.keys(d)
.filter((k) => !['client.id'].includes(k))
.map((k) => ({ label: fields[k] ?? k, value: d[k] }));
}
}
@@ -1,164 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (enquiry.status === 'FAILED') {
<div class="cic-alert error">
<b>Enquiry failed.</b> {{ enquiry.errorMessage }}
@if (enquiry.httpStatus) {
<div class="muted">HTTP status {{ enquiry.httpStatus }}</div>
}
</div>
}
@if (enquiry.status === 'NO_HIT') {
<div class="cic-alert warn"><b>No record found.</b> {{ enquiry.errorMessage }}</div>
}
@if (enquiry.status === 'REUSED') {
<div class="cic-alert info">{{ enquiry.errorMessage }}</div>
}
@if (enquiry.status === 'SUCCESS' || enquiry.status === 'REUSED') {
<div class="cic-card">
<div class="cic-score">
<div class="ring" [style.background]="ringStyle">
<div class="inner">
<div>
<b [class]="'tone-' + band.tone">{{ enquiry.score ?? '—' }}</b
><small>{{ band.label }}</small>
</div>
</div>
</div>
<div style="flex: 1; min-width: 0">
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px">
<span class="cic-badge" [class]="'cic-badge ' + statusTone(enquiry.status)">{{ enquiry.status }}</span>
<span class="cic-badge brand">{{ enquiry.bureauCode }}</span>
@if (enquiry.environment === 'SIMULATOR') {
<span class="cic-badge warn">SIMULATOR — not a real bureau report</span>
}
</div>
<dl class="cic-kv">
<dt>Report / control no.</dt>
<dd class="cic-mono">{{ result.reportId ?? '—' }}</dd>
<dt>Score model</dt>
<dd>{{ result.scoreVersion ?? '—' }}</dd>
<dt>Enquiry reference</dt>
<dd class="cic-mono">{{ enquiry.enquiryRef }}</dd>
</dl>
</div>
</div>
</div>
<div class="cic-grid cols-4" style="margin-bottom: 16px">
<div class="cic-stat">
<div class="k">Accounts</div>
<div class="v">{{ result.totalAccounts ?? '—' }}</div>
<div class="s">{{ result.activeAccounts ?? 0 }} active</div>
</div>
<div class="cic-stat">
<div class="k">Outstanding</div>
<div class="v">₹{{ result.totalOutstanding ?? 0 | number: '1.0-0' }}</div>
<div class="s">across active accounts</div>
</div>
<div class="cic-stat">
<div class="k">Overdue</div>
<div class="v" [class.tone-bad]="(result.totalOverdue ?? 0) > 0">
₹{{ result.totalOverdue ?? 0 | number: '1.0-0' }}
</div>
<div class="s">
{{ result.overdueAccounts ?? 0 }} account(s) · max DPD {{ result.maxDpd ?? 0 }}
@if ((result.writtenOffAccounts ?? 0) > 0) {
· <b class="tone-bad">{{ result.writtenOffAccounts }} written-off</b>
}
</div>
</div>
<div class="cic-stat">
<div class="k">Enquiries (6 months)</div>
<div class="v" [class.tone-warn]="(result.enquiriesLast6Months ?? 0) > 4">
{{ result.enquiriesLast6Months ?? '—' }}
</div>
<div class="s">credit hunger indicator</div>
</div>
</div>
@if (extraFields.length) {
<div class="cic-card">
<h2>Other mapped fields</h2>
<dl class="cic-kv">
@for (f of extraFields; track f.key) {
<dt>{{ f.key }}</dt>
<dd>{{ f.value ?? '—' }}</dd>
}
</dl>
</div>
}
<div class="cic-card">
<h2>
Credit accounts <span class="cic-card-sub">{{ accounts.length }} reported</span>
</h2>
@if (!accounts.length) {
<div class="cic-empty">The bureau reported no account (or the response mapping has no "accounts" field).</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
@for (k of keys(accounts[0]); track k) {
<th>{{ k }}</th>
}
</tr>
</thead>
<tbody>
@for (a of accounts; track $index) {
<tr>
@for (k of keys(accounts[0]); track k) {
<td>
@if (k === 'status') {
<span class="cic-badge" [class]="'cic-badge ' + accountTone(a)">{{ a[k] }}</span>
} @else {
{{ a[k] }}
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="cic-card">
<h2>
Recent enquiries by other lenders <span class="cic-card-sub">{{ enquiries.length }}</span>
</h2>
@if (!enquiries.length) {
<div class="cic-empty">No enquiry reported.</div>
} @else {
<div class="cic-table-wrap">
<table class="cic-table">
<thead>
<tr>
@for (k of keys(enquiries[0]); track k) {
<th>{{ k }}</th>
}
</tr>
</thead>
<tbody>
@for (q of enquiries; track $index) {
<tr>
@for (k of keys(enquiries[0]); track k) {
<td>{{ q[k] }}</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
@@ -1,108 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CicEnquiry, cicScoreBand } from '../cic.service';
/** The outcome of one enquiry: score, summary, accounts and enquiries, read from the normalised result. */
@Component({
selector: 'mifosx-cic-report-view',
standalone: true,
imports: [
CommonModule,
RouterModule
],
templateUrl: './cic-report-view.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicReportViewComponent {
@Input({ required: true }) enquiry!: CicEnquiry;
get result(): any {
return this.enquiry?.result ?? {};
}
get band() {
return cicScoreBand(this.enquiry?.score);
}
/** Conic ring filled in proportion to the score on the 300-900 scale. */
get ringStyle(): string {
const score = this.enquiry?.score;
const pct = score ? Math.max(0, Math.min(100, ((score - 300) / 600) * 100)) : 0;
const colors: Record<string, string> = {
good: '#047857',
ok: '#0369a1',
warn: '#b45309',
bad: '#b91c1c',
muted: '#94a3b8'
};
const color = colors[this.band.tone] ?? colors['muted'];
return `conic-gradient(${color} ${pct}%, #e2e8f0 ${pct}% 100%)`;
}
get accounts(): any[] {
return Array.isArray(this.result.accounts) ? this.result.accounts : [];
}
get enquiries(): any[] {
return Array.isArray(this.result.enquiries) ? this.result.enquiries : [];
}
/** Result fields beyond the standard ones a custom RESPONSE mapping may have added. */
get extraFields(): { key: string; value: any }[] {
const standard = new Set([
'hit',
'reportId',
'score',
'scoreVersion',
'totalAccounts',
'activeAccounts',
'overdueAccounts',
'writtenOffAccounts',
'totalOutstanding',
'totalOverdue',
'enquiriesLast6Months',
'maxDpd',
'accounts',
'enquiries'
]);
return Object.entries(this.result)
.filter(
([
k,
v
]) => !standard.has(k) && (v === null || typeof v !== 'object')
)
.map(
([
key,
value
]) => ({ key, value })
);
}
statusTone(status: string): string {
return status === 'SUCCESS' ? 'good' : status === 'REUSED' ? 'ok' : status === 'NO_HIT' ? 'warn' : 'bad';
}
accountTone(a: any): string {
const status = String(a?.status ?? '').toUpperCase();
if (status.includes('WRITTEN') || status.includes('SETTLED')) return 'bad';
if (Number(a?.dpd ?? 0) > 0 || Number(a?.amountOverdue ?? 0) > 0) return 'warn';
if (status === 'CLOSED') return '';
return 'good';
}
keys(o: any): string[] {
return o && typeof o === 'object' ? Object.keys(o) : [];
}
}
+29 -83
View File
@@ -492,45 +492,6 @@ textarea.cic-mono {
}
}
.cic-score {
display: flex;
align-items: center;
gap: 18px;
.ring {
width: 120px;
height: 120px;
border-radius: 50%;
display: grid;
place-items: center;
flex: none;
.inner {
width: 92px;
height: 92px;
border-radius: 50%;
background: #fff;
display: grid;
place-items: center;
text-align: center;
b {
font-size: 28px;
display: block;
line-height: 1;
font-variant-numeric: tabular-nums;
}
small {
font-size: 10px;
color: var(--cic-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
}
}
}
.cic-kv {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
@@ -562,56 +523,41 @@ textarea.cic-mono {
margin: 0;
}
.cic-steps {
.cic-trend {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: flex-end;
gap: 6px;
height: 180px;
padding-top: 6px;
.step {
.col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--cic-line);
background: #fff;
font-size: 12px;
font-weight: 600;
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;
}
i {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--cic-soft);
display: grid;
place-items: center;
font-style: normal;
font-size: 11px;
}
&.done {
color: var(--cic-good);
border-color: #a7f3d0;
background: var(--cic-good-soft);
i {
background: var(--cic-good);
color: #fff;
}
}
&.current {
color: var(--cic-brand);
border-color: #bfdbfe;
background: var(--cic-brand-soft);
i {
background: var(--cic-brand);
color: #fff;
}
}
.d {
font-size: 10px;
color: var(--cic-muted);
margin-top: 4px;
}
}
File diff suppressed because it is too large Load Diff
+104 -81
View File
@@ -30,14 +30,19 @@ export interface CicBureau {
tokenUrl?: string;
clientId?: string;
timeoutMs: number;
reportValidityDays: number;
enquiryCharge: number;
alertAutoFetch: boolean;
alertFetchIntervalMin: number;
alertLookbackDays: number;
lastFetchOn?: string;
lastFetchStatus?: string;
lastFetchMessage?: string;
lastTestStatus?: string;
lastTestMessage?: string;
lastTestedOn?: string;
updatedOn?: string;
activeEndpoints: number;
enquiriesLast30Days: number;
alertsLast30Days: number;
newAlerts: number;
}
export interface CicMapping {
@@ -70,63 +75,78 @@ export interface CicCatalog {
authTypes: string[];
endpointTypes: string[];
httpMethods: string[];
consentModes: string[];
purposes: Record<string, string>;
cbsFields: Record<string, string>;
resultFields: Record<string, string>;
rootFields: string[];
transforms: Record<string, string>;
defaultConsentText: string;
alertStatuses: string[];
matchTypes: string[];
}
export interface CicConsent {
/** One enquiry made on our customer at another lender, as reported by the CIC. */
export interface CicAlert {
id: number;
consentRef: string;
clientId: number;
clientName: string;
applicationNo?: string;
bureauId?: number;
bureauCode: string;
purpose: string;
consentMode: string;
consentText?: string;
documentRef?: string;
otpReference?: string;
validFrom: string;
validUntil: string;
status: 'ACTIVE' | 'EXPIRED' | 'REVOKED';
revokedReason?: string;
revokedOn?: string;
capturedByName?: string;
capturedOn?: string;
ipAddress?: string;
}
export interface CicEnquiry {
id: number;
enquiryRef: string;
bureauId: number;
bureauCode: string;
bureauName?: string;
clientId: number;
clientName: string;
applicationNo?: string;
enquiryType: string;
purpose: string;
loanAmount: number;
status: 'SUCCESS' | 'NO_HIT' | 'FAILED' | 'REUSED';
score?: number;
errorMessage?: string;
chargeAmount?: number;
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;
requestedByName?: string;
requestedOn: string;
durationMs?: number;
consentRef?: string;
reusedFromRef?: string;
fromDate?: string;
toDate?: string;
status: 'RUNNING' | 'SUCCESS' | 'FAILED';
httpStatus?: number;
requestMasked?: string;
responseRaw?: string;
result?: any;
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> {
@@ -134,14 +154,16 @@ export interface CicPage<T> {
pageItems: T[];
}
/** Score band used everywhere a score is shown. */
export function cicScoreBand(score: number | null | undefined): { label: string; tone: string } {
if (score === null || score === undefined) return { label: 'No score', tone: 'muted' };
if (score >= 750) return { label: 'Excellent', tone: 'good' };
if (score >= 700) return { label: 'Very good', tone: 'good' };
if (score >= 650) return { label: 'Good', tone: 'ok' };
if (score >= 550) return { label: 'Fair', tone: 'warn' };
return { label: 'Poor', tone: 'bad' };
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. */
@@ -197,45 +219,46 @@ export class CicService {
return this.http.put<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, body, this.silent);
}
preview(bureauId: number, body: any): Observable<any> {
preview(bureauId: number, body: { fromDate?: string; toDate?: string }): Observable<any> {
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/preview`, body, this.silent);
}
clientProfile(clientId: number, bureauId?: number | null): Observable<any> {
let params = new HttpParams();
if (bureauId) params = params.set('bureauId', String(bureauId));
return this.http.get<any>(`${this.base}/clients/${clientId}/profile`, { ...this.silent, params });
parseTest(bureauId: number, content: string | null): Observable<any> {
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/parse-test`, { content }, this.silent);
}
consents(filter: Record<string, any>): Observable<CicPage<CicConsent>> {
return this.http.get<CicPage<CicConsent>>(`${this.base}/consents`, { ...this.silent, params: this.params(filter) });
fetch(bureauId: number, body: { fromDate?: string; toDate?: string } = {}): Observable<CicFetchRun> {
return this.http.post<CicFetchRun>(`${this.base}/bureaus/${bureauId}/fetch`, body, this.silent);
}
createConsent(body: any): Observable<CicConsent> {
return this.http.post<CicConsent>(`${this.base}/consents`, body, this.silent);
}
revokeConsent(id: number, reason: string): Observable<CicConsent> {
return this.http.post<CicConsent>(`${this.base}/consents/${id}/revoke`, { reason }, this.silent);
}
enquiries(filter: Record<string, any>): Observable<CicPage<CicEnquiry>> {
return this.http.get<CicPage<CicEnquiry>>(`${this.base}/enquiries`, {
fetchRuns(filter: Record<string, any>): Observable<CicPage<CicFetchRun>> {
return this.http.get<CicPage<CicFetchRun>>(`${this.base}/fetch-runs`, {
...this.silent,
params: this.params(filter)
});
}
enquiry(id: number): Observable<CicEnquiry> {
return this.http.get<CicEnquiry>(`${this.base}/enquiries/${id}`, this.silent);
fetchRun(id: number): Observable<CicFetchRun> {
return this.http.get<CicFetchRun>(`${this.base}/fetch-runs/${id}`, this.silent);
}
enquire(body: any): Observable<CicEnquiry> {
return this.http.post<CicEnquiry>(`${this.base}/enquiries`, body, this.silent);
dashboard(days = 30): Observable<any> {
return this.http.get<any>(`${this.base}/dashboard`, {
...this.silent,
params: new HttpParams().set('days', String(days))
});
}
dashboard(): Observable<any> {
return this.http.get<any>(`${this.base}/dashboard`, this.silent);
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 {
@@ -11,10 +11,7 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
import { CicNewEnquiryComponent } from './cic-new-enquiry/cic-new-enquiry.component';
import { CicEnquiryHistoryComponent } from './cic-enquiry-history/cic-enquiry-history.component';
import { CicEnquiryDetailComponent } from './cic-enquiry-detail/cic-enquiry-detail.component';
import { CicConsentsComponent } from './cic-consents/cic-consents.component';
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
import { CicBureauConfigComponent } from './cic-bureau-config/cic-bureau-config.component';
import { CicApiMappingComponent } from './cic-api-mapping/cic-api-mapping.component';
@@ -35,30 +32,9 @@ const routes: Routes = [
data: { title: 'Credit Bureau Dashboard', breadcrumb: 'Dashboard' }
},
{
path: 'new-enquiry',
component: CicNewEnquiryComponent,
data: { title: 'New Bureau Enquiry', breadcrumb: 'New Enquiry' }
},
{
path: 'enquiries',
data: { breadcrumb: 'Enquiry History' },
children: [
{
path: '',
component: CicEnquiryHistoryComponent,
data: { title: 'Bureau Enquiry History' }
},
{
path: ':id',
component: CicEnquiryDetailComponent,
data: { title: 'Bureau Report', breadcrumb: 'Report' }
}
]
},
{
path: 'consents',
component: CicConsentsComponent,
data: { title: 'Bureau Consent Register', breadcrumb: 'Consent Register' }
path: 'fetch-log',
component: CicFetchLogComponent,
data: { title: 'Fetch Log', breadcrumb: 'Fetch Log' }
},
{
path: 'bureaus',
+14 -9
View File
@@ -11,25 +11,30 @@ 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 { CicNewEnquiryComponent } from './cic-new-enquiry/cic-new-enquiry.component';
import { CicEnquiryHistoryComponent } from './cic-enquiry-history/cic-enquiry-history.component';
import { CicEnquiryDetailComponent } from './cic-enquiry-detail/cic-enquiry-detail.component';
import { CicConsentsComponent } from './cic-consents/cic-consents.component';
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
import { CicAlertDetailComponent } from './cic-alert-detail/cic-alert-detail.component';
import { CicBureauConfigComponent } from './cic-bureau-config/cic-bureau-config.component';
import { CicApiMappingComponent } from './cic-api-mapping/cic-api-mapping.component';
import { CicService } from './cic.service';
import { CicStaticService } from './cic-static.service';
/** CIC / credit bureau enquiries (CIBIL, Experian, Equifax, CRIF) — its own top-level menu. */
/**
* CIC / credit bureau (CIBIL, Experian, Equifax, CRIF) - its own top-level menu: enquiry alerts on our customers from
* other lenders, the CIC whitelist and configuration, and the API mapping.
*/
@NgModule({
imports: [
SharedModule,
CreditBureauRoutingModule,
CicDashboardComponent,
CicNewEnquiryComponent,
CicEnquiryHistoryComponent,
CicEnquiryDetailComponent,
CicConsentsComponent,
CicFetchLogComponent,
CicAlertDetailComponent,
CicBureauConfigComponent,
CicApiMappingComponent
],
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 {}
@@ -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'));
}
}