Compare commits

...
Author SHA1 Message Date
niteshandClaude Opus 5.5 1b70fb4534 feat(cic): Credit Bureau menu - CIBIL / CIC enquiry, consent, whitelist and API mapping
Separate top-nav menu with dashboard, new enquiry, enquiry history and report,
consent register, CIC whitelist & configuration, and API mapping with preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:18:32 +05:30
23 changed files with 4173 additions and 0 deletions
+2
View File
@@ -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,
+45
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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) : [];
}
}
+681
View File
@@ -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);
}
+253
View File
@@ -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' },