From 885b5897af5e5b323a6f1ed988925d25b7413f53 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 19 Oct 2026 15:50:02 +0530 Subject: [PATCH] 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 --- .../api-management/api-management.service.ts | 230 +++++--- .../api-vendor-setup.component.html | 30 ++ .../api-vendor-setup.component.ts | 54 ++ .../core/shell/toolbar/toolbar.component.html | 16 +- .../cic-api-mapping.component.html | 489 ------------------ .../cic-api-mapping.component.ts | 310 ----------- .../cic-bureau-config.component.html | 279 ---------- .../cic-bureau-config.component.ts | 198 ------- .../cic-dashboard.component.html | 12 +- .../cic-dashboard/cic-dashboard.component.ts | 15 +- src/app/credit-bureau/cic-static.service.ts | 24 +- .../credit-bureau-routing.module.ts | 15 +- src/app/credit-bureau/credit-bureau.module.ts | 8 +- 13 files changed, 283 insertions(+), 1397 deletions(-) delete mode 100644 src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.html delete mode 100644 src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.ts delete mode 100644 src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.html delete mode 100644 src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts diff --git a/src/app/api-management/api-management.service.ts b/src/app/api-management/api-management.service.ts index 84ff6f4c6..a0f710431 100644 --- a/src/app/api-management/api-management.service.ts +++ b/src/app/api-management/api-management.service.ts @@ -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 { return reply(this.vendorList); } diff --git a/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html index 83a17dd44..63cf70bac 100644 --- a/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html +++ b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.html @@ -515,6 +515,10 @@

