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:
@@ -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 & 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 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 & 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 & 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 & 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 & 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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user