feat(credit-bureau): CIC enquiry-alert dashboard and generic API Management hub (static UI)
Replace CIBIL fetch screens with a dashboard of our customers' loan enquiries at other lenders (no lender name), CIC whitelist/API mapping, and a Vendor -> Work -> API set management UI (IN/OUT, SMS, WhatsApp, CIC) with full HTTP detail and JSON/cURL/Postman/HAR import. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ 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';
|
||||
@@ -191,6 +192,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
CreditBureauModule,
|
||||
ApiManagementModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
@@ -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 & 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 & 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 -->
|
||||
|
||||
@@ -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 & 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 & 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,237 @@
|
||||
<!--
|
||||
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) {
|
||||
<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>
|
||||
Other enquiries on this customer
|
||||
<span class="cic-card-sub">{{ alert.otherAlertsOfCustomer?.length }} more</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 alert.otherAlertsOfCustomer; track o.id) {
|
||||
<tr class="clickable" (click)="alertId = o.id; load()">
|
||||
<td>{{ o.enquiryDate | date: 'dd MMM yyyy' }}</td>
|
||||
<td>{{ o.loanType }}</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,149 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
action: 'ACTIONED' | 'DISMISSED' | null = null;
|
||||
remarks = '';
|
||||
|
||||
readonly matchLabels = CIC_MATCH_LABELS;
|
||||
readonly statusTone = cicStatusTone;
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (this.alertId) 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;
|
||||
// Opening a new alert is seeing it.
|
||||
if (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.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 & 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 & 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 & 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 & 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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user