{{ mappingTitle('request') }} + + · {{ matchedCount('request') }} of {{ editing.requestRows.length }} rows found in the sample + {{ 'request' === 'request' ? 'payload' : 'body' }} + @@ -527,11 +531,13 @@ {{ toLabel('request') }} Transform Fixed / default value + In the sample payload Required + @let requestMatch = sampleMatches('request'); @for (r of editing.requestRows; track $index; let i = $index) { @@ -555,6 +561,15 @@ + + @if (requestMatch[i].found) { + ✓ {{ requestMatch[i].value }} + } @else if (r.defaultValue) { + fixed: {{ r.defaultValue }} + } @else { + ✗ {{ requestMatch[i].value || 'no path' }} + } + @@ -606,6 +621,10 @@

{{ mappingTitle('response') }} + + · {{ matchedCount('response') }} of {{ editing.responseRows.length }} rows found in the sample + {{ 'response' === 'request' ? 'payload' : 'body' }} + @@ -618,11 +637,13 @@ {{ toLabel('response') }} Transform Fixed / default value + In the sample payload Required + @let responseMatch = sampleMatches('response'); @for (r of editing.responseRows; track $index; let i = $index) { @@ -646,6 +667,15 @@ + + @if (responseMatch[i].found) { + ✓ {{ responseMatch[i].value }} + } @else if (r.defaultValue) { + fixed: {{ r.defaultValue }} + } @else { + ✗ {{ responseMatch[i].value || 'no path' }} + } + diff --git a/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts index 3171a7521..d8ee5ce73 100644 --- a/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts +++ b/src/app/api-management/api-vendor-setup/api-vendor-setup.component.ts @@ -272,6 +272,60 @@ export class ApiVendorSetupComponent implements OnInit { return 'CBS field'; } + private parsedCache = new Map(); + + 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 }); } diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index fbb86e720..225d13e42 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -249,21 +249,7 @@ API Vendors & Setup - Vendor, work and API set — IN / OUT (CIC, SMS, WhatsApp …) - - - - - - - -
-
-
- - -
-
- - -
-
- @if (bureau) { - {{ bureau.environment }} - {{ - bureau.whitelisted ? 'Whitelisted' : 'Not whitelisted' - }} - @if (endpoint && !endpoint.exists) { - Not configured yet - } - @if (dirty) { - Unsaved changes - } - } -
-
-
- - @if (error) { -
{{ error }}
- } - @if (info) { -
{{ info }}
- } - @if (bureau?.environment === 'SIMULATOR') { -
- 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. -
- } - - @if (loading) { -
Loading…
- } - - @if (endpoint && !loading) { -
- - - - - -
- - - @if (tab === 'response') { -
-

- Bureau response → CBS alert - "alerts", "errorCode" and "errorMessage" are read from the whole response; every other field from each - alert -

-
- - - - - - - - - - - - - @for (r of responseRows; track $index; let i = $index) { - - - - - - - - - } - -
#Bureau response JSON pathCBS alert fieldTransformDefault
{{ i + 1 }} - - - -
- {{ isRoot(r.targetField) ? 'whole response' : 'each alert' }} -
-
- - - - - -
-
- - @for (f of resultFieldList; track f.key) { - - } - -
- - - 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. - -
-
- @for (f of resultFieldList; track f.key) { -
- {{ f.key }} — {{ f.label }} -
- } -
-
- } - - - @if (tab === 'request') { -
-

- CBS value → bureau request - Paths like criteria.fromDate build nested JSON; [0] builds arrays -

-
- - - - - - - - - - - - - - @for (r of requestRows; track $index; let i = $index) { - - - - - - - - - - } - -
#CBS valueBureau JSON pathTransformFixed / default valueMandatory
{{ i + 1 }} - - - - - - - - - - - - - -
-
-
- - A mandatory value that is empty stops the fetch before the bureau is called. -
-
- } - - - @if (tab === 'endpoint') { -
-

Endpoint

-
-
- - -
-
- - -
-
- - -
-
- -
-
- - - Placeholders like {{ '{{' }}bureau.memberCode{{ '}}' }} or - {{ '{{' }}fetch.reference{{ '}}' }} are filled in. Authentication headers are added - automatically. - @if (jsonErrors.headers) { - {{ jsonErrors.headers }} - } -
-
- - - Constant parts of the request; request mappings are written into it. - @if (jsonErrors.body) { - {{ jsonErrors.body }} - } -
-
-
- } - - - @if (tab === 'test') { -
-

- Test response - reads a bureau answer through the saved response mapping and matches customers — nothing is saved -

-
- - -
-
- - -
-
- @if (testResult) { - @if (testResult.errorCode) { -
Bureau error {{ testResult.errorCode }}: {{ testResult.errorMessage }}
- } - @if (!testResult.alertListFound) { -
- No alert list was found at the path mapped to "alerts". Check that row of the response mapping. -
- } -
-

{{ testResult.count }} alert(s) read

- @if (testResult.items?.length) { -
- - - - - - - - - - - - - - @for (it of testResult.items; track $index) { - - - - - - - - - - } - -
Alert idCustomer (bureau)PANDateLoan typeAmountMatch
{{ it.alertRef }}{{ it.customerName }}{{ it.pan || it.aadhaar || it.customerRef }}{{ it.enquiryDate }}{{ it.loanType }}{{ it.amount | number: '1.0-0' }} - @if (it.ownBank) { - Own bank — skipped - } @else if (it.clientId) { - {{ matchLabels[it.matchType] }} {{ it.clientName }} - } @else { - Not matched - } -
-
- } -
- } - } - - - @if (tab === 'preview') { -
-

Preview builds the request from the saved mapping — nothing is sent

-
-
- - -
-
- - -
-
- Empty = the bureau's look-back window up to today. -
-
- -
- - @if (previewResult) { - @if (previewResult.missingMandatory?.length) { -
- The fetch would be refused — mandatory values are empty: -
    - @for (m of previewResult.missingMandatory; track m) { -
  • {{ m }}
  • - } -
-
- } -
-
-

Request body

-
{{ prettyJson(previewResult.body) }}
-
-
-

Call

-
-
Method
-
{{ previewResult.method }}
-
URL
-
{{ previewResult.url }}
-
Window
-
{{ previewResult.fromDate }} → {{ previewResult.toDate }}
-
Auth
-
{{ previewResult.authentication }}
- @for (h of headerEntries(previewResult.headers); track h.k) { -
{{ h.k }}
-
{{ h.v }}
- } -
-
-
- } - } - } - diff --git a/src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.ts b/src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.ts deleted file mode 100644 index 6ee1a3ef3..000000000 --- a/src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.ts +++ /dev/null @@ -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 | null | undefined): { k: string; v: string }[] { - return Object.entries(h ?? {}).map( - ([ - k, - v - ]) => ({ k, v }) - ); - } -} diff --git a/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.html b/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.html deleted file mode 100644 index 7a44b3d73..000000000 --- a/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.html +++ /dev/null @@ -1,279 +0,0 @@ - - -
-
-
-

