Replace CIBIL fetch screens with a dashboard of our customers' loan enquiries at other lenders (no lender name), CIC whitelist/API mapping, and a Vendor -> Work -> API set management UI (IN/OUT, SMS, WhatsApp, CIC) with full HTTP detail and JSON/cURL/Postman/HAR import. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
490 lines
19 KiB
HTML
490 lines
19 KiB
HTML
<!--
|
|
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>
|