feat(api-management): set up each CIC bureau as an API vendor

CIBIL, Experian, Equifax and CRIF are vendors under API Management (active =
whitelisted) and the Credit Bureau dashboard reads them from there; the
separate CIC whitelist and API mapping screens are removed. Mapping tables show
the value each row finds in the sample payload, and bureau cards link to their
set-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:50:27 +05:30
co-authored by Claude Opus 5.5
parent f0b8f7f5f4
commit 885b5897af
13 changed files with 283 additions and 1397 deletions
+148 -82
View File
@@ -307,88 +307,148 @@ export function buildRequestPreview(vendor: ApiVendor, a: VendorApi): RequestPre
};
}
/**
* The credit information companies. Each bureau is a vendor of its own - set up, switched on (whitelisted) and mapped
* here like any other API; the Credit Bureau dashboard fetches alerts only from the bureaus that are active.
*/
function cicVendors(): ApiVendor[] {
const bureau = (
id: number,
code: string,
name: string,
active: boolean,
environment: ApiVendor['environment'],
baseUrl: string,
path: string,
lastCall: number | undefined
): ApiVendor => ({
id,
code,
name,
work: 'CIC enquiry alerts: tells us when another lender enquires on one of our customers (the lender is not named)',
configNote:
'Active = whitelisted for this bank. The Credit Bureau dashboard fetches alerts every 60 minutes from each active bureau, looking back 7 days; the sender / member id is the bank member code at the bureau.',
environment,
baseUrl,
authType: environment === 'SIMULATOR' ? 'NONE' : 'BASIC',
username: environment === 'SIMULATOR' ? '' : 'MH00123456_API',
apiKeyHeader: 'X-API-KEY',
hasSecret: environment !== 'SIMULATOR',
senderId: active ? 'MH00123456' : '',
timeoutMs: 30000,
retries: 1,
defaultHeaders: [kv('Accept', 'application/json', 'Every call asks for JSON')],
active,
lastCallOn: lastCall === undefined ? undefined : ago(lastCall),
links: [],
apis: [
api({
id: id * 100 + 1,
name: 'Enquiry alerts',
direction: 'IN',
mode: 'PULL',
method: 'POST',
path,
usedFor: `Every 60 minutes the CBS asks ${code} for new enquiries on our customers by other lenders`,
queryParams: [kv('version', '1.0', 'API version of the bureau')],
headers: [
kv('X-Member-Code', '{{bank.memberCode}}', "The bank's member code at the bureau"),
kv('X-Request-Id', '{{request.reference}}', 'Our reference for this fetch')
],
requestBody: pretty({
requestHeader: { memberCode: 'MH00123456', referenceNumber: 'CICF000001' },
criteria: { fromDate: '01102026', toDate: '08102026', scope: 'OTHER_MEMBERS' }
}),
successCodes: '200',
responseHeaders: [
kv('Content-Type', 'application/json'),
kv('X-RateLimit-Remaining', '98', 'Calls left in this window')
],
responseBody: pretty({
responseHeader: { status: 'SUCCESS' },
data: {
totalAlerts: 1,
alerts: [
{
alertId: `${code}-20261008-1`,
consumer: { name: 'RAHUL SHARMA', pan: 'ABXPS4421K', aadhaar: 'XXXXXXXX4421', dob: '12051988' },
enquiry: {
productType: 'PERSONAL LOAN',
amount: 450000,
date: '08102026',
purpose: 'NEW LOAN',
location: 'PUNE'
}
}
]
}
}),
errorBody: pretty({
responseHeader: { status: 'ERROR', errorCode: 'E102', errorMessage: 'Invalid member code' }
}),
requestRows: [
row('bank.memberCode', 'requestHeader.memberCode', 'NONE', true),
row('request.reference', 'requestHeader.referenceNumber', 'NONE', true),
row('request.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', true),
row('request.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', true),
row('', 'criteria.scope', 'NONE', false, 'OTHER_MEMBERS')
],
responseRows: [
row('data.alerts', 'alerts', 'NONE', true),
row('responseHeader.errorCode', 'errorCode'),
row('responseHeader.errorMessage', 'errorMessage'),
row('alertId', 'alert.reference', 'NONE', true),
row('consumer.pan', 'customer.pan', 'UPPER'),
row('consumer.aadhaar', 'customer.aadhaar', 'DIGITS_ONLY'),
row('consumer.name', 'customer.name', 'UPPER'),
row('consumer.dob', 'customer.dob', 'DATE_DDMMYYYY'),
row('enquiry.productType', 'alert.loanType'),
row('enquiry.amount', 'alert.amount', 'NUMBER'),
row('enquiry.date', 'alert.enquiryDate', 'DATE_DDMMYYYY', true),
row('enquiry.purpose', 'alert.purpose'),
row('enquiry.location', 'alert.location')
]
})
]
});
return [
bureau(11, 'CIBIL', 'TransUnion CIBIL', true, 'SIMULATOR', '', '/monitoring/enquiry-alerts', 55),
bureau(
12,
'EXPERIAN',
'Experian Credit Information Company of India',
true,
'SIMULATOR',
'',
'/ecv-api/alerts/enquiries',
53
),
bureau(
13,
'EQUIFAX',
'Equifax Credit Information Services',
false,
'UAT',
'https://uat.equifax.co.in',
'/alerts/v1/enquiries',
undefined
),
bureau(
14,
'CRIF',
'CRIF High Mark Credit Information Services',
false,
'UAT',
'https://test.crifhighmark.com',
'/api/monitor/enquiryAlerts',
undefined
)
];
}
function buildVendors(): ApiVendor[] {
return [
{
id: 1,
code: 'CIC',
name: 'CIC — Credit Information Companies',
work: 'Enquiry alerts: tells us when another lender enquires on one of our customers (CIBIL, Experian, Equifax, CRIF)',
configNote:
'Each bureau is whitelisted and connected on the CIC Whitelist & Configuration screen; this API set is its alert call.',
environment: 'SIMULATOR',
baseUrl: '',
authType: 'NONE',
username: '',
apiKeyHeader: 'X-API-KEY',
hasSecret: false,
senderId: 'MH00123456',
timeoutMs: 30000,
retries: 1,
defaultHeaders: [kv('Accept', 'application/json', 'Every call asks for JSON')],
active: true,
lastCallOn: ago(55),
links: [
{ label: 'Whitelist & configuration', route: '/credit-bureau/bureaus' },
{ label: 'API mapping', route: '/credit-bureau/api-mapping' },
{ label: 'Credit Bureau dashboard', route: '/credit-bureau/dashboard' }
],
apis: [
api({
id: 101,
name: 'Enquiry alerts',
direction: 'IN',
mode: 'PULL',
method: 'POST',
path: '/monitoring/enquiry-alerts',
usedFor: 'Every 60 minutes the CBS asks each whitelisted bureau for new enquiries on our customers',
queryParams: [kv('version', '1.0', 'API version of the bureau')],
headers: [
kv('X-Member-Code', '{{bank.memberCode}}', "The bank's member code at the bureau"),
kv('X-Request-Id', '{{request.reference}}', 'Our reference for this fetch')
],
requestBody: pretty({
requestHeader: { memberCode: 'MH00123456', referenceNumber: 'CICF000001' },
criteria: { fromDate: '01102026', toDate: '08102026', scope: 'OTHER_MEMBERS' }
}),
successCodes: '200',
responseHeaders: [
kv('Content-Type', 'application/json'),
kv('X-RateLimit-Remaining', '98', 'Calls left in this window')
],
responseBody: pretty({
responseHeader: { status: 'SUCCESS' },
data: {
totalAlerts: 1,
alerts: [
{
alertId: 'CIBIL-20261008-1',
consumer: { pan: 'ABXPS4421K' },
enquiry: { productType: 'PERSONAL LOAN', amount: 450000, date: '08102026' }
}
]
}
}),
errorBody: pretty({
responseHeader: { status: 'ERROR', errorCode: 'E102', errorMessage: 'Invalid member code' }
}),
requestRows: [
row('bank.memberCode', 'requestHeader.memberCode'),
row('request.reference', 'requestHeader.referenceNumber', 'NONE', true),
row('request.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', true),
row('request.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', true),
row('', 'criteria.scope', 'NONE', false, 'OTHER_MEMBERS')
],
responseRows: [
row('data.alerts', 'alerts', 'NONE', true),
row('consumer.pan', 'customer.pan', 'UPPER'),
row('enquiry.productType', 'alert.loanType'),
row('enquiry.amount', 'alert.amount', 'NUMBER'),
row('enquiry.date', 'alert.enquiryDate', 'DATE_DDMMYYYY')
]
})
]
},
...cicVendors(),
{
id: 2,
code: 'SMS_NOTICE',
@@ -684,7 +744,8 @@ function buildLogs(vendors: ApiVendor[]): ApiLog[] {
return seed / 4294967296;
};
for (let i = 0; i < 64; i++) {
const v = vendors[Math.floor(next() * vendors.length)];
const live = vendors.filter((x) => x.active && x.apis.some((a) => a.active));
const v = live[Math.floor(next() * live.length)];
const apis = v.apis.filter((a) => a.active);
const a = apis[Math.floor(next() * apis.length)];
const failed = next() < 0.14;
@@ -755,12 +816,17 @@ function jsonError(text: string): string | null {
export class ApiManagementService {
private vendorList = buildVendors();
private logList = buildLogs(this.vendorList);
private nextVendorId = 10;
private nextVendorId = 20;
private nextApiId = 1000;
private nextLogId = 1000;
// ── vendors ──
/** Synchronous read for other static services (the Credit Bureau dashboard reads the bureaus' set-up from here). */
vendorByCode(code: string): ApiVendor | undefined {
return this.vendorList.find((v) => v.code === code);
}
vendors(): Observable<ApiVendor[]> {
return reply(this.vendorList);
}
@@ -515,6 +515,10 @@
<h4 style="margin: 16px 0 6px; font-size: 13px">
{{ mappingTitle('request') }}
<span class="muted" style="font-weight: 400; font-size: 12px">
· {{ matchedCount('request') }} of {{ editing.requestRows.length }} rows found in the sample
{{ 'request' === 'request' ? 'payload' : 'body' }}
</span>
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('request')">
Build rows from the payload
</button>
@@ -527,11 +531,13 @@
<th>{{ toLabel('request') }}</th>
<th>Transform</th>
<th>Fixed / default value</th>
<th>In the sample payload</th>
<th>Required</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
@let requestMatch = sampleMatches('request');
@for (r of editing.requestRows; track $index; let i = $index) {
<tr>
<td>
@@ -555,6 +561,15 @@
</select>
</td>
<td><input [(ngModel)]="r.defaultValue" /></td>
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
@if (requestMatch[i].found) {
<span class="tone-good">✓ {{ requestMatch[i].value }}</span>
} @else if (r.defaultValue) {
<span class="muted">fixed: {{ r.defaultValue }}</span>
} @else {
<span class="tone-bad">✗ {{ requestMatch[i].value || 'no path' }}</span>
}
</td>
<td style="text-align: center">
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
</td>
@@ -606,6 +621,10 @@
<h4 style="margin: 16px 0 6px; font-size: 13px">
{{ mappingTitle('response') }}
<span class="muted" style="font-weight: 400; font-size: 12px">
· {{ matchedCount('response') }} of {{ editing.responseRows.length }} rows found in the sample
{{ 'response' === 'request' ? 'payload' : 'body' }}
</span>
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('response')">
Build rows from the body
</button>
@@ -618,11 +637,13 @@
<th>{{ toLabel('response') }}</th>
<th>Transform</th>
<th>Fixed / default value</th>
<th>In the sample payload</th>
<th>Required</th>
<th style="width: 40px"></th>
</tr>
</thead>
<tbody>
@let responseMatch = sampleMatches('response');
@for (r of editing.responseRows; track $index; let i = $index) {
<tr>
<td>
@@ -646,6 +667,15 @@
</select>
</td>
<td><input [(ngModel)]="r.defaultValue" /></td>
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
@if (responseMatch[i].found) {
<span class="tone-good">✓ {{ responseMatch[i].value }}</span>
} @else if (r.defaultValue) {
<span class="muted">fixed: {{ r.defaultValue }}</span>
} @else {
<span class="tone-bad">✗ {{ responseMatch[i].value || 'no path' }}</span>
}
</td>
<td style="text-align: center">
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
</td>
@@ -272,6 +272,60 @@ export class ApiVendorSetupComponent implements OnInit {
return 'CBS field';
}
private parsedCache = new Map<string, unknown>();
private parsed(text: string): unknown {
if (!this.parsedCache.has(text)) {
let v: unknown = undefined;
try {
v = JSON.parse(text);
} catch {
v = undefined;
}
if (this.parsedCache.size > 20) this.parsedCache.clear();
this.parsedCache.set(text, v);
}
return this.parsedCache.get(text);
}
/** Reads a.b[0].c out of a parsed payload; undefined when it is not there. */
private readPath(root: unknown, path: string): unknown {
let cur: any = root;
for (const part of path.match(/[^.[\]]+/g) ?? []) {
if (cur === null || cur === undefined) return undefined;
cur = cur[/^\d+$/.test(part) ? Number(part) : part];
}
return cur;
}
/**
* How a mapping row matches the sample payload on its side: the value found at the row's JSON path. Rows after a
* list (e.g. data.alerts) are read from the first item of that list, as the CBS does for each item.
*/
sampleMatches(side: 'request' | 'response'): { found: boolean; value: string }[] {
if (!this.editing) return [];
const body = this.parsed(side === 'request' ? this.editing.requestBody : this.editing.responseBody);
const pathIsTo = this.fromIsCbs(side);
let item: unknown = undefined;
return this.rowsOf(side).map((r) => {
const path = (pathIsTo ? r.to : r.from).trim();
if (!path || body === undefined) return { found: false, value: body === undefined ? 'payload is not JSON' : '' };
let v = this.readPath(body, path);
if (v === undefined && item !== undefined) v = this.readPath(item, path);
if (Array.isArray(v)) {
if (item === undefined && v.length) item = v[0];
return { found: true, value: `list of ${v.length}` };
}
if (v === undefined) return { found: false, value: 'not in the sample payload' };
const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
return { found: true, value: text.length > 40 ? text.slice(0, 40) + '…' : text };
});
}
matchedCount(side: 'request' | 'response'): number {
return this.sampleMatches(side).filter((m) => m.found).length;
}
addRow(side: 'request' | 'response'): void {
this.rowsOf(side).push({ from: '', to: '', transform: 'NONE', defaultValue: '', mandatory: false });
}
@@ -249,21 +249,7 @@
<span class="ai-card-ic"><fa-icon icon="cogs"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">API Vendors &amp; Setup</span>
<span class="ai-card-d">Vendor, work and API set — IN / OUT (CIC, SMS, WhatsApp …)</span>
</span>
</button>
<button mat-menu-item class="ai-card" routerLink="/credit-bureau/bureaus" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="id-card"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">CIC Whitelist &amp; Configuration</span>
<span class="ai-card-d">Choose and connect CIBIL, Experian, Equifax, CRIF</span>
</span>
</button>
<button mat-menu-item class="ai-card" routerLink="/credit-bureau/api-mapping" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="project-diagram"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">CIC API Mapping</span>
<span class="ai-card-d">Map CBS fields to the bureau's request and response</span>
<span class="ai-card-d">Vendor, work and API set — IN / OUT (CIC bureaus, SMS, WhatsApp …)</span>
</span>
</button>
<button mat-menu-item class="ai-card" routerLink="/api-management/logs" tabindex="0">
@@ -1,489 +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>CIC API Mapping — Enquiry Alerts</h1>
<p>
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"
(click)="save()"
[disabled]="saving || !endpoint"
*mifosxHasPermission="'UPDATE_CICBUREAU'"
>
{{ saving ? 'Saving…' : dirty ? 'Save changes' : 'Save' }}
</button>
</div>
</div>
<div class="cic-card">
<div class="cic-grid cols-3" style="align-items: end">
<div class="cic-field">
<label>Bureau</label>
<select [(ngModel)]="bureauId" (ngModelChange)="load()">
@for (b of bureaus; track b.id) {
<option [ngValue]="b.id">{{ b.code }} — {{ b.name }}</option>
}
</select>
</div>
<div class="cic-field">
<label>API</label>
<select [(ngModel)]="endpointType" (ngModelChange)="load()">
@for (t of catalog?.endpointTypes ?? []; track t) {
<option [value]="t">
{{ t === 'ENQUIRY_ALERT' ? 'Enquiry alerts (our customer enquired elsewhere)' : t }}
</option>
}
</select>
</div>
<div>
@if (bureau) {
<span
class="cic-badge"
[class.warn]="bureau.environment === 'SIMULATOR'"
[class.good]="bureau.environment === 'PRODUCTION'"
>{{ bureau.environment }}</span
>
<span class="cic-badge" [class.good]="bureau.whitelisted">{{
bureau.whitelisted ? 'Whitelisted' : 'Not whitelisted'
}}</span>
@if (endpoint && !endpoint.exists) {
<span class="cic-badge warn">Not configured yet</span>
}
@if (dirty) {
<span class="cic-badge warn">Unsaved changes</span>
}
}
</div>
</div>
</div>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
@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 the bureau's own API specification and check them with Test
Response.
</div>
}
@if (loading) {
<div class="cic-card cic-empty">Loading…</div>
}
@if (endpoint && !loading) {
<div class="cic-tabs">
<button [class.active]="tab === 'response'" (click)="tab = 'response'">
Response mapping ({{ responseRows.length }})
</button>
<button [class.active]="tab === 'request'" (click)="tab = 'request'">
Request mapping ({{ requestRows.length }})
</button>
<button [class.active]="tab === 'endpoint'" (click)="tab = 'endpoint'">Endpoint, headers &amp; body</button>
<button [class.active]="tab === 'test'" (click)="tab = 'test'">Test response</button>
<button [class.active]="tab === 'preview'" (click)="tab = 'preview'">Preview request</button>
</div>
<!-- RESPONSE -->
@if (tab === 'response') {
<div class="cic-card">
<h2>
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: 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>
<th>Mandatory</th>
<th style="width: 110px"></th>
</tr>
</thead>
<tbody>
@for (r of requestRows; track $index; let i = $index) {
<tr>
<td class="muted">{{ i + 1 }}</td>
<td>
<select [(ngModel)]="r.sourceField" (ngModelChange)="markDirty()">
<option [ngValue]="null">— fixed value —</option>
@for (f of cbsFieldList; track f.key) {
<option [ngValue]="f.key">{{ f.label }}</option>
}
</select>
</td>
<td>
<input
class="cic-mono"
[(ngModel)]="r.targetField"
(ngModelChange)="markDirty()"
placeholder="criteria.fromDate"
/>
</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()"
[placeholder]="r.sourceField ? 'if empty' : 'value'"
/>
</td>
<td style="text-align: center">
<input
type="checkbox"
[(ngModel)]="r.mandatory"
(ngModelChange)="markDirty()"
style="width: auto"
/>
</td>
<td style="white-space: nowrap">
<button class="cic-btn sm" (click)="move('request', i, -1)" [disabled]="i === 0" title="Move up">
↑
</button>
<button
class="cic-btn sm"
(click)="move('request', i, 1)"
[disabled]="i === requestRows.length - 1"
title="Move down"
>
↓
</button>
<button class="cic-btn sm danger" (click)="removeRow('request', i)" title="Remove">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<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 value that is empty stops the fetch before the bureau is called.</span
>
</div>
</div>
}
<!-- ENDPOINT -->
@if (tab === 'endpoint') {
<div class="cic-card">
<h2>Endpoint</h2>
<div class="cic-grid cols-4">
<div class="cic-field">
<label>HTTP method</label>
<select [(ngModel)]="endpoint.httpMethod" (ngModelChange)="markDirty()">
@for (m of catalog?.httpMethods ?? []; track m) {
<option [value]="m">{{ m }}</option>
}
</select>
</div>
<div class="cic-field" style="grid-column: span 2">
<label>Path (appended to the bureau's base URL)</label>
<input
class="cic-mono"
[(ngModel)]="endpoint.path"
(ngModelChange)="markDirty()"
placeholder="/monitoring/enquiry-alerts"
/>
</div>
<div class="cic-field">
<label>Content type</label>
<input class="cic-mono" [(ngModel)]="endpoint.contentType" (ngModelChange)="markDirty()" />
</div>
</div>
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px; margin-top: 12px">
<input type="checkbox" [(ngModel)]="endpoint.isActive" (ngModelChange)="markDirty()" /> Endpoint active
</label>
<div class="cic-grid cols-2" style="margin-top: 14px">
<div class="cic-field">
<label>Static headers (JSON object)</label>
<textarea
class="cic-mono"
rows="10"
[(ngModel)]="endpoint.headersJson"
(ngModelChange)="markDirty()"
></textarea>
<span class="hint"
>Placeholders like <code>{{ '{{' }}bureau.memberCode{{ '}}' }}</code> or
<code>{{ '{{' }}fetch.reference{{ '}}' }}</code> are filled in. Authentication headers are added
automatically.</span
>
@if (jsonErrors.headers) {
<span class="tone-bad" style="font-size: 12px">{{ jsonErrors.headers }}</span>
}
</div>
<div class="cic-field">
<label>Base request body (JSON object)</label>
<textarea
class="cic-mono"
rows="10"
[(ngModel)]="endpoint.baseBody"
(ngModelChange)="markDirty()"
></textarea>
<span class="hint">Constant parts of the request; request mappings are written into it.</span>
@if (jsonErrors.body) {
<span class="tone-bad" style="font-size: 12px">{{ jsonErrors.body }}</span>
}
</div>
</div>
</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 — nothing is sent</span></h2>
<div class="cic-grid cols-4" style="align-items: end">
<div class="cic-field">
<label>Alerts from</label>
<input type="date" [(ngModel)]="previewFrom" />
</div>
<div class="cic-field">
<label>Alerts to</label>
<input type="date" [(ngModel)]="previewTo" />
</div>
<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">
{{ previewing ? 'Building…' : 'Build preview' }}
</button>
</div>
@if (previewResult) {
@if (previewResult.missingMandatory?.length) {
<div class="cic-alert error">
The fetch would be refused — mandatory values are empty:
<ul>
@for (m of previewResult.missingMandatory; track m) {
<li>{{ m }}</li>
}
</ul>
</div>
}
<div class="cic-split">
<div class="cic-card">
<h2>Request body</h2>
<pre class="cic-code">{{ prettyJson(previewResult.body) }}</pre>
</div>
<div class="cic-card">
<h2>Call</h2>
<dl class="cic-kv" style="grid-template-columns: 120px minmax(0, 1fr)">
<dt>Method</dt>
<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) {
<dt class="cic-mono">{{ h.k }}</dt>
<dd class="cic-mono">{{ h.v }}</dd>
}
</dl>
</div>
</div>
}
}
}
</div>
@@ -1,310 +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 { ActivatedRoute, RouterModule } from '@angular/router';
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
import { CIC_MATCH_LABELS, CicBureau, CicCatalog, CicEndpoint, CicMapping, CicService, cicError } from '../cic.service';
/**
* 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',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
HasPermissionDirective
],
templateUrl: './cic-api-mapping.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicApiMappingComponent implements OnInit {
private cic = inject(CicService);
private route = inject(ActivatedRoute);
catalog: CicCatalog | null = null;
bureaus: CicBureau[] = [];
bureauId: number | null = null;
endpointType = 'ENQUIRY_ALERT';
endpoint: CicEndpoint | null = null;
requestRows: CicMapping[] = [];
responseRows: CicMapping[] = [];
tab: 'request' | 'response' | 'endpoint' | 'preview' | 'test' = 'response';
loading = false;
saving = false;
dirty = false;
error: string | null = null;
info: string | null = null;
jsonErrors: { headers?: string; body?: string } = {};
// preview
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 }[] = [];
readonly matchLabels = CIC_MATCH_LABELS;
ngOnInit(): void {
this.cic.catalog().subscribe({
next: (c) => {
this.catalog = c;
this.cbsFieldList = Object.entries(c.cbsFields).map(
([
key,
label
]) => ({ key, label })
);
this.resultFieldList = Object.entries(c.resultFields).map(
([
key,
label
]) => ({ key, label })
);
this.transformList = Object.entries(c.transforms).map(
([
key,
label
]) => ({ key, label })
);
},
error: (e) => (this.error = cicError(e))
});
this.cic.bureaus().subscribe({
next: (b) => {
this.bureaus = b;
const wanted = Number(this.route.snapshot.queryParamMap.get('bureauId'));
this.bureauId = b.find((x) => x.id === wanted)?.id ?? b[0]?.id ?? null;
this.load();
},
error: (e) => (this.error = cicError(e, 'Could not load the bureaus.'))
});
}
get bureau(): CicBureau | undefined {
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;
this.loading = true;
this.error = null;
this.info = null;
this.previewResult = null;
this.testResult = null;
this.cic.endpoint(this.bureauId, this.endpointType).subscribe({
next: (e) => {
this.apply(e);
this.loading = false;
},
error: (err) => {
this.loading = false;
this.error = cicError(err, 'Could not load the mapping.');
}
});
}
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;
}
addRow(direction: 'request' | 'response'): void {
const row: CicMapping = {
sourceField: null,
targetField: '',
transform: 'NONE',
defaultValue: null,
mandatory: false
};
(direction === 'request' ? this.requestRows : this.responseRows).push(row);
this.dirty = true;
}
removeRow(direction: 'request' | 'response', index: number): void {
(direction === 'request' ? this.requestRows : this.responseRows).splice(index, 1);
this.dirty = true;
}
move(direction: 'request' | 'response', index: number, delta: number): void {
const rows = direction === 'request' ? this.requestRows : this.responseRows;
const to = index + delta;
if (to < 0 || to >= rows.length) return;
[
rows[index],
rows[to]
] = [
rows[to],
rows[index]
];
this.dirty = true;
}
/** Local checks before the server's own validation. */
private validJson(): boolean {
this.jsonErrors = {};
for (const [
key,
text
] of [
[
'headers',
this.endpoint?.headersJson
],
[
'body',
this.endpoint?.baseBody
]
] as const) {
try {
const parsed = JSON.parse(text || '{}');
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null)
throw new Error('must be a JSON object');
} catch (e: any) {
this.jsonErrors[key] = `Not a valid JSON object: ${e?.message ?? e}`;
}
}
return !this.jsonErrors.headers && !this.jsonErrors.body;
}
save(): void {
if (!this.bureauId || !this.endpoint) return;
if (!this.validJson()) {
this.tab = 'endpoint';
this.error = 'Fix the JSON on the Endpoint tab before saving.';
return;
}
this.saving = true;
this.error = null;
this.info = null;
const clean = (rows: CicMapping[]) =>
rows.map((r, i) => ({
sourceField: r.sourceField || null,
targetField: (r.targetField || '').trim(),
transform: r.transform || 'NONE',
defaultValue: r.defaultValue === '' ? null : r.defaultValue,
mandatory: !!r.mandatory,
sortOrder: (i + 1) * 10
}));
this.cic
.saveEndpoint(this.bureauId, this.endpointType, {
httpMethod: this.endpoint.httpMethod,
path: this.endpoint.path,
contentType: this.endpoint.contentType,
headersJson: JSON.stringify(JSON.parse(this.endpoint.headersJson || '{}')),
baseBody: JSON.stringify(JSON.parse(this.endpoint.baseBody || '{}')),
isActive: this.endpoint.isActive,
requestMappings: clean(this.requestRows),
responseMappings: clean(this.responseRows)
})
.subscribe({
next: (e) => {
this.saving = false;
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;
this.error = cicError(err, 'Could not save the mapping.');
}
});
}
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.requireSaved()) return;
this.previewing = true;
this.error = null;
this.cic
.preview(this.bureauId, { fromDate: this.previewFrom || undefined, toDate: this.previewTo || undefined })
.subscribe({
next: (r) => {
this.previewing = false;
this.previewResult = r;
},
error: (e) => {
this.previewing = false;
this.error = cicError(e, 'Preview failed.');
}
});
}
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 {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
headerEntries(h: Record<string, string> | null | undefined): { k: string; v: string }[] {
return Object.entries(h ?? {}).map(
([
k,
v
]) => ({ k, v })
);
}
}
@@ -1,279 +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>CIC Whitelist &amp; Configuration</h1>
<p>
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>
@if (error) {
<div class="cic-alert error">{{ error }}</div>
}
@if (info) {
<div class="cic-alert success">{{ info }}</div>
}
@if (loading && !bureaus.length) {
<div class="cic-card cic-empty">Loading…</div>
}
<div class="cic-grid cols-2">
@for (b of bureaus; track b.id) {
<div class="cic-card" style="margin-bottom: 0">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
<div style="min-width: 0">
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
<b style="font-size: 16px">{{ b.code }}</b>
<span class="cic-badge" [class]="'cic-badge ' + envTone(b.environment)">{{ b.environment }}</span>
@if (b.isDefault) {
<span class="cic-badge brand">Default</span>
}
</div>
<div class="muted" style="font-size: 12px; margin-top: 3px">{{ b.name }}</div>
</div>
<label style="display: flex; gap: 8px; align-items: center; font-size: 12px; font-weight: 600">
{{ b.whitelisted ? 'Whitelisted' : 'Not whitelisted' }}
<span class="cic-switch">
<input
type="checkbox"
[checked]="b.whitelisted"
[disabled]="toggling === b.id"
(change)="toggle(b, $event)"
*mifosxHasPermission="'UPDATE_CICBUREAU'"
/>
<span></span>
</span>
</label>
</div>
<dl class="cic-kv" style="margin-top: 14px; grid-template-columns: 140px minmax(0, 1fr)">
<dt>Base URL</dt>
<dd class="cic-mono">{{ b.environment === 'SIMULATOR' ? '(simulator)' : b.baseUrl || '— not set —' }}</dd>
<dt>Member code</dt>
<dd>{{ b.memberCode || '—' }}</dd>
<dt>Authentication</dt>
<dd>
{{ authLabels[b.authType] || b.authType }}
@if (b.authType !== 'NONE') {
<span class="cic-badge" [class.good]="b.hasSecret" [class.bad]="!b.hasSecret">{{
b.hasSecret ? 'credential stored' : 'no credential'
}}</span>
}
</dd>
<dt>Endpoints</dt>
<dd>{{ b.activeEndpoints }} active</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) {
<span
class="cic-badge"
[class.good]="b.lastTestStatus === 'OK'"
[class.bad]="b.lastTestStatus !== 'OK'"
>{{ b.lastTestStatus }}</span
>
<span class="muted" style="font-size: 12px"> {{ b.lastTestedOn | slice: 0 : 16 }}</span>
<div class="muted" style="font-size: 12px">{{ b.lastTestMessage }}</div>
} @else {
<span class="muted">never</span>
}
</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">
<button class="cic-btn" (click)="edit(b)" *mifosxHasPermission="'UPDATE_CICBUREAU'">Configure</button>
<button
class="cic-btn"
(click)="test(b)"
[disabled]="testing === b.id"
*mifosxHasPermission="'UPDATE_CICBUREAU'"
>
{{ 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>
}
</div>
<div class="cic-alert info" style="margin-top: 16px">
<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) {
<div class="cic-modal-backdrop" (click)="editing = null">
<div class="cic-modal" style="width: min(820px, 100%)" (click)="$event.stopPropagation()">
<h3>Configure {{ editing.code }}</h3>
<div class="cic-grid cols-2">
<div class="cic-field">
<label>Display name</label>
<input [(ngModel)]="form.name" />
</div>
<div class="cic-field">
<label>Environment</label>
<select [(ngModel)]="form.environment">
<option value="SIMULATOR">SIMULATOR — built-in test bureau, no network call</option>
<option value="UAT">UAT — bureau's test environment</option>
<option value="PRODUCTION">PRODUCTION — live bureau</option>
</select>
</div>
<div class="cic-field" style="grid-column: 1 / -1">
<label>Base URL</label>
<input class="cic-mono" [(ngModel)]="form.baseUrl" placeholder="https://api.bureau.example/v1" />
<span class="hint"
>Endpoint paths from API Mapping are appended to this. PRODUCTION requires https://.</span
>
</div>
<div class="cic-field">
<label>Bank's member code / id</label>
<input [(ngModel)]="form.memberCode" />
</div>
<div class="cic-field">
<label>Authentication</label>
<select [(ngModel)]="form.authType">
@for (a of catalog?.authTypes ?? []; track a) {
<option [value]="a">{{ authLabels[a] || a }}</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 (form.authType === 'OAUTH2_CLIENT_CREDENTIALS') {
<div class="cic-field">
<label>Token URL</label>
<input
class="cic-mono"
[(ngModel)]="form.tokenUrl"
placeholder="https://auth.bureau.example/oauth/token"
/>
</div>
<div class="cic-field">
<label>Client id</label>
<input [(ngModel)]="form.clientId" autocomplete="off" />
</div>
}
@if (needsSecret(form.authType)) {
<div class="cic-field">
<label>{{ secretLabel(form.authType) }}</label>
<input
type="password"
[(ngModel)]="form.secret"
autocomplete="new-password"
[placeholder]="editing.hasSecret ? '•••••• stored — leave blank to keep' : 'Enter to store (encrypted)'"
/>
@if (editing.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>Timeout (ms)</label>
<input type="number" min="1000" max="300000" [(ngModel)]="form.timeoutMs" />
</div>
<div class="cic-field">
<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>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 (listed first)
</label>
</div>
<div class="foot">
<button class="cic-btn" (click)="editing = null">Cancel</button>
<button class="cic-btn primary" (click)="save()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
}
</div>
@@ -1,198 +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 { RouterModule } from '@angular/router';
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
import { CicBureau, CicCatalog, CicService, cicError } from '../cic.service';
/**
* 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',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
HasPermissionDirective
],
templateUrl: './cic-bureau-config.component.html',
styleUrls: ['../cic-shared.scss']
})
export class CicBureauConfigComponent implements OnInit {
private cic = inject(CicService);
catalog: CicCatalog | null = null;
bureaus: CicBureau[] = [];
loading = false;
error: string | null = null;
info: string | null = null;
editing: CicBureau | null = null;
form: any = {};
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)',
BASIC: 'Basic (user id + password)',
API_KEY: 'API key in a header',
BEARER_TOKEN: 'Static bearer token',
OAUTH2_CLIENT_CREDENTIALS: 'OAuth2 client credentials'
};
ngOnInit(): void {
this.cic.catalog().subscribe({ next: (c) => (this.catalog = c), error: (e) => (this.error = cicError(e)) });
this.load();
}
load(): void {
this.loading = true;
this.cic.bureaus().subscribe({
next: (b) => {
this.bureaus = b;
this.loading = false;
},
error: (e) => {
this.error = cicError(e, 'Could not load the bureaus.');
this.loading = false;
}
});
}
toggle(b: CicBureau, event: Event): void {
const target = event.target as HTMLInputElement;
const want = target.checked;
target.checked = b.whitelisted; // reflect the server's answer, not the click
this.error = null;
this.info = null;
this.toggling = b.id;
this.cic.setWhitelisted(b.id, want).subscribe({
next: (u) => {
this.toggling = null;
this.replace(u);
this.info = `${u.code} is now ${u.whitelisted ? 'whitelisted — its enquiry alerts will be fetched' : 'not whitelisted'}.`;
},
error: (e) => {
this.toggling = null;
this.error = cicError(e, 'Could not change the whitelist.');
}
});
}
edit(b: CicBureau): void {
this.editing = b;
this.info = null;
this.error = null;
this.form = {
name: b.name,
description: b.description ?? '',
environment: b.environment,
baseUrl: b.baseUrl ?? '',
memberCode: b.memberCode ?? '',
authType: b.authType,
username: b.username ?? '',
apiKeyHeader: b.apiKeyHeader ?? 'X-API-KEY',
secret: '',
clearSecret: false,
tokenUrl: b.tokenUrl ?? '',
clientId: b.clientId ?? '',
timeoutMs: b.timeoutMs,
alertAutoFetch: b.alertAutoFetch,
alertFetchIntervalMin: b.alertFetchIntervalMin,
alertLookbackDays: b.alertLookbackDays,
isDefault: b.isDefault
};
}
save(): void {
if (!this.editing) return;
this.saving = true;
this.error = null;
const body = { ...this.form };
if (!body.secret) delete body.secret;
this.cic.updateBureau(this.editing.id, body).subscribe({
next: (u) => {
this.saving = false;
this.editing = null;
this.info = `${u.code} configuration saved.`;
this.load();
},
error: (e) => {
this.saving = false;
this.error = cicError(e, 'Could not save the configuration.');
}
});
}
test(b: CicBureau): void {
this.testing = b.id;
this.error = null;
this.info = null;
this.cic.testConnection(b.id).subscribe({
next: (r) => {
this.testing = null;
if (r.status === 'OK') this.info = `${b.code}: ${r.message}`;
else this.error = `${b.code}: ${r.message}`;
this.load();
},
error: (e) => {
this.testing = null;
this.error = cicError(e, 'Connection test failed.');
}
});
}
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';
}
secretLabel(auth: string): string {
return auth === 'BASIC'
? 'Password'
: auth === 'API_KEY'
? 'API key'
: auth === 'BEARER_TOKEN'
? 'Bearer token'
: 'Client secret';
}
envTone(env: string): string {
return env === 'PRODUCTION' ? 'good' : env === 'UAT' ? 'ok' : 'warn';
}
private replace(u: CicBureau): void {
this.bureaus = this.bureaus.map((b) => (b.id === u.id ? u : b));
}
}
@@ -44,8 +44,8 @@
}
@if (data && !whitelisted.length) {
<div class="cic-alert warn">
No CIC is whitelisted yet, so no alerts are being received. Whitelist a bureau from the AI / Intelligence Modules
menu → API Management → CIC Whitelist &amp; Configuration.
No CIC is whitelisted yet, so no alerts are being received. Switch a bureau on (CIBIL, Experian, Equifax or CRIF)
under AI / Intelligence Modules → API Management → API Vendors &amp; Setup.
</div>
}
@if (loading && !data) {
@@ -227,6 +227,13 @@
@if (b.lastFetchMessage) {
<div class="s" style="font-size: 11px">{{ b.lastFetchMessage }}</div>
}
<div class="s" style="font-size: 11px; margin-top: 4px">
@if (b.environment === 'SIMULATOR') {
Simulator: no call goes to {{ b.code }}; the alerts are made up by the CBS for testing.
} @else {
Calls {{ b.code }} {{ b.environment }} with the API set up under API Management.
}
</div>
@if (b.whitelisted) {
<button
class="cic-btn sm"
@@ -238,6 +245,7 @@
{{ fetching === b.id ? 'Fetching…' : 'Fetch now' }}
</button>
}
<a class="cic-btn sm" style="margin-top: 8px; margin-left: 4px" [routerLink]="setupRoute(b.code)">Set up</a>
</div>
}
</div>
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { ApiManagementService } from 'app/api-management/api-management.service';
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
import { CicAlertDetailComponent } from '../cic-alert-detail/cic-alert-detail.component';
import { CicService, cicError, cicStatusTone } from '../cic.service';
@@ -18,8 +19,8 @@ import { CicService, cicError, cicStatusTone } from '../cic.service';
/**
* Credit Bureau dashboard: our customers who applied for a loan at another bank / NBFC, as reported by the CICs -
* who, what loan and how much (the CIC does not name the lender) - with the customers applying elsewhere repeatedly,
* the loan types, the trend, and the fetch status of each bureau. The other pages (fetch log, CIC configuration, API
* mapping) are reached from the buttons at the top.
* the loan types, the trend, and the fetch status of each bureau. Each bureau is set up under API Management → API
* Vendors & Setup.
*/
@Component({
selector: 'mifosx-cic-dashboard',
@@ -36,6 +37,16 @@ import { CicService, cicError, cicStatusTone } from '../cic.service';
})
export class CicDashboardComponent implements OnInit {
private cic = inject(CicService);
private apis = inject(ApiManagementService);
/** Each bureau is set up as a vendor under API Management (connection, whitelist, request / response mapping). */
setupRoute(code: string): any[] {
const v = this.apis.vendorByCode(code);
return v ? [
'/api-management/vendors',
v.id
] : ['/api-management/vendors'];
}
days = 30;
loading = false;
+22 -2
View File
@@ -6,9 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable, delay, of, throwError } from 'rxjs';
import { ApiManagementService } from 'app/api-management/api-management.service';
import {
CicAlert,
CicBureau,
@@ -592,6 +593,7 @@ function reply<T>(value: T): Observable<T> {
@Injectable()
export class CicStaticService extends CicService {
private apis = inject(ApiManagementService);
private bureauList = buildBureaus();
private alertList = buildAlerts();
private endpoints = new Map<number, CicEndpoint>(
@@ -704,7 +706,23 @@ export class CicStaticService extends CicService {
};
}
/** Each bureau is set up as a vendor under API Management: active = whitelisted, plus its environment and member code. */
private syncFromApiManagement(): void {
for (const b of this.bureauList) {
const v = this.apis.vendorByCode(b.code);
if (!v) continue;
b.name = v.name;
b.whitelisted = v.active;
b.environment = v.environment;
b.baseUrl = v.baseUrl || undefined;
b.memberCode = v.senderId || undefined;
b.authType = v.authType;
b.timeoutMs = v.timeoutMs;
}
}
private refreshCounts(): void {
this.syncFromApiManagement();
const since = Date.now() - 30 * DAY;
for (const b of this.bureauList) {
b.alertsLast30Days = this.alertList.filter(
@@ -1036,10 +1054,11 @@ export class CicStaticService extends CicService {
// ── fetch / import ──
override fetch(bureauId: number, _body: { fromDate?: string; toDate?: string } = {}): Observable<CicFetchRun> {
this.syncFromApiManagement();
const b = this.bureauList.find((x) => x.id === bureauId);
if (!b) return fail('Credit bureau was not found.');
if (!b.whitelisted)
return fail(`${b.code} is not whitelisted for this bank. Whitelist it under CIC Configuration first.`);
return fail(`${b.code} is not whitelisted for this bank. Switch it on under API Management → Vendors first.`);
const now = new Date();
const r = rng(now.getTime() % 100000);
const created: CicAlert[] = [];
@@ -1175,6 +1194,7 @@ export class CicStaticService extends CicService {
// ── dashboard ──
override dashboard(days = 30): Observable<any> {
this.syncFromApiManagement();
const today = daysAgo(0);
const since = daysAgo(days - 1);
const matched = this.alertList.filter((a) => a.clientId);
@@ -12,8 +12,6 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
import { CicBureauConfigComponent } from './cic-bureau-config/cic-bureau-config.component';
import { CicApiMappingComponent } from './cic-api-mapping/cic-api-mapping.component';
const routes: Routes = [
Route.withShell([
@@ -36,16 +34,9 @@ const routes: Routes = [
component: CicFetchLogComponent,
data: { title: 'Fetch Log', breadcrumb: 'Fetch Log' }
},
{
path: 'bureaus',
component: CicBureauConfigComponent,
data: { title: 'CIC Whitelist & Configuration', breadcrumb: 'CIC Configuration' }
},
{
path: 'api-mapping',
component: CicApiMappingComponent,
data: { title: 'CIC API Mapping', breadcrumb: 'API Mapping' }
}
// The bureaus are set up, whitelisted and mapped under API Management like every other vendor.
{ path: 'bureaus', redirectTo: '/api-management/vendors', pathMatch: 'full' },
{ path: 'api-mapping', redirectTo: '/api-management/vendors', pathMatch: 'full' }
]
}
])
@@ -13,14 +13,12 @@ import { CreditBureauRoutingModule } from './credit-bureau-routing.module';
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
import { CicAlertDetailComponent } from './cic-alert-detail/cic-alert-detail.component';
import { 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 (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.
* other lenders and the fetch log. Each bureau's connection, whitelist and API mapping live under API Management.
*/
@NgModule({
imports: [
@@ -28,9 +26,7 @@ import { CicStaticService } from './cic-static.service';
CreditBureauRoutingModule,
CicDashboardComponent,
CicFetchLogComponent,
CicAlertDetailComponent,
CicBureauConfigComponent,
CicApiMappingComponent
CicAlertDetailComponent
],
providers: [
// Static demo data, no backend call. Remove this line to use the real /v1/cic API again.