CIC Whitelist & Configuration

-

- Choose which Credit Information Companies this bank receives enquiry alerts from, and how each is reached. Only - whitelisted bureaus are fetched. -

-
- -
- - @if (error) { -
{{ error }}
- } - @if (info) { -
{{ info }}
- } - - @if (loading && !bureaus.length) { -
Loading…
- } - -
- @for (b of bureaus; track b.id) { -
-
-
-
- {{ b.code }} - {{ b.environment }} - @if (b.isDefault) { - Default - } -
-
{{ b.name }}
-
- -
- -
-
Base URL
-
{{ b.environment === 'SIMULATOR' ? '(simulator)' : b.baseUrl || '— not set —' }}
-
Member code
-
{{ b.memberCode || '—' }}
-
Authentication
-
- {{ authLabels[b.authType] || b.authType }} - @if (b.authType !== 'NONE') { - {{ - b.hasSecret ? 'credential stored' : 'no credential' - }} - } -
-
Endpoints
-
{{ b.activeEndpoints }} active
-
Alert fetch
-
- {{ b.alertAutoFetch ? 'Automatic, every ' + b.alertFetchIntervalMin + ' min' : 'Manual only' }} - · looks back {{ b.alertLookbackDays }} days -
-
Last fetch
-
- @if (b.lastFetchOn) { - {{ b.lastFetchStatus }} - {{ b.lastFetchOn | slice: 0 : 16 }} -
{{ b.lastFetchMessage }}
- } @else { - never - } -
-
Last test
-
- @if (b.lastTestStatus) { - {{ b.lastTestStatus }} - {{ b.lastTestedOn | slice: 0 : 16 }} -
{{ b.lastTestMessage }}
- } @else { - never - } -
-
Alerts (30d)
-
- {{ b.alertsLast30Days }} - @if (b.newAlerts) { - {{ b.newAlerts }} new - } -
-
- -
- - - @if (b.whitelisted) { - - } - API Mapping -
-
- } -
- -
- Going live with a bureau: 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. -
- - @if (editing) { -
-
-

Configure {{ editing.code }}

-
-
- - -
-
- - -
-
- - - Endpoint paths from API Mapping are appended to this. PRODUCTION requires https://. -
-
- - -
-
- - -
- @if (form.authType === 'BASIC') { -
- - -
- } - @if (form.authType === 'API_KEY') { -
- - -
- } - @if (form.authType === 'OAUTH2_CLIENT_CREDENTIALS') { -
- - -
-
- - -
- } - @if (needsSecret(form.authType)) { -
- - - @if (editing.hasSecret) { - - } -
- } -
- - -
-
- - - -
-
- - - Each fetch asks for alerts of this many past days; ones already received are skipped. -
-
- - -
- -
-
- - -
-
-
- } -
diff --git a/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts b/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts deleted file mode 100644 index 3fba2fde6..000000000 --- a/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts +++ /dev/null @@ -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 = { - 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)); - } -} diff --git a/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html b/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html index 58c8202f1..574a10dea 100644 --- a/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html +++ b/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html @@ -44,8 +44,8 @@ } @if (data && !whitelisted.length) {
- 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.
} @if (loading && !data) { @@ -227,6 +227,13 @@ @if (b.lastFetchMessage) {
{{ b.lastFetchMessage }}
} +
+ @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. + } +
@if (b.whitelisted) {