Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b70fb4534 |
@@ -73,6 +73,7 @@ import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||
import { CreditBureauModule } from './credit-bureau/credit-bureau.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
@@ -189,6 +190,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
CreditBureauModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
@@ -407,6 +407,10 @@
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="creditBureauMenu" *mifosxHasPermission="'MENU_CREDIT_BUREAU'">
|
||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Credit Bureau
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -508,6 +512,11 @@
|
||||
><span class="ni-short">Loans</span>
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="creditBureauMenu" *mifosxHasPermission="'MENU_CREDIT_BUREAU'">
|
||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon><span class="ni-full">Credit Bureau</span
|
||||
><span class="ni-short">CIC</span>
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
@@ -1662,6 +1671,42 @@
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #creditBureauMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
|
||||
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
|
||||
<!-- Enquiry -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Bureau Enquiry</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/dashboard">
|
||||
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Dashboard
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
tabindex="0"
|
||||
routerLink="/credit-bureau/new-enquiry"
|
||||
*mifosxHasPermission="'CREATE_CICENQUIRY'"
|
||||
>
|
||||
<fa-icon icon="search" class="menu-item-icon"></fa-icon> New Enquiry (CIBIL / CIC)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/enquiries">
|
||||
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Enquiry History & Reports
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/consents">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Consent Register
|
||||
</button>
|
||||
</div>
|
||||
<!-- Setup -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">CIC Setup</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/bureaus">
|
||||
<fa-icon icon="shield-alt" class="menu-item-icon"></fa-icon> CIC Whitelist & Configuration
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/credit-bureau/api-mapping">
|
||||
<fa-icon icon="project-diagram" class="menu-item-icon"></fa-icon> API Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
|
||||
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
|
||||
<!-- KYC Operations -->
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<!--
|
||||
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</h1>
|
||||
<p>
|
||||
Map CBS data to each bureau's request, and the bureau's response back to the score and report — no code change
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<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>Enquiry type</label>
|
||||
<select [(ngModel)]="endpointType" (ngModelChange)="load()">
|
||||
@for (t of catalog?.endpointTypes ?? []; track t) {
|
||||
<option [value]="t">{{ t === 'CONSUMER_ENQUIRY' ? 'Consumer enquiry' : 'Commercial enquiry' }}</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 match the bureau's own API specification.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="cic-card cic-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (endpoint && !loading) {
|
||||
<div class="cic-tabs">
|
||||
<button [class.active]="tab === 'request'" (click)="tab = 'request'">
|
||||
Request mapping ({{ requestRows.length }})
|
||||
</button>
|
||||
<button [class.active]="tab === 'response'" (click)="tab = 'response'">
|
||||
Response mapping ({{ responseRows.length }})
|
||||
</button>
|
||||
<button [class.active]="tab === 'endpoint'" (click)="tab = 'endpoint'">Endpoint, headers & body</button>
|
||||
<button [class.active]="tab === 'preview'" (click)="tab = 'preview'">Preview (dry run)</button>
|
||||
</div>
|
||||
|
||||
<!-- REQUEST -->
|
||||
@if (tab === 'request') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
CBS field → bureau request
|
||||
<span class="cic-card-sub">Paths like <code>consumer.ids[0].number</code> build nested JSON and arrays</span>
|
||||
</h2>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 34px">#</th>
|
||||
<th style="min-width: 200px">CBS field</th>
|
||||
<th style="min-width: 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="consumer.name.firstName"
|
||||
/>
|
||||
</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 field missing on the customer stops the enquiry before the bureau is called.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- RESPONSE -->
|
||||
@if (tab === 'response') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Bureau response → CBS result
|
||||
<span class="cic-card-sub"
|
||||
>"score" drives the score shown everywhere; "hit" = false marks a no-hit; "errorCode" marks a failure</span
|
||||
>
|
||||
</h2>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 34px">#</th>
|
||||
<th style="min-width: 240px">Bureau response JSON path</th>
|
||||
<th style="min-width: 220px">CBS result field</th>
|
||||
<th style="min-width: 150px">Transform</th>
|
||||
<th style="min-width: 110px">Default</th>
|
||||
<th style="width: 110px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of responseRows; track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="muted">{{ i + 1 }}</td>
|
||||
<td>
|
||||
<input
|
||||
class="cic-mono"
|
||||
[(ngModel)]="r.sourceField"
|
||||
(ngModelChange)="markDirty()"
|
||||
placeholder="report.score.value"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
class="cic-mono"
|
||||
[(ngModel)]="r.targetField"
|
||||
(ngModelChange)="markDirty()"
|
||||
list="cic-result-fields"
|
||||
placeholder="score"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select [(ngModel)]="r.transform" (ngModelChange)="markDirty()">
|
||||
@for (t of transformList; track t.key) {
|
||||
<option [value]="t.key">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input [(ngModel)]="r.defaultValue" (ngModelChange)="markDirty()" /></td>
|
||||
<td style="white-space: nowrap">
|
||||
<button class="cic-btn sm" (click)="move('response', i, -1)" [disabled]="i === 0">↑</button>
|
||||
<button
|
||||
class="cic-btn sm"
|
||||
(click)="move('response', i, 1)"
|
||||
[disabled]="i === responseRows.length - 1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<datalist id="cic-result-fields">
|
||||
@for (f of resultFieldList; track f.key) {
|
||||
<option [value]="f.key">{{ f.label }}</option>
|
||||
}
|
||||
</datalist>
|
||||
<div class="cic-actions" style="margin-top: 12px">
|
||||
<button class="cic-btn" (click)="addRow('response')">+ Add response field</button>
|
||||
<span class="muted" style="font-size: 12px"
|
||||
>Standard result fields: {{ resultFieldList.length }}; any other simple name is kept as an extra
|
||||
field.</span
|
||||
>
|
||||
</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="/consumer/enquiry"
|
||||
/>
|
||||
</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>{{ '{{' }}enquiry.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>
|
||||
}
|
||||
|
||||
<!-- PREVIEW -->
|
||||
@if (tab === 'preview') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Preview
|
||||
<span class="cic-card-sub"
|
||||
>builds the request from the saved mapping for a real customer — nothing is sent</span
|
||||
>
|
||||
</h2>
|
||||
<div class="cic-grid cols-4" style="align-items: end">
|
||||
<div class="cic-field" style="grid-column: span 2">
|
||||
<label>Customer</label>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<input
|
||||
[value]="previewClient ? previewClient.name + ' (#' + previewClient.id + ')' : ''"
|
||||
placeholder="Search a customer"
|
||||
readonly
|
||||
/>
|
||||
<button class="cic-btn sm" (click)="pickPreviewClient()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Amount (₹)</label>
|
||||
<input type="number" [(ngModel)]="previewAmount" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Purpose</label>
|
||||
<select [(ngModel)]="previewPurpose">
|
||||
@for (p of purposes; track p.code) {
|
||||
<option [value]="p.code">{{ p.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="cic-btn primary"
|
||||
style="margin-top: 12px"
|
||||
(click)="runPreview()"
|
||||
[disabled]="previewing || !previewClient"
|
||||
>
|
||||
{{ previewing ? 'Building…' : 'Build preview' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (previewResult) {
|
||||
@if (previewResult.missingMandatory?.length) {
|
||||
<div class="cic-alert error">
|
||||
This customer would be refused — missing mandatory fields:
|
||||
<ul>
|
||||
@for (m of previewResult.missingMandatory; track m) {
|
||||
<li>{{ m }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-alert success">All mandatory fields are present for this customer.</div>
|
||||
}
|
||||
<div class="cic-split">
|
||||
<div class="cic-card">
|
||||
<h2>Request body <span class="cic-card-sub">sensitive values masked</span></h2>
|
||||
<pre class="cic-code">{{ prettyBody(previewResult.body) }}</pre>
|
||||
</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>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>
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
|
||||
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
|
||||
import {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { CicBureau, CicCatalog, CicEndpoint, CicMapping, CicService, cicError } from '../cic.service';
|
||||
|
||||
/**
|
||||
* The per-bureau API mapping, maintained from the UI: endpoint (method, path, headers, base body), REQUEST rows that
|
||||
* put a CBS field into the bureau's request at a JSON path, and RESPONSE rows that read the bureau's answer into the
|
||||
* CBS result (score, accounts, overdue ...). A dry-run preview shows the exact request for a real customer.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cic-api-mapping',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
],
|
||||
templateUrl: './cic-api-mapping.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicApiMappingComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private dialog = inject(MatDialog);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
catalog: CicCatalog | null = null;
|
||||
bureaus: CicBureau[] = [];
|
||||
bureauId: number | null = null;
|
||||
endpointType = 'CONSUMER_ENQUIRY';
|
||||
|
||||
endpoint: CicEndpoint | null = null;
|
||||
requestRows: CicMapping[] = [];
|
||||
responseRows: CicMapping[] = [];
|
||||
tab: 'request' | 'response' | 'endpoint' | 'preview' = 'request';
|
||||
|
||||
loading = false;
|
||||
saving = false;
|
||||
dirty = false;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
jsonErrors: { headers?: string; body?: string } = {};
|
||||
|
||||
// preview
|
||||
previewClient: { id: number; name: string } | null = null;
|
||||
previewAmount = 100000;
|
||||
previewPurpose = 'NEW_LOAN';
|
||||
previewResult: any = null;
|
||||
previewing = false;
|
||||
|
||||
cbsFieldList: { key: string; label: string }[] = [];
|
||||
resultFieldList: { key: string; label: string }[] = [];
|
||||
transformList: { key: string; label: string }[] = [];
|
||||
purposes: { code: string; label: string }[] = [];
|
||||
|
||||
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 })
|
||||
);
|
||||
this.purposes = Object.entries(c.purposes).map(
|
||||
([
|
||||
code,
|
||||
label
|
||||
]) => ({ code, 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);
|
||||
}
|
||||
|
||||
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.cic.endpoint(this.bureauId, this.endpointType).subscribe({
|
||||
next: (e) => {
|
||||
this.endpoint = { ...e, headersJson: this.prettyJson(e.headersJson), baseBody: this.prettyJson(e.baseBody) };
|
||||
this.requestRows = e.requestMappings.map((m) => ({ ...m }));
|
||||
this.responseRows = e.responseMappings.map((m) => ({ ...m }));
|
||||
this.loading = false;
|
||||
this.dirty = false;
|
||||
this.jsonErrors = {};
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.error = cicError(err, 'Could not load the mapping.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.dirty = false;
|
||||
this.endpoint = { ...e, headersJson: this.prettyJson(e.headersJson), baseBody: this.prettyJson(e.baseBody) };
|
||||
this.requestRows = e.requestMappings.map((m) => ({ ...m }));
|
||||
this.responseRows = e.responseMappings.map((m) => ({ ...m }));
|
||||
this.info = `${this.bureau?.code} ${this.endpointType} mapping saved (${this.requestRows.length} request, ${this.responseRows.length} response fields).`;
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.error = cicError(err, 'Could not save the mapping.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pickPreviewClient(): void {
|
||||
this.dialog
|
||||
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
||||
.afterClosed()
|
||||
.subscribe((r: CustomerSearchResult | undefined) => {
|
||||
if (r?.clientId) this.previewClient = { id: r.clientId, name: r.displayName };
|
||||
});
|
||||
}
|
||||
|
||||
runPreview(): void {
|
||||
if (!this.bureauId || !this.previewClient) return;
|
||||
if (this.dirty) {
|
||||
this.error = 'Save the mapping first — the preview uses the saved mapping.';
|
||||
return;
|
||||
}
|
||||
this.previewing = true;
|
||||
this.error = null;
|
||||
this.cic
|
||||
.preview(this.bureauId, {
|
||||
clientId: this.previewClient.id,
|
||||
enquiryType: this.endpointType,
|
||||
purpose: this.previewPurpose,
|
||||
loanAmount: this.previewAmount
|
||||
})
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.previewing = false;
|
||||
this.previewResult = r;
|
||||
},
|
||||
error: (e) => {
|
||||
this.previewing = false;
|
||||
this.error = cicError(e, 'Preview failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prettyJson(text?: string | null): string {
|
||||
if (!text) return '{}';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
prettyBody(text?: string | null): string {
|
||||
return this.prettyJson(text);
|
||||
}
|
||||
|
||||
headerEntries(h: Record<string, string> | null | undefined): { k: string; v: string }[] {
|
||||
return Object.entries(h ?? {}).map(
|
||||
([
|
||||
k,
|
||||
v
|
||||
]) => ({ k, v })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<!--
|
||||
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 may enquire, and how each is reached. Only whitelisted
|
||||
bureaus appear on the enquiry screen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<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>Report reuse</dt>
|
||||
<dd>{{ b.reportValidityDays ? 'within ' + b.reportValidityDays + ' days' : 'always pull fresh' }}</dd>
|
||||
<dt>Charge / enquiry</dt>
|
||||
<dd>₹{{ b.enquiryCharge | number: '1.0-2' }}</dd>
|
||||
<dt>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>Enquiries (30d)</dt>
|
||||
<dd>{{ b.enquiriesLast30Days }}</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>
|
||||
<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) get the bank's member code, API URL and credentials from the bureau; 2)
|
||||
Configure them here (environment UAT first); 3) adapt the request / response fields under API Mapping to the
|
||||
bureau's specification and use Preview; 4) Test Connection and run a test enquiry; 5) switch to PRODUCTION and
|
||||
whitelist it.
|
||||
</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>Reuse a report for (days)</label>
|
||||
<input type="number" min="0" max="365" [(ngModel)]="form.reportValidityDays" />
|
||||
<span class="hint">0 = always pull a fresh report. Reuse avoids a second hard enquiry and its charge.</span>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Charge per enquiry (₹)</label>
|
||||
<input type="number" min="0" [(ngModel)]="form.enquiryCharge" />
|
||||
</div>
|
||||
<div class="cic-field" style="grid-column: 1 / -1">
|
||||
<label>Description</label>
|
||||
<input [(ngModel)]="form.description" />
|
||||
</div>
|
||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px">
|
||||
<input type="checkbox" [(ngModel)]="form.isDefault" /> Default bureau on the enquiry screen
|
||||
</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>
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 may enquire (whitelist), and how each is reached: environment, URL, member code,
|
||||
* authentication and credentials (stored encrypted, never shown back), timeouts, report reuse window and charge.
|
||||
*/
|
||||
@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;
|
||||
|
||||
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 — enquiries can be run against it' : '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,
|
||||
reportValidityDays: b.reportValidityDays,
|
||||
enquiryCharge: b.enquiryCharge,
|
||||
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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>Consent Register</h1>
|
||||
<p>Borrowers' consents to pull their credit report. No enquiry runs without an active, unexpired consent.</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn primary" (click)="startCapture()" *mifosxHasPermission="'CREATE_CICCONSENT'">
|
||||
Record Consent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
@if (info) {
|
||||
<div class="cic-alert success">{{ info }}</div>
|
||||
}
|
||||
|
||||
@if (capturing) {
|
||||
<div class="cic-card">
|
||||
<h2>Record a consent <button class="cic-btn sm" (click)="capturing = false">Close</button></h2>
|
||||
<div class="cic-grid cols-3">
|
||||
<div class="cic-field">
|
||||
<label>Customer *</label>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<input
|
||||
[value]="formClient ? formClient.name + ' (#' + formClient.id + ')' : ''"
|
||||
placeholder="Search a customer"
|
||||
readonly
|
||||
/>
|
||||
<button class="cic-btn sm" (click)="pickClient()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Applies to</label>
|
||||
<select [(ngModel)]="form.bureauId">
|
||||
<option value="">Any credit bureau</option>
|
||||
@for (b of bureaus; track b.id) {
|
||||
<option [value]="b.id">Only {{ b.code }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Purpose</label>
|
||||
<select [(ngModel)]="form.purpose">
|
||||
@for (p of purposes; track p.code) {
|
||||
<option [value]="p.code">{{ p.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Consent mode</label>
|
||||
<select [(ngModel)]="form.consentMode">
|
||||
<option value="SIGNED_FORM">Signed consent form</option>
|
||||
<option value="OTP">OTP verified</option>
|
||||
<option value="DIGITAL_ACCEPTANCE">Digital acceptance</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (form.consentMode === 'OTP') {
|
||||
<div class="cic-field">
|
||||
<label>OTP / transaction reference *</label>
|
||||
<input [(ngModel)]="form.otpReference" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-field">
|
||||
<label>Form / document reference {{ form.consentMode === 'SIGNED_FORM' ? '*' : '' }}</label>
|
||||
<input [(ngModel)]="form.documentRef" />
|
||||
</div>
|
||||
}
|
||||
<div class="cic-field">
|
||||
<label>Valid for (days)</label>
|
||||
<input type="number" min="1" max="365" [(ngModel)]="form.validityDays" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Loan application no. (optional)</label>
|
||||
<input [(ngModel)]="form.applicationNo" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-field" style="margin-top: 12px">
|
||||
<label>Consent declaration</label>
|
||||
<textarea rows="4" [(ngModel)]="form.consentText"></textarea>
|
||||
</div>
|
||||
<label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; margin: 12px 0">
|
||||
<input type="checkbox" [(ngModel)]="form.customerDeclarationAccepted" style="margin-top: 3px" />
|
||||
<span>The customer has read the declaration above and given consent in the selected mode.</span>
|
||||
</label>
|
||||
<button
|
||||
class="cic-btn primary"
|
||||
(click)="save()"
|
||||
[disabled]="saving || !formClient || !form.customerDeclarationAccepted"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Record Consent' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cic-card">
|
||||
<div class="cic-grid cols-3" style="align-items: end; margin-bottom: 14px">
|
||||
<div class="cic-field">
|
||||
<label>Search</label>
|
||||
<input [(ngModel)]="filter.search" placeholder="Customer, consent ref or application" (keyup.enter)="apply()" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="filter.status" (ngModelChange)="apply()">
|
||||
<option value="">All</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="REVOKED">Revoked</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn" (click)="apply()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cic-empty">Loading…</div>
|
||||
} @else if (!items.length) {
|
||||
<div class="cic-empty">No consent recorded yet.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Consent ref</th>
|
||||
<th>Customer</th>
|
||||
<th>Bureau</th>
|
||||
<th>Purpose</th>
|
||||
<th>Mode</th>
|
||||
<th>Valid</th>
|
||||
<th>Status</th>
|
||||
<th>Captured by</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of items; track c.id) {
|
||||
<tr>
|
||||
<td class="cic-mono">{{ c.consentRef }}</td>
|
||||
<td>
|
||||
{{ c.clientName }} <span class="muted">#{{ c.clientId }}</span>
|
||||
@if (c.applicationNo) {
|
||||
<div class="muted" style="font-size: 11px">App {{ c.applicationNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ c.bureauCode }}</td>
|
||||
<td>{{ c.purpose }}</td>
|
||||
<td>{{ c.consentMode }}</td>
|
||||
<td class="muted">{{ c.validFrom }} → {{ c.validUntil }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class]="'cic-badge ' + tone(c.status)">{{ c.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ c.capturedByName }}
|
||||
<div class="muted" style="font-size: 11px">{{ c.capturedOn | slice: 0 : 16 }}</div>
|
||||
</td>
|
||||
<td style="white-space: nowrap">
|
||||
<button class="cic-btn sm" (click)="viewing = c">View</button>
|
||||
@if (c.status === 'ACTIVE') {
|
||||
<button
|
||||
class="cic-btn sm danger"
|
||||
(click)="revoking = c; revokeReason = ''"
|
||||
*mifosxHasPermission="'REVOKE_CICCONSENT'"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cic-pager">
|
||||
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (revoking) {
|
||||
<div class="cic-modal-backdrop" (click)="revoking = null">
|
||||
<div class="cic-modal" (click)="$event.stopPropagation()">
|
||||
<h3>Revoke consent {{ revoking.consentRef }}</h3>
|
||||
<p class="muted" style="font-size: 13px">
|
||||
{{ revoking.clientName }} — no further enquiry can run under this consent. Enquiries already made are not
|
||||
affected.
|
||||
</p>
|
||||
<div class="cic-field">
|
||||
<label>Reason *</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
[(ngModel)]="revokeReason"
|
||||
placeholder="e.g. customer withdrew consent in writing"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="cic-btn" (click)="revoking = null">Cancel</button>
|
||||
<button class="cic-btn danger" (click)="confirmRevoke()" [disabled]="!revokeReason.trim()">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (viewing) {
|
||||
<div class="cic-modal-backdrop" (click)="viewing = null">
|
||||
<div class="cic-modal" (click)="$event.stopPropagation()">
|
||||
<h3>Consent {{ viewing.consentRef }}</h3>
|
||||
<dl class="cic-kv">
|
||||
<dt>Customer</dt>
|
||||
<dd>{{ viewing.clientName }} (#{{ viewing.clientId }})</dd>
|
||||
<dt>Bureau</dt>
|
||||
<dd>{{ viewing.bureauCode }}</dd>
|
||||
<dt>Purpose / mode</dt>
|
||||
<dd>{{ viewing.purpose }} · {{ viewing.consentMode }}</dd>
|
||||
<dt>Document / OTP ref</dt>
|
||||
<dd>{{ viewing.documentRef || viewing.otpReference || '—' }}</dd>
|
||||
<dt>Valid</dt>
|
||||
<dd>{{ viewing.validFrom }} → {{ viewing.validUntil }}</dd>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ viewing.status }}{{ viewing.revokedReason ? ' — ' + viewing.revokedReason : '' }}</dd>
|
||||
<dt>Captured</dt>
|
||||
<dd>
|
||||
{{ viewing.capturedOn | slice: 0 : 19 }} by {{ viewing.capturedByName }} ({{
|
||||
viewing.ipAddress || 'IP n/a'
|
||||
}})
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="cic-field" style="margin-top: 12px">
|
||||
<label>Declaration</label>
|
||||
<div style="font-size: 13px; background: #f8fafc; border-radius: 8px; padding: 10px">
|
||||
{{ viewing.consentText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><button class="cic-btn" (click)="viewing = null">Close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
|
||||
import {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { CicBureau, CicCatalog, CicConsent, CicService, cicError } from '../cic.service';
|
||||
|
||||
/** Register of borrower consents to a credit bureau pull: capture, view, revoke. */
|
||||
@Component({
|
||||
selector: 'mifosx-cic-consents',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
],
|
||||
templateUrl: './cic-consents.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicConsentsComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
catalog: CicCatalog | null = null;
|
||||
bureaus: CicBureau[] = [];
|
||||
purposes: { code: string; label: string }[] = [];
|
||||
|
||||
items: CicConsent[] = [];
|
||||
total = 0;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
|
||||
filter = { search: '', status: '' };
|
||||
offset = 0;
|
||||
readonly limit = 20;
|
||||
|
||||
// capture
|
||||
capturing = false;
|
||||
saving = false;
|
||||
form = this.blankForm();
|
||||
formClient: { id: number; name: string } | null = null;
|
||||
|
||||
// revoke / view
|
||||
revoking: CicConsent | null = null;
|
||||
revokeReason = '';
|
||||
viewing: CicConsent | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cic.catalog().subscribe({
|
||||
next: (c) => {
|
||||
this.catalog = c;
|
||||
this.purposes = Object.entries(c.purposes).map(
|
||||
([
|
||||
code,
|
||||
label
|
||||
]) => ({ code, label })
|
||||
);
|
||||
this.form.consentText = c.defaultConsentText;
|
||||
},
|
||||
error: (e) => (this.error = cicError(e))
|
||||
});
|
||||
this.cic.bureaus().subscribe({ next: (b) => (this.bureaus = b), error: () => (this.bureaus = []) });
|
||||
this.load();
|
||||
}
|
||||
|
||||
private blankForm() {
|
||||
return {
|
||||
bureauId: '' as any,
|
||||
applicationNo: '',
|
||||
purpose: 'NEW_LOAN',
|
||||
consentMode: 'SIGNED_FORM',
|
||||
documentRef: '',
|
||||
otpReference: '',
|
||||
validityDays: 30,
|
||||
consentText: this.catalog?.defaultConsentText ?? '',
|
||||
customerDeclarationAccepted: false
|
||||
};
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.cic.consents({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe({
|
||||
next: (p) => {
|
||||
this.items = p.pageItems;
|
||||
this.total = p.totalFilteredRecords;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = cicError(e, 'Could not load consents.');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
this.offset = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
page(delta: number): void {
|
||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
||||
this.load();
|
||||
}
|
||||
|
||||
tone(status: string): string {
|
||||
return status === 'ACTIVE' ? 'good' : status === 'EXPIRED' ? 'warn' : 'bad';
|
||||
}
|
||||
|
||||
startCapture(): void {
|
||||
this.capturing = true;
|
||||
this.form = this.blankForm();
|
||||
this.formClient = null;
|
||||
this.info = null;
|
||||
}
|
||||
|
||||
pickClient(): void {
|
||||
this.dialog
|
||||
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
||||
.afterClosed()
|
||||
.subscribe((r: CustomerSearchResult | undefined) => {
|
||||
if (r?.clientId) this.formClient = { id: r.clientId, name: r.displayName };
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.formClient) return;
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.cic
|
||||
.createConsent({
|
||||
clientId: this.formClient.id,
|
||||
bureauId: this.form.bureauId ? Number(this.form.bureauId) : null,
|
||||
applicationNo: this.form.applicationNo || null,
|
||||
purpose: this.form.purpose,
|
||||
consentMode: this.form.consentMode,
|
||||
documentRef: this.form.documentRef || null,
|
||||
otpReference: this.form.otpReference || null,
|
||||
validityDays: this.form.validityDays,
|
||||
consentText: this.form.consentText,
|
||||
customerDeclarationAccepted: this.form.customerDeclarationAccepted
|
||||
})
|
||||
.subscribe({
|
||||
next: (c) => {
|
||||
this.saving = false;
|
||||
this.capturing = false;
|
||||
this.info = `Consent ${c.consentRef} recorded for ${c.clientName}, valid until ${c.validUntil}.`;
|
||||
this.apply();
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false;
|
||||
this.error = cicError(e, 'Could not record the consent.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmRevoke(): void {
|
||||
if (!this.revoking) return;
|
||||
const target = this.revoking;
|
||||
this.cic.revokeConsent(target.id, this.revokeReason).subscribe({
|
||||
next: () => {
|
||||
this.info = `Consent ${target.consentRef} revoked.`;
|
||||
this.revoking = null;
|
||||
this.revokeReason = '';
|
||||
this.load();
|
||||
},
|
||||
error: (e) => (this.error = cicError(e, 'Could not revoke the consent.'))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<!--
|
||||
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>Credit Bureau Dashboard</h1>
|
||||
<p>
|
||||
Enquiries to the Credit Information Companies (CIBIL, Experian, Equifax, CRIF High Mark) over the last 30 days.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn" (click)="load()" [disabled]="loading">Refresh</button>
|
||||
<a class="cic-btn primary" routerLink="/credit-bureau/new-enquiry">New Enquiry</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
@if (loading && !data) {
|
||||
<div class="cic-card cic-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (data) {
|
||||
@if (whitelistedCount === 0) {
|
||||
<div class="cic-alert warn">
|
||||
No credit bureau is whitelisted yet, so no enquiry can be run.
|
||||
<a routerLink="/credit-bureau/bureaus">Open CIC Whitelist & Configuration</a> to enable one.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
||||
<div class="cic-stat">
|
||||
<div class="k">Enquiries today</div>
|
||||
<div class="v">{{ data.enquiriesToday }}</div>
|
||||
<div class="s">{{ data.enquiriesLast30Days }} in the last 30 days</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Success rate</div>
|
||||
<div class="v">{{ successRate }}%</div>
|
||||
<div class="s">{{ statusCount('FAILED') }} failed · {{ statusCount('NO_HIT') }} no-hit</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Average score</div>
|
||||
<div class="v" [class]="'tone-' + scoreBand(data.averageScoreLast30Days).tone">
|
||||
{{ data.averageScoreLast30Days ? (data.averageScoreLast30Days | number: '1.0-0') : '—' }}
|
||||
</div>
|
||||
<div class="s">{{ scoreBand(data.averageScoreLast30Days).label }}</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Active consents</div>
|
||||
<div class="v">{{ data.activeConsents }}</div>
|
||||
<div class="s">
|
||||
Charges ₹{{ data.chargesLast30Days | number: '1.0-2' }} · {{ statusCount('REUSED') }} reports reused
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-split">
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Bureaus <span class="cic-card-sub">{{ whitelistedCount }} of {{ data.bureaus.length }} whitelisted</span>
|
||||
</h2>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bureau</th>
|
||||
<th>Whitelisted</th>
|
||||
<th>Environment</th>
|
||||
<th class="num">Enquiries (30d)</th>
|
||||
<th class="num">Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of data.bureaus; track b.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ b.code }}</b>
|
||||
@if (b.isDefault) {
|
||||
<span class="cic-badge brand">Default</span>
|
||||
}
|
||||
<div class="muted" style="font-size: 12px">{{ b.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class.good]="b.whitelisted">{{ b.whitelisted ? 'Yes' : 'No' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cic-badge"
|
||||
[class.warn]="b.environment === 'SIMULATOR'"
|
||||
[class.ok]="b.environment === 'UAT'"
|
||||
[class.good]="b.environment === 'PRODUCTION'"
|
||||
>{{ b.environment }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="num">{{ b.enquiries }}</td>
|
||||
<td class="num" [class.tone-bad]="b.failed > 0">{{ b.failed }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>Score spread <span class="cic-card-sub">successful enquiries, 30 days</span></h2>
|
||||
@for (band of data.scoreBandsLast30Days; track band.label) {
|
||||
<div style="margin-bottom: 10px">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px">
|
||||
<span>{{ band.label }}</span
|
||||
><b>{{ band.count }}</b>
|
||||
</div>
|
||||
<div class="cic-bar"><div [style.width.%]="(band.count / maxBand) * 100"></div></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Recent enquiries
|
||||
<a class="cic-btn sm" routerLink="/credit-bureau/enquiries">View all</a>
|
||||
</h2>
|
||||
@if (!data.recent?.length) {
|
||||
<div class="cic-empty">No enquiry yet.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Customer</th>
|
||||
<th>Bureau</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Score</th>
|
||||
<th>By</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of data.recent; track e.id) {
|
||||
<tr class="clickable" (click)="open(e.id)">
|
||||
<td class="cic-mono">{{ e.enquiryRef }}</td>
|
||||
<td>{{ e.clientName }}</td>
|
||||
<td>{{ e.bureauCode }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(e.status)">{{ e.status }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<b [class]="'tone-' + scoreBand(e.score).tone">{{ e.score ?? '—' }}</b>
|
||||
</td>
|
||||
<td>{{ e.requestedByName }}</td>
|
||||
<td class="muted">{{ e.requestedOn | slice: 0 : 16 }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 { Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { CicService, cicError, cicScoreBand } from '../cic.service';
|
||||
|
||||
/** Credit Bureau overview: volumes, outcomes, score spread and the state of each bureau. */
|
||||
@Component({
|
||||
selector: 'mifosx-cic-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule
|
||||
],
|
||||
templateUrl: './cic-dashboard.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicDashboardComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
data: any = null;
|
||||
|
||||
readonly scoreBand = cicScoreBand;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.cic.dashboard().subscribe({
|
||||
next: (d) => {
|
||||
this.data = d;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = cicError(e, 'Could not load the credit bureau dashboard.');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
statusCount(status: string): number {
|
||||
return Number(this.data?.byStatusLast30Days?.[status] ?? 0);
|
||||
}
|
||||
|
||||
get successRate(): number {
|
||||
const total = Number(this.data?.enquiriesLast30Days ?? 0);
|
||||
if (!total) return 0;
|
||||
return Math.round(((this.statusCount('SUCCESS') + this.statusCount('REUSED')) / total) * 100);
|
||||
}
|
||||
|
||||
get maxBand(): number {
|
||||
return Math.max(1, ...(this.data?.scoreBandsLast30Days ?? []).map((b: any) => Number(b.count)));
|
||||
}
|
||||
|
||||
get whitelistedCount(): number {
|
||||
return (this.data?.bureaus ?? []).filter((b: any) => b.whitelisted).length;
|
||||
}
|
||||
|
||||
statusTone(status: string): string {
|
||||
return status === 'SUCCESS' ? 'good' : status === 'REUSED' ? 'ok' : status === 'NO_HIT' ? 'warn' : 'bad';
|
||||
}
|
||||
|
||||
open(id: number): void {
|
||||
this.router.navigate([
|
||||
'/credit-bureau/enquiries',
|
||||
id
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>Enquiry {{ enquiry?.enquiryRef ?? '' }}</h1>
|
||||
<p>{{ enquiry?.clientName }} · {{ enquiry?.bureauName ?? enquiry?.bureauCode }}</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<a class="cic-btn" routerLink="/credit-bureau/enquiries">Back to history</a>
|
||||
<button class="cic-btn" (click)="print()" [disabled]="!enquiry">Print</button>
|
||||
@if (enquiry) {
|
||||
<a
|
||||
class="cic-btn primary"
|
||||
routerLink="/credit-bureau/new-enquiry"
|
||||
[queryParams]="{
|
||||
clientId: enquiry.clientId,
|
||||
amount: enquiry.loanAmount,
|
||||
applicationNo: enquiry.applicationNo || null
|
||||
}"
|
||||
>Enquire again</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (enquiry) {
|
||||
<div class="cic-card">
|
||||
<dl class="cic-kv">
|
||||
<dt>Customer</dt>
|
||||
<dd>
|
||||
{{ enquiry.clientName }} <span class="muted">(client #{{ enquiry.clientId }})</span>
|
||||
</dd>
|
||||
<dt>Bureau / environment</dt>
|
||||
<dd>{{ enquiry.bureauCode }} · {{ enquiry.environment }}</dd>
|
||||
<dt>Type / purpose</dt>
|
||||
<dd>{{ enquiry.enquiryType }} · {{ enquiry.purpose }}</dd>
|
||||
<dt>Amount</dt>
|
||||
<dd>₹{{ enquiry.loanAmount | number: '1.0-2' }}</dd>
|
||||
<dt>Loan application</dt>
|
||||
<dd>{{ enquiry.applicationNo || '—' }}</dd>
|
||||
<dt>Consent</dt>
|
||||
<dd class="cic-mono">{{ enquiry.consentRef || '—' }}</dd>
|
||||
@if (enquiry.reusedFromRef) {
|
||||
<dt>Reused report of</dt>
|
||||
<dd class="cic-mono">{{ enquiry.reusedFromRef }}</dd>
|
||||
}
|
||||
<dt>Charge</dt>
|
||||
<dd>₹{{ enquiry.chargeAmount ?? 0 | number: '1.0-2' }}</dd>
|
||||
<dt>Requested</dt>
|
||||
<dd>{{ enquiry.requestedOn | slice: 0 : 19 }} by {{ enquiry.requestedByName }}</dd>
|
||||
<dt>Response time</dt>
|
||||
<dd>
|
||||
{{ enquiry.durationMs ?? 0 }} ms
|
||||
@if (enquiry.httpStatus) {
|
||||
· HTTP {{ enquiry.httpStatus }}
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="cic-tabs">
|
||||
<button [class.active]="tab === 'report'" (click)="tab = 'report'">Report</button>
|
||||
<button [class.active]="tab === 'request'" (click)="tab = 'request'">Request sent (masked)</button>
|
||||
<button [class.active]="tab === 'response'" (click)="tab = 'response'">Raw bureau response</button>
|
||||
</div>
|
||||
|
||||
@if (tab === 'report') {
|
||||
<mifosx-cic-report-view [enquiry]="enquiry"></mifosx-cic-report-view>
|
||||
}
|
||||
@if (tab === 'request') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Request <span class="cic-card-sub">PAN, mobile and e-mail are masked; credentials are never stored</span>
|
||||
</h2>
|
||||
<pre class="cic-code">{{ pretty(enquiry.requestMasked) }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (tab === 'response') {
|
||||
<div class="cic-card">
|
||||
<h2>Raw response <span class="cic-card-sub">as returned by the bureau</span></h2>
|
||||
<pre class="cic-code">{{ pretty(enquiry.responseRaw) }}</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
|
||||
import { CicEnquiry, CicService, cicError } from '../cic.service';
|
||||
import { CicReportViewComponent } from '../cic-report-view/cic-report-view.component';
|
||||
|
||||
/** One enquiry in full: report, audit trail, the masked request sent and the bureau's raw response. */
|
||||
@Component({
|
||||
selector: 'mifosx-cic-enquiry-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
CicReportViewComponent
|
||||
],
|
||||
templateUrl: './cic-enquiry-detail.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicEnquiryDetailComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
enquiry: CicEnquiry | null = null;
|
||||
error: string | null = null;
|
||||
tab: 'report' | 'request' | 'response' = 'report';
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = Number(this.route.snapshot.paramMap.get('id'));
|
||||
this.cic.enquiry(id).subscribe({
|
||||
next: (e) => (this.enquiry = e),
|
||||
error: (e) => (this.error = cicError(e, 'Could not load the enquiry.'))
|
||||
});
|
||||
}
|
||||
|
||||
pretty(text?: string | null): string {
|
||||
if (!text) return '—';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>Enquiry History</h1>
|
||||
<p>Every credit bureau enquiry made from this bank, with its outcome and score.</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn" (click)="exportCsv()" [disabled]="!items.length">Export CSV</button>
|
||||
<a class="cic-btn primary" routerLink="/credit-bureau/new-enquiry">New Enquiry</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<div class="cic-grid cols-4" style="align-items: end">
|
||||
<div class="cic-field">
|
||||
<label>Search</label>
|
||||
<input [(ngModel)]="filter.search" placeholder="Customer, reference or application" (keyup.enter)="apply()" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Bureau</label>
|
||||
<select [(ngModel)]="filter.bureauId">
|
||||
<option value="">All</option>
|
||||
@for (b of bureaus; track b.id) {
|
||||
<option [value]="b.id">{{ b.code }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="filter.status">
|
||||
<option value="">All</option>
|
||||
@for (s of statuses; track s) {
|
||||
<option [value]="s">{{ s }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-grid cols-2" style="gap: 8px">
|
||||
<div class="cic-field">
|
||||
<label>From</label>
|
||||
<input type="date" [(ngModel)]="filter.fromDate" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>To</label>
|
||||
<input type="date" [(ngModel)]="filter.toDate" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-actions" style="margin-top: 12px">
|
||||
<button class="cic-btn primary" (click)="apply()">Apply</button>
|
||||
<button class="cic-btn" (click)="reset()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
|
||||
<div class="cic-card">
|
||||
@if (loading) {
|
||||
<div class="cic-empty">Loading…</div>
|
||||
} @else if (!items.length) {
|
||||
<div class="cic-empty">No enquiry matches these filters.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Customer</th>
|
||||
<th>Bureau</th>
|
||||
<th>Purpose</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Score</th>
|
||||
<th>Requested by</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of items; track e.id) {
|
||||
<tr class="clickable" (click)="open(e)">
|
||||
<td class="cic-mono">
|
||||
{{ e.enquiryRef }}
|
||||
@if (e.environment === 'SIMULATOR') {
|
||||
<span class="cic-badge warn" title="Answered by the simulator">SIM</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ e.clientName }}
|
||||
@if (e.applicationNo) {
|
||||
<div class="muted" style="font-size: 11px">App {{ e.applicationNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ e.bureauCode }}</td>
|
||||
<td>{{ e.purpose }}</td>
|
||||
<td class="num">₹{{ e.loanAmount | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(e.status)">{{ e.status }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<b [class]="'tone-' + scoreBand(e.score).tone">{{ e.score ?? '—' }}</b>
|
||||
</td>
|
||||
<td>{{ e.requestedByName }}</td>
|
||||
<td class="muted">{{ e.requestedOn | slice: 0 : 16 }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cic-pager">
|
||||
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { CicBureau, CicEnquiry, CicService, cicError, cicScoreBand } from '../cic.service';
|
||||
|
||||
/** Every bureau enquiry made, with filters, paging and CSV export. */
|
||||
@Component({
|
||||
selector: 'mifosx-cic-enquiry-history',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
],
|
||||
templateUrl: './cic-enquiry-history.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicEnquiryHistoryComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private router = inject(Router);
|
||||
|
||||
bureaus: CicBureau[] = [];
|
||||
items: CicEnquiry[] = [];
|
||||
total = 0;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
|
||||
filter = { search: '', bureauId: '', status: '', fromDate: '', toDate: '' };
|
||||
offset = 0;
|
||||
readonly limit = 20;
|
||||
readonly statuses = [
|
||||
'SUCCESS',
|
||||
'NO_HIT',
|
||||
'FAILED',
|
||||
'REUSED'
|
||||
];
|
||||
readonly scoreBand = cicScoreBand;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cic.bureaus().subscribe({ next: (b) => (this.bureaus = b), error: () => (this.bureaus = []) });
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.cic.enquiries({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe({
|
||||
next: (p) => {
|
||||
this.items = p.pageItems;
|
||||
this.total = p.totalFilteredRecords;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = cicError(e, 'Could not load enquiries.');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
this.offset = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.filter = { search: '', bureauId: '', status: '', fromDate: '', toDate: '' };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
page(delta: number): void {
|
||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
||||
this.load();
|
||||
}
|
||||
|
||||
open(e: CicEnquiry): void {
|
||||
this.router.navigate([
|
||||
'/credit-bureau/enquiries',
|
||||
e.id
|
||||
]);
|
||||
}
|
||||
|
||||
statusTone(status: string): string {
|
||||
return status === 'SUCCESS' ? 'good' : status === 'REUSED' ? 'ok' : status === 'NO_HIT' ? 'warn' : 'bad';
|
||||
}
|
||||
|
||||
/** CSV of the current page, for audit / MIS. */
|
||||
exportCsv(): void {
|
||||
const head = [
|
||||
'Reference',
|
||||
'Customer',
|
||||
'Client id',
|
||||
'Bureau',
|
||||
'Type',
|
||||
'Purpose',
|
||||
'Amount',
|
||||
'Status',
|
||||
'Score',
|
||||
'Requested by',
|
||||
'Requested on'
|
||||
];
|
||||
const rows = this.items.map((e) => [
|
||||
e.enquiryRef,
|
||||
e.clientName,
|
||||
e.clientId,
|
||||
e.bureauCode,
|
||||
e.enquiryType,
|
||||
e.purpose,
|
||||
e.loanAmount,
|
||||
e.status,
|
||||
e.score ?? '',
|
||||
e.requestedByName,
|
||||
e.requestedOn
|
||||
]);
|
||||
const csv = [
|
||||
head,
|
||||
...rows
|
||||
]
|
||||
.map((r) => r.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cic-enquiries-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>New Credit Bureau Enquiry</h1>
|
||||
<p>
|
||||
Pull a customer's credit report from a whitelisted Credit Information Company, under their recorded consent.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<a class="cic-btn" routerLink="/credit-bureau/enquiries">Enquiry History</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-steps">
|
||||
<div class="step" [class.done]="step > 1" [class.current]="step === 1"><i>1</i>Customer</div>
|
||||
<div class="step" [class.done]="step > 2" [class.current]="step === 2"><i>2</i>Consent</div>
|
||||
<div class="step" [class.done]="!!result" [class.current]="step === 3 && !result"><i>3</i>Enquiry</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
@if (info) {
|
||||
<div class="cic-alert success">{{ info }}</div>
|
||||
}
|
||||
|
||||
<div class="cic-split">
|
||||
<div>
|
||||
<!-- 1. Customer -->
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
1. Customer
|
||||
@if (client) {
|
||||
<button class="cic-btn sm" (click)="clearClient()">Change</button>
|
||||
}
|
||||
</h2>
|
||||
@if (!client) {
|
||||
<div class="cic-grid cols-2" style="align-items: end">
|
||||
<div class="cic-field">
|
||||
<label>Client id</label>
|
||||
<input type="number" [(ngModel)]="clientIdInput" placeholder="e.g. 12" (keyup.enter)="loadClientById()" />
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<button class="cic-btn" (click)="loadClientById()" [disabled]="!clientIdInput">Load</button>
|
||||
<button class="cic-btn primary" (click)="openSearch()">Search Customer</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div style="display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap">
|
||||
<div>
|
||||
<div style="font-size: 16px; font-weight: 700">{{ client.name }}</div>
|
||||
<div class="muted" style="font-size: 12px">
|
||||
Client #{{ client.id }}
|
||||
@if (client.office) {
|
||||
· {{ client.office }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (loadingProfile) {
|
||||
<span class="muted">Loading…</span>
|
||||
}
|
||||
</div>
|
||||
@if (profile) {
|
||||
@if (missingEssentials.length) {
|
||||
<div class="cic-alert warn" style="margin-top: 12px">
|
||||
Missing on the customer record: <b>{{ missingEssentials.join(', ') }}</b
|
||||
>. Bureaus match a customer on these — update the customer's KYC before enquiring, or the enquiry will
|
||||
be refused.
|
||||
</div>
|
||||
}
|
||||
<details style="margin-top: 12px">
|
||||
<summary class="muted" style="cursor: pointer; font-size: 12px">Data that will be sent (masked)</summary>
|
||||
<dl class="cic-kv" style="margin-top: 10px">
|
||||
@for (r of dataRows(); track r.label) {
|
||||
<dt>{{ r.label }}</dt>
|
||||
<dd>{{ r.value ?? '—' }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 2. Consent -->
|
||||
@if (client && profile) {
|
||||
<div class="cic-card">
|
||||
<h2>2. Customer consent</h2>
|
||||
@if (profile.validConsent) {
|
||||
<div class="cic-alert success" style="margin-bottom: 8px">
|
||||
Consent <b>{{ profile.validConsent.consentRef }}</b> ({{ profile.validConsent.consentMode }}) on record,
|
||||
valid until <b>{{ profile.validConsent.validUntil }}</b
|
||||
>.
|
||||
</div>
|
||||
@if (!showConsentForm) {
|
||||
<button class="cic-btn sm" (click)="showConsentForm = true">Record a new consent</button>
|
||||
}
|
||||
} @else {
|
||||
<div class="cic-alert warn">
|
||||
No valid consent for this customer{{ selectedBureau ? ' and ' + selectedBureau.code : '' }}. RBI requires
|
||||
the borrower's consent before a credit report is pulled — record it below.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showConsentForm) {
|
||||
<div class="cic-grid cols-2">
|
||||
<div class="cic-field">
|
||||
<label>Consent mode</label>
|
||||
<select [(ngModel)]="consent.consentMode">
|
||||
<option value="SIGNED_FORM">Signed consent form</option>
|
||||
<option value="OTP">OTP verified</option>
|
||||
<option value="DIGITAL_ACCEPTANCE">Digital acceptance</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (consent.consentMode === 'SIGNED_FORM') {
|
||||
<div class="cic-field">
|
||||
<label>Form / document reference *</label>
|
||||
<input [(ngModel)]="consent.documentRef" placeholder="Form no. or uploaded document name" />
|
||||
</div>
|
||||
} @else if (consent.consentMode === 'OTP') {
|
||||
<div class="cic-field">
|
||||
<label>OTP / transaction reference *</label>
|
||||
<input [(ngModel)]="consent.otpReference" placeholder="OTP log or transaction id" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-field">
|
||||
<label>Reference (optional)</label>
|
||||
<input [(ngModel)]="consent.documentRef" placeholder="Channel / session reference" />
|
||||
</div>
|
||||
}
|
||||
<div class="cic-field">
|
||||
<label>Valid for (days)</label>
|
||||
<input type="number" min="1" max="365" [(ngModel)]="consent.validityDays" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Applies to</label>
|
||||
<select [(ngModel)]="consent.bureauScope">
|
||||
<option value="ANY">Any credit bureau</option>
|
||||
<option value="THIS" [disabled]="!selectedBureau">
|
||||
Only {{ selectedBureau?.code ?? 'the selected bureau' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-field" style="margin-top: 12px">
|
||||
<label>Consent declaration</label>
|
||||
<textarea [(ngModel)]="consent.consentText" rows="4"></textarea>
|
||||
</div>
|
||||
<label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; margin: 12px 0">
|
||||
<input type="checkbox" [(ngModel)]="consent.customerDeclarationAccepted" style="margin-top: 3px" />
|
||||
<span>The customer has read the declaration above and given consent in the selected mode.</span>
|
||||
</label>
|
||||
<div class="cic-actions">
|
||||
<button
|
||||
class="cic-btn primary"
|
||||
(click)="saveConsent()"
|
||||
[disabled]="savingConsent || !consent.customerDeclarationAccepted"
|
||||
>
|
||||
{{ savingConsent ? 'Saving…' : 'Record Consent' }}
|
||||
</button>
|
||||
@if (profile.validConsent) {
|
||||
<button class="cic-btn" (click)="showConsentForm = false">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 3. Enquiry -->
|
||||
@if (client && profile) {
|
||||
<div class="cic-card">
|
||||
<h2>3. Enquiry</h2>
|
||||
<div class="cic-grid cols-2">
|
||||
<div class="cic-field">
|
||||
<label>Credit bureau (whitelisted)</label>
|
||||
<select [(ngModel)]="bureauId" (ngModelChange)="onBureauChange()">
|
||||
@for (b of bureaus; track b.id) {
|
||||
<option [ngValue]="b.id">
|
||||
{{ b.code }} — {{ b.name }}{{ b.environment === 'SIMULATOR' ? ' (simulator)' : '' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@if (selectedBureau) {
|
||||
<span class="hint">
|
||||
{{ selectedBureau.environment }} · reuses a report up to {{ selectedBureau.reportValidityDays }} days
|
||||
old · charge ₹{{ selectedBureau.enquiryCharge | number: '1.0-2' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Enquiry type</label>
|
||||
<select [(ngModel)]="enquiryType">
|
||||
@for (t of catalog?.endpointTypes ?? []; track t) {
|
||||
<option [value]="t">
|
||||
{{ t === 'CONSUMER_ENQUIRY' ? 'Consumer (individual)' : 'Commercial (business)' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Purpose</label>
|
||||
<select [(ngModel)]="purpose">
|
||||
@for (p of purposes; track p.code) {
|
||||
<option [value]="p.code">{{ p.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Loan / credit amount (₹) *</label>
|
||||
<input type="number" min="1" [(ngModel)]="loanAmount" placeholder="e.g. 500000" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Loan application no. (optional)</label>
|
||||
<input [(ngModel)]="applicationNo" placeholder="Links the enquiry to an application" />
|
||||
</div>
|
||||
<div class="cic-field" style="justify-content: flex-end">
|
||||
<label style="display: flex; gap: 8px; align-items: center; font-weight: 500">
|
||||
<input type="checkbox" [(ngModel)]="forceFresh" style="width: auto" />
|
||||
Pull a fresh report (don't reuse a recent one)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-actions" style="margin-top: 14px">
|
||||
<button
|
||||
class="cic-btn primary"
|
||||
(click)="runEnquiry()"
|
||||
[disabled]="running || !bureauId || !loanAmount || !profile.validConsent"
|
||||
>
|
||||
{{ running ? 'Contacting bureau…' : 'Pull Credit Report' }}
|
||||
</button>
|
||||
@if (!profile.validConsent) {
|
||||
<span class="muted" style="font-size: 12px">Record the customer's consent first.</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Result / recent -->
|
||||
<div>
|
||||
@if (result) {
|
||||
<div class="cic-card" style="padding-bottom: 6px">
|
||||
<h2>
|
||||
Result
|
||||
<a class="cic-btn sm" [routerLink]="['/credit-bureau/enquiries', result.id]">Full details</a>
|
||||
</h2>
|
||||
</div>
|
||||
<mifosx-cic-report-view [enquiry]="result"></mifosx-cic-report-view>
|
||||
} @else if (profile?.recentEnquiries?.length) {
|
||||
<div class="cic-card">
|
||||
<h2>Previous enquiries of this customer</h2>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Bureau</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Score</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of profile.recentEnquiries; track e.id) {
|
||||
<tr class="clickable" [routerLink]="['/credit-bureau/enquiries', e.id]">
|
||||
<td class="cic-mono">{{ e.enquiryRef }}</td>
|
||||
<td>{{ e.bureauCode }}</td>
|
||||
<td>
|
||||
<span class="cic-badge">{{ e.status }}</span>
|
||||
</td>
|
||||
<td class="num">{{ e.score ?? '—' }}</td>
|
||||
<td class="muted">{{ e.requestedOn | slice: 0 : 16 }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-card cic-empty">
|
||||
The credit report appears here once the enquiry is run.
|
||||
<div style="margin-top: 8px; font-size: 12px">
|
||||
Bureaus in <b>SIMULATOR</b> mode answer with a realistic test report — no real bureau is contacted and no
|
||||
charge applies.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
|
||||
import {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { CicBureau, CicCatalog, CicEnquiry, CicService, cicError } from '../cic.service';
|
||||
import { CicReportViewComponent } from '../cic-report-view/cic-report-view.component';
|
||||
|
||||
/**
|
||||
* Pull a credit report: pick the customer, make sure their bureau consent is on record (capture it here if not),
|
||||
* choose a whitelisted bureau, purpose and amount, and run the enquiry.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cic-new-enquiry',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatDialogModule,
|
||||
CicReportViewComponent
|
||||
],
|
||||
templateUrl: './cic-new-enquiry.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicNewEnquiryComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private dialog = inject(MatDialog);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
catalog: CicCatalog | null = null;
|
||||
bureaus: CicBureau[] = [];
|
||||
purposes: { code: string; label: string }[] = [];
|
||||
|
||||
clientIdInput: number | null = null;
|
||||
client: { id: number; name: string; mobile?: string | null; office?: string | null } | null = null;
|
||||
profile: any = null;
|
||||
loadingProfile = false;
|
||||
|
||||
bureauId: number | null = null;
|
||||
enquiryType = 'CONSUMER_ENQUIRY';
|
||||
purpose = 'NEW_LOAN';
|
||||
loanAmount: number | null = null;
|
||||
applicationNo = '';
|
||||
forceFresh = false;
|
||||
|
||||
showConsentForm = false;
|
||||
consent = {
|
||||
consentMode: 'SIGNED_FORM',
|
||||
documentRef: '',
|
||||
otpReference: '',
|
||||
validityDays: 30,
|
||||
bureauScope: 'ANY',
|
||||
customerDeclarationAccepted: false,
|
||||
consentText: ''
|
||||
};
|
||||
savingConsent = false;
|
||||
|
||||
running = false;
|
||||
result: CicEnquiry | null = null;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cic.catalog().subscribe({
|
||||
next: (c) => {
|
||||
this.catalog = c;
|
||||
this.purposes = Object.entries(c.purposes).map(
|
||||
([
|
||||
code,
|
||||
label
|
||||
]) => ({ code, label })
|
||||
);
|
||||
this.consent.consentText = c.defaultConsentText;
|
||||
},
|
||||
error: (e) => (this.error = cicError(e))
|
||||
});
|
||||
this.cic.bureaus(true).subscribe({
|
||||
next: (b) => {
|
||||
this.bureaus = b;
|
||||
this.bureauId = (b.find((x) => x.isDefault) ?? b[0])?.id ?? null;
|
||||
if (!b.length) {
|
||||
this.error =
|
||||
'No credit bureau is whitelisted for this bank yet. Ask an administrator to whitelist one under CIC Configuration.';
|
||||
}
|
||||
this.reloadProfile();
|
||||
},
|
||||
error: (e) => (this.error = cicError(e))
|
||||
});
|
||||
// Deep link from another screen (e.g. a loan application): /credit-bureau/new-enquiry?clientId=..&amount=..&applicationNo=..
|
||||
const q = this.route.snapshot.queryParamMap;
|
||||
if (q.get('applicationNo')) this.applicationNo = q.get('applicationNo')!;
|
||||
if (q.get('amount')) this.loanAmount = Number(q.get('amount'));
|
||||
if (q.get('clientId')) {
|
||||
this.clientIdInput = Number(q.get('clientId'));
|
||||
this.loadClientById();
|
||||
}
|
||||
}
|
||||
|
||||
get selectedBureau(): CicBureau | undefined {
|
||||
return this.bureaus.find((b) => b.id === this.bureauId);
|
||||
}
|
||||
|
||||
get step(): number {
|
||||
if (!this.client) return 1;
|
||||
if (!this.profile?.validConsent) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** Data the default mapping cannot work without; the server checks the real mapping on submit. */
|
||||
get missingEssentials(): string[] {
|
||||
const d = this.profile?.data ?? {};
|
||||
const missing: string[] = [];
|
||||
if (!d['client.pan']) missing.push('PAN');
|
||||
if (!d['client.dob']) missing.push('Date of birth');
|
||||
if (!d['client.firstName'] && !d['client.fullName']) missing.push('Name');
|
||||
return missing;
|
||||
}
|
||||
|
||||
openSearch(): void {
|
||||
this.dialog
|
||||
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
||||
.afterClosed()
|
||||
.subscribe((r: CustomerSearchResult | undefined) => {
|
||||
if (!r?.clientId) return;
|
||||
this.client = { id: r.clientId, name: r.displayName, mobile: r.mobileNo, office: r.officeName };
|
||||
this.clientIdInput = r.clientId;
|
||||
this.resetOutcome();
|
||||
this.reloadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
loadClientById(): void {
|
||||
if (!this.clientIdInput) return;
|
||||
this.client = { id: Number(this.clientIdInput), name: `Client #${this.clientIdInput}` };
|
||||
this.resetOutcome();
|
||||
this.reloadProfile();
|
||||
}
|
||||
|
||||
reloadProfile(): void {
|
||||
if (!this.client) return;
|
||||
this.loadingProfile = true;
|
||||
this.cic.clientProfile(this.client.id, this.bureauId).subscribe({
|
||||
next: (p) => {
|
||||
this.profile = p;
|
||||
this.loadingProfile = false;
|
||||
const name = p?.data?.['client.fullName'];
|
||||
if (name && this.client) this.client.name = name;
|
||||
if (this.client && p?.data?.['client.branch']) this.client.office = p.data['client.branch'];
|
||||
this.showConsentForm = !p?.validConsent;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = cicError(e, 'Could not load the customer.');
|
||||
this.profile = null;
|
||||
this.client = null;
|
||||
this.loadingProfile = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onBureauChange(): void {
|
||||
this.resetOutcome();
|
||||
this.reloadProfile();
|
||||
}
|
||||
|
||||
saveConsent(): void {
|
||||
if (!this.client) return;
|
||||
this.error = null;
|
||||
this.savingConsent = true;
|
||||
this.cic
|
||||
.createConsent({
|
||||
clientId: this.client.id,
|
||||
bureauId: this.consent.bureauScope === 'THIS' ? this.bureauId : null,
|
||||
applicationNo: this.applicationNo || null,
|
||||
purpose: this.purpose,
|
||||
consentMode: this.consent.consentMode,
|
||||
documentRef: this.consent.documentRef || null,
|
||||
otpReference: this.consent.otpReference || null,
|
||||
validityDays: this.consent.validityDays,
|
||||
consentText: this.consent.consentText,
|
||||
customerDeclarationAccepted: this.consent.customerDeclarationAccepted
|
||||
})
|
||||
.subscribe({
|
||||
next: (c) => {
|
||||
this.savingConsent = false;
|
||||
this.info = `Consent ${c.consentRef} recorded, valid until ${c.validUntil}.`;
|
||||
this.consent.customerDeclarationAccepted = false;
|
||||
this.reloadProfile();
|
||||
},
|
||||
error: (e) => {
|
||||
this.savingConsent = false;
|
||||
this.error = cicError(e, 'Could not record the consent.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runEnquiry(): void {
|
||||
if (!this.client || !this.bureauId) return;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
this.result = null;
|
||||
this.running = true;
|
||||
this.cic
|
||||
.enquire({
|
||||
clientId: this.client.id,
|
||||
bureauId: this.bureauId,
|
||||
enquiryType: this.enquiryType,
|
||||
purpose: this.purpose,
|
||||
loanAmount: this.loanAmount,
|
||||
applicationNo: this.applicationNo || null,
|
||||
consentId: this.profile?.validConsent?.id ?? null,
|
||||
forceFresh: this.forceFresh
|
||||
})
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.running = false;
|
||||
this.result = r;
|
||||
this.reloadProfile();
|
||||
},
|
||||
error: (e) => {
|
||||
this.running = false;
|
||||
this.error = cicError(e, 'The enquiry could not be run.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearClient(): void {
|
||||
this.client = null;
|
||||
this.profile = null;
|
||||
this.clientIdInput = null;
|
||||
this.resetOutcome();
|
||||
}
|
||||
|
||||
private resetOutcome(): void {
|
||||
this.result = null;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
}
|
||||
|
||||
dataRows(): { label: string; value: any }[] {
|
||||
const d = this.profile?.data ?? {};
|
||||
const fields = this.catalog?.cbsFields ?? {};
|
||||
return Object.keys(d)
|
||||
.filter((k) => !['client.id'].includes(k))
|
||||
.map((k) => ({ label: fields[k] ?? k, value: d[k] }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
@if (enquiry.status === 'FAILED') {
|
||||
<div class="cic-alert error">
|
||||
<b>Enquiry failed.</b> {{ enquiry.errorMessage }}
|
||||
@if (enquiry.httpStatus) {
|
||||
<div class="muted">HTTP status {{ enquiry.httpStatus }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (enquiry.status === 'NO_HIT') {
|
||||
<div class="cic-alert warn"><b>No record found.</b> {{ enquiry.errorMessage }}</div>
|
||||
}
|
||||
@if (enquiry.status === 'REUSED') {
|
||||
<div class="cic-alert info">{{ enquiry.errorMessage }}</div>
|
||||
}
|
||||
|
||||
@if (enquiry.status === 'SUCCESS' || enquiry.status === 'REUSED') {
|
||||
<div class="cic-card">
|
||||
<div class="cic-score">
|
||||
<div class="ring" [style.background]="ringStyle">
|
||||
<div class="inner">
|
||||
<div>
|
||||
<b [class]="'tone-' + band.tone">{{ enquiry.score ?? '—' }}</b
|
||||
><small>{{ band.label }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px">
|
||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(enquiry.status)">{{ enquiry.status }}</span>
|
||||
<span class="cic-badge brand">{{ enquiry.bureauCode }}</span>
|
||||
@if (enquiry.environment === 'SIMULATOR') {
|
||||
<span class="cic-badge warn">SIMULATOR — not a real bureau report</span>
|
||||
}
|
||||
</div>
|
||||
<dl class="cic-kv">
|
||||
<dt>Report / control no.</dt>
|
||||
<dd class="cic-mono">{{ result.reportId ?? '—' }}</dd>
|
||||
<dt>Score model</dt>
|
||||
<dd>{{ result.scoreVersion ?? '—' }}</dd>
|
||||
<dt>Enquiry reference</dt>
|
||||
<dd class="cic-mono">{{ enquiry.enquiryRef }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
||||
<div class="cic-stat">
|
||||
<div class="k">Accounts</div>
|
||||
<div class="v">{{ result.totalAccounts ?? '—' }}</div>
|
||||
<div class="s">{{ result.activeAccounts ?? 0 }} active</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Outstanding</div>
|
||||
<div class="v">₹{{ result.totalOutstanding ?? 0 | number: '1.0-0' }}</div>
|
||||
<div class="s">across active accounts</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Overdue</div>
|
||||
<div class="v" [class.tone-bad]="(result.totalOverdue ?? 0) > 0">
|
||||
₹{{ result.totalOverdue ?? 0 | number: '1.0-0' }}
|
||||
</div>
|
||||
<div class="s">
|
||||
{{ result.overdueAccounts ?? 0 }} account(s) · max DPD {{ result.maxDpd ?? 0 }}
|
||||
@if ((result.writtenOffAccounts ?? 0) > 0) {
|
||||
· <b class="tone-bad">{{ result.writtenOffAccounts }} written-off</b>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Enquiries (6 months)</div>
|
||||
<div class="v" [class.tone-warn]="(result.enquiriesLast6Months ?? 0) > 4">
|
||||
{{ result.enquiriesLast6Months ?? '—' }}
|
||||
</div>
|
||||
<div class="s">credit hunger indicator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (extraFields.length) {
|
||||
<div class="cic-card">
|
||||
<h2>Other mapped fields</h2>
|
||||
<dl class="cic-kv">
|
||||
@for (f of extraFields; track f.key) {
|
||||
<dt>{{ f.key }}</dt>
|
||||
<dd>{{ f.value ?? '—' }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Credit accounts <span class="cic-card-sub">{{ accounts.length }} reported</span>
|
||||
</h2>
|
||||
@if (!accounts.length) {
|
||||
<div class="cic-empty">The bureau reported no account (or the response mapping has no "accounts" field).</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (k of keys(accounts[0]); track k) {
|
||||
<th>{{ k }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of accounts; track $index) {
|
||||
<tr>
|
||||
@for (k of keys(accounts[0]); track k) {
|
||||
<td>
|
||||
@if (k === 'status') {
|
||||
<span class="cic-badge" [class]="'cic-badge ' + accountTone(a)">{{ a[k] }}</span>
|
||||
} @else {
|
||||
{{ a[k] }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Recent enquiries by other lenders <span class="cic-card-sub">{{ enquiries.length }}</span>
|
||||
</h2>
|
||||
@if (!enquiries.length) {
|
||||
<div class="cic-empty">No enquiry reported.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (k of keys(enquiries[0]); track k) {
|
||||
<th>{{ k }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (q of enquiries; track $index) {
|
||||
<tr>
|
||||
@for (k of keys(enquiries[0]); track k) {
|
||||
<td>{{ q[k] }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { CicEnquiry, cicScoreBand } from '../cic.service';
|
||||
|
||||
/** The outcome of one enquiry: score, summary, accounts and enquiries, read from the normalised result. */
|
||||
@Component({
|
||||
selector: 'mifosx-cic-report-view',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule
|
||||
],
|
||||
templateUrl: './cic-report-view.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicReportViewComponent {
|
||||
@Input({ required: true }) enquiry!: CicEnquiry;
|
||||
|
||||
get result(): any {
|
||||
return this.enquiry?.result ?? {};
|
||||
}
|
||||
|
||||
get band() {
|
||||
return cicScoreBand(this.enquiry?.score);
|
||||
}
|
||||
|
||||
/** Conic ring filled in proportion to the score on the 300-900 scale. */
|
||||
get ringStyle(): string {
|
||||
const score = this.enquiry?.score;
|
||||
const pct = score ? Math.max(0, Math.min(100, ((score - 300) / 600) * 100)) : 0;
|
||||
const colors: Record<string, string> = {
|
||||
good: '#047857',
|
||||
ok: '#0369a1',
|
||||
warn: '#b45309',
|
||||
bad: '#b91c1c',
|
||||
muted: '#94a3b8'
|
||||
};
|
||||
const color = colors[this.band.tone] ?? colors['muted'];
|
||||
return `conic-gradient(${color} ${pct}%, #e2e8f0 ${pct}% 100%)`;
|
||||
}
|
||||
|
||||
get accounts(): any[] {
|
||||
return Array.isArray(this.result.accounts) ? this.result.accounts : [];
|
||||
}
|
||||
|
||||
get enquiries(): any[] {
|
||||
return Array.isArray(this.result.enquiries) ? this.result.enquiries : [];
|
||||
}
|
||||
|
||||
/** Result fields beyond the standard ones a custom RESPONSE mapping may have added. */
|
||||
get extraFields(): { key: string; value: any }[] {
|
||||
const standard = new Set([
|
||||
'hit',
|
||||
'reportId',
|
||||
'score',
|
||||
'scoreVersion',
|
||||
'totalAccounts',
|
||||
'activeAccounts',
|
||||
'overdueAccounts',
|
||||
'writtenOffAccounts',
|
||||
'totalOutstanding',
|
||||
'totalOverdue',
|
||||
'enquiriesLast6Months',
|
||||
'maxDpd',
|
||||
'accounts',
|
||||
'enquiries'
|
||||
]);
|
||||
return Object.entries(this.result)
|
||||
.filter(
|
||||
([
|
||||
k,
|
||||
v
|
||||
]) => !standard.has(k) && (v === null || typeof v !== 'object')
|
||||
)
|
||||
.map(
|
||||
([
|
||||
key,
|
||||
value
|
||||
]) => ({ key, value })
|
||||
);
|
||||
}
|
||||
|
||||
statusTone(status: string): string {
|
||||
return status === 'SUCCESS' ? 'good' : status === 'REUSED' ? 'ok' : status === 'NO_HIT' ? 'warn' : 'bad';
|
||||
}
|
||||
|
||||
accountTone(a: any): string {
|
||||
const status = String(a?.status ?? '').toUpperCase();
|
||||
if (status.includes('WRITTEN') || status.includes('SETTLED')) return 'bad';
|
||||
if (Number(a?.dpd ?? 0) > 0 || Number(a?.amountOverdue ?? 0) > 0) return 'warn';
|
||||
if (status === 'CLOSED') return '';
|
||||
return 'good';
|
||||
}
|
||||
|
||||
keys(o: any): string[] {
|
||||
return o && typeof o === 'object' ? Object.keys(o) : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Shared look for the Credit Bureau (CIC) screens. */
|
||||
|
||||
:host {
|
||||
--cic-ink: #0f172a;
|
||||
--cic-muted: #64748b;
|
||||
--cic-line: #e2e8f0;
|
||||
--cic-soft: #f8fafc;
|
||||
--cic-brand: #1d4ed8;
|
||||
--cic-brand-soft: #eff6ff;
|
||||
--cic-good: #047857;
|
||||
--cic-good-soft: #ecfdf5;
|
||||
--cic-ok: #0369a1;
|
||||
--cic-ok-soft: #f0f9ff;
|
||||
--cic-warn: #b45309;
|
||||
--cic-warn-soft: #fffbeb;
|
||||
--cic-bad: #b91c1c;
|
||||
--cic-bad-soft: #fef2f2;
|
||||
|
||||
display: block;
|
||||
color: var(--cic-ink);
|
||||
}
|
||||
|
||||
.cic-page {
|
||||
padding: 20px 24px 40px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cic-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--cic-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cic-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--cic-line);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
|
||||
> h2,
|
||||
.cic-card-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cic-card-sub {
|
||||
color: var(--cic-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
&.cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
&.cols-3,
|
||||
&.cols-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
&.cols-2,
|
||||
&.cols-3,
|
||||
&.cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cic-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-stat {
|
||||
background: #fff;
|
||||
border: 1px solid var(--cic-line);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
|
||||
.k {
|
||||
font-size: 12px;
|
||||
color: var(--cic-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.s {
|
||||
font-size: 12px;
|
||||
color: var(--cic-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--cic-ink);
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--cic-brand);
|
||||
box-shadow: 0 0 0 3px rgb(29 78 216 / 12%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--cic-soft);
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-mono,
|
||||
textarea.cic-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.cic-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--cic-line);
|
||||
background: #fff;
|
||||
color: var(--cic-ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--cic-soft);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--cic-brand);
|
||||
border-color: var(--cic-brand);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--cic-bad);
|
||||
border-color: #fecaca;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--cic-bad-soft);
|
||||
}
|
||||
}
|
||||
|
||||
&.sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--cic-soft);
|
||||
color: var(--cic-muted);
|
||||
border: 1px solid var(--cic-line);
|
||||
white-space: nowrap;
|
||||
|
||||
&.good {
|
||||
background: var(--cic-good-soft);
|
||||
color: var(--cic-good);
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: var(--cic-ok-soft);
|
||||
color: var(--cic-ok);
|
||||
border-color: #bae6fd;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--cic-warn-soft);
|
||||
color: var(--cic-warn);
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: var(--cic-bad-soft);
|
||||
color: var(--cic-bad);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&.brand {
|
||||
background: var(--cic-brand-soft);
|
||||
color: var(--cic-brand);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-alert {
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid;
|
||||
|
||||
&.error {
|
||||
background: var(--cic-bad-soft);
|
||||
border-color: #fecaca;
|
||||
color: var(--cic-bad);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: var(--cic-good-soft);
|
||||
border-color: #a7f3d0;
|
||||
color: var(--cic-good);
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: var(--cic-brand-soft);
|
||||
border-color: #bfdbfe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--cic-warn-soft);
|
||||
border-color: #fde68a;
|
||||
color: var(--cic-warn);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--cic-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cic-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--cic-muted);
|
||||
font-weight: 700;
|
||||
background: var(--cic-soft);
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--cic-line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
td input,
|
||||
td select {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 5px 7px;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-empty {
|
||||
text-align: center;
|
||||
color: var(--cic-muted);
|
||||
padding: 28px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cic-pager {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
.cic-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background: var(--cic-good);
|
||||
}
|
||||
|
||||
input:checked + span::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
input:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--cic-line);
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--cic-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
|
||||
&.active {
|
||||
color: var(--cic-brand);
|
||||
border-bottom-color: var(--cic-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cic-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
|
||||
.ring {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
|
||||
.inner {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
|
||||
b {
|
||||
font-size: 28px;
|
||||
display: block;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 10px;
|
||||
color: var(--cic-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cic-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
gap: 7px 12px;
|
||||
font-size: 13px;
|
||||
|
||||
dt {
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-code {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cic-steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--cic-line);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--cic-muted);
|
||||
|
||||
i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--cic-soft);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.done {
|
||||
color: var(--cic-good);
|
||||
border-color: #a7f3d0;
|
||||
background: var(--cic-good-soft);
|
||||
|
||||
i {
|
||||
background: var(--cic-good);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.current {
|
||||
color: var(--cic-brand);
|
||||
border-color: #bfdbfe;
|
||||
background: var(--cic-brand-soft);
|
||||
|
||||
i {
|
||||
background: var(--cic-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cic-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cic-modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: min(640px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: 20px 22px;
|
||||
box-shadow: 0 20px 50px rgb(15 23 42 / 25%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
background: var(--cic-brand);
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
.tone-good {
|
||||
color: var(--cic-good);
|
||||
}
|
||||
|
||||
.tone-ok {
|
||||
color: var(--cic-ok);
|
||||
}
|
||||
|
||||
.tone-warn {
|
||||
color: var(--cic-warn);
|
||||
}
|
||||
|
||||
.tone-bad {
|
||||
color: var(--cic-bad);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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 { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/** A credit bureau (CIC) as configured for the bank. Never carries the secret itself. */
|
||||
export interface CicBureau {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
whitelisted: boolean;
|
||||
isDefault: boolean;
|
||||
environment: 'SIMULATOR' | 'UAT' | 'PRODUCTION';
|
||||
baseUrl?: string;
|
||||
memberCode?: string;
|
||||
authType: string;
|
||||
username?: string;
|
||||
apiKeyHeader?: string;
|
||||
hasSecret: boolean;
|
||||
tokenUrl?: string;
|
||||
clientId?: string;
|
||||
timeoutMs: number;
|
||||
reportValidityDays: number;
|
||||
enquiryCharge: number;
|
||||
lastTestStatus?: string;
|
||||
lastTestMessage?: string;
|
||||
lastTestedOn?: string;
|
||||
updatedOn?: string;
|
||||
activeEndpoints: number;
|
||||
enquiriesLast30Days: number;
|
||||
}
|
||||
|
||||
export interface CicMapping {
|
||||
id?: number;
|
||||
sourceField: string | null;
|
||||
targetField: string;
|
||||
transform: string;
|
||||
defaultValue: string | null;
|
||||
mandatory: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface CicEndpoint {
|
||||
bureauId: number;
|
||||
endpointType: string;
|
||||
exists: boolean;
|
||||
id?: number;
|
||||
httpMethod: string;
|
||||
path?: string;
|
||||
contentType: string;
|
||||
headersJson: string;
|
||||
baseBody: string;
|
||||
isActive: boolean;
|
||||
requestMappings: CicMapping[];
|
||||
responseMappings: CicMapping[];
|
||||
}
|
||||
|
||||
export interface CicCatalog {
|
||||
environments: string[];
|
||||
authTypes: string[];
|
||||
endpointTypes: string[];
|
||||
httpMethods: string[];
|
||||
consentModes: string[];
|
||||
purposes: Record<string, string>;
|
||||
cbsFields: Record<string, string>;
|
||||
resultFields: Record<string, string>;
|
||||
transforms: Record<string, string>;
|
||||
defaultConsentText: string;
|
||||
}
|
||||
|
||||
export interface CicConsent {
|
||||
id: number;
|
||||
consentRef: string;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
applicationNo?: string;
|
||||
bureauId?: number;
|
||||
bureauCode: string;
|
||||
purpose: string;
|
||||
consentMode: string;
|
||||
consentText?: string;
|
||||
documentRef?: string;
|
||||
otpReference?: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'REVOKED';
|
||||
revokedReason?: string;
|
||||
revokedOn?: string;
|
||||
capturedByName?: string;
|
||||
capturedOn?: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export interface CicEnquiry {
|
||||
id: number;
|
||||
enquiryRef: string;
|
||||
bureauId: number;
|
||||
bureauCode: string;
|
||||
bureauName?: string;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
applicationNo?: string;
|
||||
enquiryType: string;
|
||||
purpose: string;
|
||||
loanAmount: number;
|
||||
status: 'SUCCESS' | 'NO_HIT' | 'FAILED' | 'REUSED';
|
||||
score?: number;
|
||||
errorMessage?: string;
|
||||
chargeAmount?: number;
|
||||
environment?: string;
|
||||
requestedByName?: string;
|
||||
requestedOn: string;
|
||||
durationMs?: number;
|
||||
consentRef?: string;
|
||||
reusedFromRef?: string;
|
||||
httpStatus?: number;
|
||||
requestMasked?: string;
|
||||
responseRaw?: string;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export interface CicPage<T> {
|
||||
totalFilteredRecords: number;
|
||||
pageItems: T[];
|
||||
}
|
||||
|
||||
/** Score band used everywhere a score is shown. */
|
||||
export function cicScoreBand(score: number | null | undefined): { label: string; tone: string } {
|
||||
if (score === null || score === undefined) return { label: 'No score', tone: 'muted' };
|
||||
if (score >= 750) return { label: 'Excellent', tone: 'good' };
|
||||
if (score >= 700) return { label: 'Very good', tone: 'good' };
|
||||
if (score >= 650) return { label: 'Good', tone: 'ok' };
|
||||
if (score >= 550) return { label: 'Fair', tone: 'warn' };
|
||||
return { label: 'Poor', tone: 'bad' };
|
||||
}
|
||||
|
||||
/** The server's own message for a failed call. */
|
||||
export function cicError(err: any, fallback = 'Something went wrong. Please try again.'): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
err?.error?.message ??
|
||||
err?.message ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CicService {
|
||||
private http = inject(HttpClient);
|
||||
private base = '/cic';
|
||||
/** Errors are shown inline on these screens, so the global alert is suppressed. */
|
||||
private silent = { context: new HttpContext().set(SILENT_HTTP_ERROR, true) };
|
||||
|
||||
catalog(): Observable<CicCatalog> {
|
||||
return this.http.get<CicCatalog>(`${this.base}/catalog`, this.silent);
|
||||
}
|
||||
|
||||
bureaus(whitelistedOnly = false): Observable<CicBureau[]> {
|
||||
return this.http.get<CicBureau[]>(`${this.base}/bureaus`, {
|
||||
...this.silent,
|
||||
params: new HttpParams().set('whitelistedOnly', String(whitelistedOnly))
|
||||
});
|
||||
}
|
||||
|
||||
updateBureau(id: number, body: any): Observable<CicBureau> {
|
||||
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}`, body, this.silent);
|
||||
}
|
||||
|
||||
setWhitelisted(id: number, whitelisted: boolean): Observable<CicBureau> {
|
||||
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}/whitelist`, { whitelisted }, this.silent);
|
||||
}
|
||||
|
||||
testConnection(id: number): Observable<{ status: string; message: string }> {
|
||||
return this.http.post<{ status: string; message: string }>(
|
||||
`${this.base}/bureaus/${id}/test-connection`,
|
||||
{},
|
||||
this.silent
|
||||
);
|
||||
}
|
||||
|
||||
endpoint(bureauId: number, type: string): Observable<CicEndpoint> {
|
||||
return this.http.get<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, this.silent);
|
||||
}
|
||||
|
||||
saveEndpoint(bureauId: number, type: string, body: any): Observable<CicEndpoint> {
|
||||
return this.http.put<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, body, this.silent);
|
||||
}
|
||||
|
||||
preview(bureauId: number, body: any): Observable<any> {
|
||||
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/preview`, body, this.silent);
|
||||
}
|
||||
|
||||
clientProfile(clientId: number, bureauId?: number | null): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (bureauId) params = params.set('bureauId', String(bureauId));
|
||||
return this.http.get<any>(`${this.base}/clients/${clientId}/profile`, { ...this.silent, params });
|
||||
}
|
||||
|
||||
consents(filter: Record<string, any>): Observable<CicPage<CicConsent>> {
|
||||
return this.http.get<CicPage<CicConsent>>(`${this.base}/consents`, { ...this.silent, params: this.params(filter) });
|
||||
}
|
||||
|
||||
createConsent(body: any): Observable<CicConsent> {
|
||||
return this.http.post<CicConsent>(`${this.base}/consents`, body, this.silent);
|
||||
}
|
||||
|
||||
revokeConsent(id: number, reason: string): Observable<CicConsent> {
|
||||
return this.http.post<CicConsent>(`${this.base}/consents/${id}/revoke`, { reason }, this.silent);
|
||||
}
|
||||
|
||||
enquiries(filter: Record<string, any>): Observable<CicPage<CicEnquiry>> {
|
||||
return this.http.get<CicPage<CicEnquiry>>(`${this.base}/enquiries`, {
|
||||
...this.silent,
|
||||
params: this.params(filter)
|
||||
});
|
||||
}
|
||||
|
||||
enquiry(id: number): Observable<CicEnquiry> {
|
||||
return this.http.get<CicEnquiry>(`${this.base}/enquiries/${id}`, this.silent);
|
||||
}
|
||||
|
||||
enquire(body: any): Observable<CicEnquiry> {
|
||||
return this.http.post<CicEnquiry>(`${this.base}/enquiries`, body, this.silent);
|
||||
}
|
||||
|
||||
dashboard(): Observable<any> {
|
||||
return this.http.get<any>(`${this.base}/dashboard`, this.silent);
|
||||
}
|
||||
|
||||
private params(filter: Record<string, any>): HttpParams {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filter).forEach(
|
||||
([
|
||||
k,
|
||||
v
|
||||
]) => {
|
||||
if (v !== null && v !== undefined && v !== '') params = params.set(k, String(v));
|
||||
}
|
||||
);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
|
||||
import { CicNewEnquiryComponent } from './cic-new-enquiry/cic-new-enquiry.component';
|
||||
import { CicEnquiryHistoryComponent } from './cic-enquiry-history/cic-enquiry-history.component';
|
||||
import { CicEnquiryDetailComponent } from './cic-enquiry-detail/cic-enquiry-detail.component';
|
||||
import { CicConsentsComponent } from './cic-consents/cic-consents.component';
|
||||
import { CicBureauConfigComponent } from './cic-bureau-config/cic-bureau-config.component';
|
||||
import { CicApiMappingComponent } from './cic-api-mapping/cic-api-mapping.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'credit-bureau',
|
||||
data: { title: 'Credit Bureau', breadcrumb: 'Credit Bureau' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: CicDashboardComponent,
|
||||
data: { title: 'Credit Bureau Dashboard', breadcrumb: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: 'new-enquiry',
|
||||
component: CicNewEnquiryComponent,
|
||||
data: { title: 'New Bureau Enquiry', breadcrumb: 'New Enquiry' }
|
||||
},
|
||||
{
|
||||
path: 'enquiries',
|
||||
data: { breadcrumb: 'Enquiry History' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: CicEnquiryHistoryComponent,
|
||||
data: { title: 'Bureau Enquiry History' }
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
component: CicEnquiryDetailComponent,
|
||||
data: { title: 'Bureau Report', breadcrumb: 'Report' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'consents',
|
||||
component: CicConsentsComponent,
|
||||
data: { title: 'Bureau Consent Register', breadcrumb: 'Consent Register' }
|
||||
},
|
||||
{
|
||||
path: 'bureaus',
|
||||
component: CicBureauConfigComponent,
|
||||
data: { title: 'CIC Whitelist & Configuration', breadcrumb: 'CIC Configuration' }
|
||||
},
|
||||
{
|
||||
path: 'api-mapping',
|
||||
component: CicApiMappingComponent,
|
||||
data: { title: 'CIC API Mapping', breadcrumb: 'API Mapping' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class CreditBureauRoutingModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { CreditBureauRoutingModule } from './credit-bureau-routing.module';
|
||||
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
|
||||
import { CicNewEnquiryComponent } from './cic-new-enquiry/cic-new-enquiry.component';
|
||||
import { CicEnquiryHistoryComponent } from './cic-enquiry-history/cic-enquiry-history.component';
|
||||
import { CicEnquiryDetailComponent } from './cic-enquiry-detail/cic-enquiry-detail.component';
|
||||
import { CicConsentsComponent } from './cic-consents/cic-consents.component';
|
||||
import { CicBureauConfigComponent } from './cic-bureau-config/cic-bureau-config.component';
|
||||
import { CicApiMappingComponent } from './cic-api-mapping/cic-api-mapping.component';
|
||||
|
||||
/** CIC / credit bureau enquiries (CIBIL, Experian, Equifax, CRIF) — its own top-level menu. */
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
CreditBureauRoutingModule,
|
||||
CicDashboardComponent,
|
||||
CicNewEnquiryComponent,
|
||||
CicEnquiryHistoryComponent,
|
||||
CicEnquiryDetailComponent,
|
||||
CicConsentsComponent,
|
||||
CicBureauConfigComponent,
|
||||
CicApiMappingComponent
|
||||
]
|
||||
})
|
||||
export class CreditBureauModule {}
|
||||
@@ -65,6 +65,7 @@ export class PlatformMenuAccessComponent {
|
||||
{ code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' },
|
||||
{ code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' },
|
||||
{ code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' },
|
||||
{ code: 'MENU_CREDIT_BUREAU', label: 'Credit Bureau', icon: 'id-card' },
|
||||
{ code: 'MENU_SHARE', label: 'Share', icon: 'coins' },
|
||||
{ code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' },
|
||||
{ code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' },
|
||||
|
||||
Reference in New Issue
Block a user