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
niteshandClaude Opus 5.5 55f5c1cf32 feat(ui): longer startup splash and updated loading animation
The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%)
instead of 2, and the loading animation is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:24 +05:30
niteshandClaude Opus 5.5 6507e68203 feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD
- A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly):
  its DR button is disabled, and Save refuses any FD row left on DR (for example by a
  Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature
  closure; the clerk's "Close this account" flow is the one exception.
- Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from
  the customer's own Savings or Current account. The server enforces the funding rules
  (first and only funding, exactly the principal, the source account's owner, freeze
  status and available balance).

Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:03 +05:30
35 changed files with 4262 additions and 422 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,
@@ -184,7 +184,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -267,7 +267,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="isCreditOnly(row)"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -1644,7 +1644,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1715,7 +1715,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="isCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -2344,6 +2344,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
);
}
/**
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
* exception is the clerk's own "Close this account" flow, which raises a closure request.
*/
isFdCreditOnly(row: TxnRow): boolean {
return row.acTyp === 'FD' && !this.pendingClosure;
}
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
isCreditOnly(row: TxnRow): boolean {
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
@@ -5272,6 +5286,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
if (fdDebitRows.length > 0) {
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
'An FD can only be credited — once, with exactly its principal.'
]);
return;
}
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
@@ -5349,7 +5377,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
// (first and only funding, exactly the principal, the source account's owner and available balance).
const allowedTypes = new Set([
'SD',
'CA',
@@ -5357,13 +5387,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
const invalidAccounts = [
...drRows,
...crRows
...crRows.filter((r) => r.acTyp !== 'FD')
].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
@@ -1,16 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { HttpContextToken } from '@angular/common/http';
/**
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
* otherwise keep the bar running or make it flash on a screen that has not changed.
*/
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
@@ -16,7 +16,6 @@ import { finalize } from 'rxjs/operators';
/** Custom Services */
import { ProgressBarService } from './progress-bar.service';
import { NO_PROGRESS_BAR } from './no-progress.token';
/**
* Http Request interceptor to start/stop loading the progress bar.
@@ -30,9 +29,6 @@ export class ProgressInterceptor implements HttpInterceptor {
* and stop when a response or error is received.
*/
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.context.get(NO_PROGRESS_BAR)) {
return next.handle(request);
}
this.progressBarService.increase();
return next.handle(request).pipe(
finalize(() => {
+45 -5
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>
@@ -465,11 +469,6 @@
*mifosxHasPermission="'MENU_TASKS'"
>
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
@if (approvalTotal > 0) {
<span class="ni-badge" title="Requests waiting for approval">{{
approvalTotal > 99 ? '99+' : approvalTotal
}}</span>
}
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
@@ -513,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"
@@ -1667,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 -->
-15
View File
@@ -1082,18 +1082,3 @@ $gray-700: #334155;
width: $bar;
}
}
// How many requests are waiting under Approvals.
.ni-badge {
min-width: 16px;
height: 16px;
margin-left: 4px;
padding: 0 5px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
-15
View File
@@ -17,7 +17,6 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
import { map, filter } from 'rxjs/operators';
/** Custom Services */
@@ -69,11 +68,6 @@ export class ShellComponent implements OnInit, OnDestroy {
private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService);
private tenantMenuAccessService = inject(TenantMenuAccessService);
private approvalCounts = inject(ApprovalCountsService);
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
approvalTotal = 0;
private approvalTotalSub?: Subscription;
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
@@ -150,13 +144,6 @@ export class ShellComponent implements OnInit, OnDestroy {
* Subscribes to progress bar to update its mode.
*/
ngOnInit() {
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
this.approvalTotal = total;
this.cdr.markForCheck();
});
this.approvalCounts.start();
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
this.progressBarMode = mode;
this.cdr.detectChanges();
@@ -381,8 +368,6 @@ export class ShellComponent implements OnInit, OnDestroy {
* Unsubscribes from progress bar.
*/
ngOnDestroy() {
this.approvalTotalSub?.unsubscribe();
this.approvalCounts.stop();
if (this.progressBar$) {
this.progressBar$.unsubscribe();
}
@@ -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' },
-193
View File
@@ -1,193 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, OnDestroy, inject } from '@angular/core';
import { HttpClient, HttpContext, HttpDownloadProgressEvent, HttpEvent, HttpEventType } from '@angular/common/http';
import { BehaviorSubject, Subscription } from 'rxjs';
import { AuthenticationService } from '../core/authentication/authentication.service';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
import { NO_PROGRESS_BAR } from '../core/progress-bar/no-progress.token';
/** What the server pushes: the total and, for each Approval Center tab with something waiting, its number. */
interface ApprovalCounts {
total: number;
counts: Record<string, number>;
}
/** After a stream drops, the first wait before reconnecting; it doubles up to the cap. */
const RETRY_MS = 2_000;
const RETRY_MAX_MS = 60_000;
/** A stream that ends sooner than this after opening is treated as a failure. */
const MIN_STREAM_MS = 3_000;
/**
* How many requests wait on each Approval Center tab, and the total for the Approvals menu.
*
* The numbers are pushed, not polled: the service opens one Server-Sent Events stream, GET /approvals/counts/stream, and
* the server writes a `counts` event the moment the numbers change, so a request raised or actioned by anyone moves the
* badge within about a second with no reloads and no traffic while nothing changes. The server answers numbers only
* (no rows); a tab still loads its own list when it is opened, and applies its own table, status, office scope and
* permission, so a tab the login may not see is absent.
*
* The stream goes through HttpClient (not EventSource, which cannot send the login's headers), so the usual
* authentication and tenant headers apply. It is silent (see SILENT_HTTP_ERROR, NO_PROGRESS_BAR): no error popup and no
* progress bar. The server ends a stream every few minutes and the service reconnects at once; after a failure it keeps
* the last numbers and retries with a growing wait.
*
* A browser tab that is hidden keeps its stream: it is one idle connection, and the badge is right when it is shown.
*/
@Injectable({ providedIn: 'root' })
export class ApprovalCountsService implements OnDestroy {
private http = inject(HttpClient);
private authenticationService = inject(AuthenticationService);
private readonly countsSubject = new BehaviorSubject<Record<string, number>>({});
private readonly totalSubject = new BehaviorSubject<number>(0);
/** Pending count per tab key; a tab with nothing waiting is absent. */
readonly counts$ = this.countsSubject.asObservable();
/** Everything waiting across all the tabs the signed-in user can see. */
readonly total$ = this.totalSubject.asObservable();
private active = false;
private failures = 0;
private stream: Subscription | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
/** Whether this login reaches the Approval Center at all, so nothing is asked for everyone else. */
get canSeeApprovals(): boolean {
const permissions = this.authenticationService.getCredentials()?.permissions ?? [];
return permissions.includes('ALL_FUNCTIONS') || permissions.includes('MENU_TASKS');
}
/** Opens the stream (once; a second call changes nothing). */
start(): void {
if (this.active || !this.canSeeApprovals) return;
this.active = true;
// After a page refresh the last numbers show at once; the stream's first event (a moment later) makes them exact.
this.restore();
this.connect();
}
stop(): void {
this.active = false;
this.stream?.unsubscribe();
this.stream = null;
if (this.retryTimer) clearTimeout(this.retryTimer);
this.retryTimer = null;
this.failures = 0;
}
ngOnDestroy(): void {
this.stop();
}
private connect(): void {
if (!this.active) return;
// Text already handled, so each progress event is only read from where the last one ended.
let consumed = 0;
const startedAt = Date.now();
this.stream = this.http
.get('/approvals/counts/stream', {
observe: 'events',
reportProgress: true,
responseType: 'text',
headers: { Accept: 'text/event-stream' },
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
})
.subscribe({
next: (event: HttpEvent<string>) => {
if (event.type !== HttpEventType.DownloadProgress) return;
const text = (event as HttpDownloadProgressEvent).partialText ?? '';
consumed = this.read(text, consumed);
},
// The server closes a stream after a few minutes: connect again straight away. One that ended almost at once
// was not a stream at all (a proxy answering for it, say), so that is a failure and backs off.
complete: () => (Date.now() - startedAt < MIN_STREAM_MS ? this.retryLater() : this.connect()),
error: () => this.retryLater()
});
}
private retryLater(): void {
this.failures += 1;
// While the stream is down (an older server without it, a proxy that blocks it) the badge is still kept right by a
// plain read each time the retry comes round; the stream takes over again as soon as it connects.
this.http
.get<ApprovalCounts>('/approvals/counts', {
params: { fresh: 'true' },
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
})
.subscribe({
next: (answer) => {
this.countsSubject.next(answer.counts ?? {});
this.totalSubject.next(answer.total ?? 0);
this.remember(answer);
},
error: () => undefined
});
const wait = Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** (this.failures - 1));
this.retryTimer = setTimeout(() => this.connect(), wait);
}
/** Reads every complete event after `from` (events end with a blank line) and returns how far it got. */
private read(text: string, from: number): number {
let position = from;
let end = text.indexOf('\n\n', position);
while (end !== -1) {
this.handle(text.slice(position, end));
position = end + 2;
end = text.indexOf('\n\n', position);
}
return position;
}
private handle(block: string): void {
let name = 'message';
const data: string[] = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) name = line.slice(6).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
// Comment lines (the server's keep-alive pings) carry no event and are ignored.
if (name !== 'counts' || data.length === 0) return;
try {
const answer = JSON.parse(data.join('\n')) as ApprovalCounts;
this.failures = 0;
this.countsSubject.next(answer.counts ?? {});
this.totalSubject.next(answer.total ?? 0);
this.remember(answer);
} catch {
// A damaged event is skipped; the next change sends the numbers again.
}
}
/** Storage is per login, so one user's numbers are never shown to the next person on the same browser. */
private storageKey(): string {
return `approvalCounts:${this.authenticationService.getCredentials()?.username ?? ''}`;
}
private remember(answer: ApprovalCounts): void {
try {
localStorage.setItem(this.storageKey(), JSON.stringify({ total: answer.total, counts: answer.counts }));
} catch {
// Storage can be blocked or full; the badge simply starts empty after a refresh.
}
}
private restore(): void {
try {
const saved = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null') as ApprovalCounts | null;
if (saved && typeof saved.total === 'number' && saved.counts) {
this.countsSubject.next(saved.counts);
this.totalSubject.next(saved.total);
}
} catch {
// Unreadable or missing: start empty and wait for the first event.
}
}
}
@@ -31,9 +31,6 @@
>
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
<span>Checker Inbox</span>
@if (count('checker-inbox')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('checker-inbox') }}</span>
}
</a>
<a
class="ac-tab"
@@ -43,16 +40,10 @@
>
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
<span>Client Approval</span>
@if (count('client-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('client-approval') }}</span>
}
</a>
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Loan Approval</span>
@if (count('loan-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-approval') }}</span>
}
</a>
<a
class="ac-tab"
@@ -62,9 +53,6 @@
>
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Loan Disbursal</span>
@if (count('loan-disbursal')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-disbursal') }}</span>
}
</a>
<a
class="ac-tab"
@@ -74,9 +62,6 @@
>
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
<span>Reschedule Loan</span>
@if (count('reschedule-loan')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('reschedule-loan') }}</span>
}
</a>
<a
class="ac-tab"
@@ -86,9 +71,6 @@
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span>
@if (count('pending-account')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-account') }}</span>
}
</a>
<a
class="ac-tab"
@@ -98,9 +80,6 @@
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
@if (count('pending-share-accounts')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-share-accounts') }}</span>
}
</a>
<a
class="ac-tab"
@@ -110,9 +89,6 @@
>
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Product Approval</span>
@if (count('product-approval')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('product-approval') }}</span>
}
</a>
<a
class="ac-tab"
@@ -122,9 +98,6 @@
>
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span>
@if (count('pending-savings-account-edits')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-savings-account-edits') }}</span>
}
</a>
<a
class="ac-tab"
@@ -134,9 +107,6 @@
>
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span>
@if (count('pending-client-edits')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-client-edits') }}</span>
}
</a>
<a
class="ac-tab"
@@ -146,9 +116,6 @@
>
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
<span>Pending User Approvals</span>
@if (count('pending-user-edits')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-user-edits') }}</span>
}
</a>
<a
class="ac-tab"
@@ -176,9 +143,6 @@
>
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span>
@if (count('withdrawal-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('withdrawal-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -188,9 +152,6 @@
>
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
<span>Cash Transfers</span>
@if (count('cash-transfers')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('cash-transfers') }}</span>
}
</a>
<a
class="ac-tab"
@@ -200,9 +161,6 @@
>
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
<span>Voucher Corrections</span>
@if (count('voucher-corrections')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('voucher-corrections') }}</span>
}
</a>
<a
class="ac-tab"
@@ -212,9 +170,6 @@
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Share Withdrawal Requests</span>
@if (count('share-withdrawal-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('share-withdrawal-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -224,9 +179,6 @@
>
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>Dividend Requests</span>
@if (count('dividend-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dividend-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -236,9 +188,6 @@
>
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
<span>Share Addition Requests</span>
@if (count('share-addition-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('share-addition-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -248,9 +197,6 @@
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Deposit Requests</span>
@if (count('deposit-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('deposit-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -260,16 +206,10 @@
>
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span>
@if (count('disbursement-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('disbursement-requests') }}</span>
}
</a>
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>DDS Approvals</span>
@if (count('dds-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
}
</a>
<a
class="ac-tab"
@@ -279,9 +219,6 @@
>
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
<span>Cheque Book Requests</span>
@if (count('chequebook-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('chequebook-requests') }}</span>
}
</a>
<a
class="ac-tab"
@@ -291,41 +228,26 @@
>
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
<span>Closure Requests</span>
@if (count('closure-requests')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('closure-requests') }}</span>
}
</a>
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
<span>DD Approvals</span>
@if (count('dd-issue-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-issue-approvals') }}</span>
}
</a>
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
<span>DD Reprint Requests</span>
@if (count('dd-reprint-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-reprint-approvals') }}</span>
}
</a>
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span>
@if (count('dd-revoke-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-revoke-approvals') }}</span>
}
</a>
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
re-KYC due date together, so it belongs with the other maker-checker queues. -->
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
<span>Risk Category Changes</span>
@if (count('risk-change-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('risk-change-approvals') }}</span>
}
</a>
</nav>
@@ -103,30 +103,11 @@ $amber: #f59e0b;
color: inherit;
}
// How many requests wait on the tab.
&__count {
min-width: 17px;
height: 17px;
padding: 0 5px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 17px;
text-align: center;
}
&:hover:not(.ac-tab--active) {
background: #f1f5f9;
color: $navy;
}
&.ac-tab--active .ac-tab__count {
background: #fff;
color: $blue-d;
}
&.ac-tab--active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
color: #fff;
@@ -7,12 +7,10 @@
*/
/** Angular Imports */
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Component } from '@angular/core';
import { RouterLinkActive, RouterOutlet } from '@angular/router';
import { Subscription } from 'rxjs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ApprovalCountsService } from '../approval-counts.service';
/**
* Approval Center — checker inbox + client/loan approval workflows.
@@ -33,24 +31,6 @@ import { ApprovalCountsService } from '../approval-counts.service';
FaIconComponent
]
})
export class CheckerInboxAndTasksComponent implements OnInit, OnDestroy {
private counts = inject(ApprovalCountsService);
/** How many requests wait on each tab, by the tab's route segment; a tab with none is absent. */
pending: Record<string, number> = {};
private subscriptions = new Subscription();
ngOnInit(): void {
this.subscriptions.add(this.counts.counts$.subscribe((c) => (this.pending = c)));
// The same live counts the Approvals menu badge listens to; they move as requests are raised or actioned.
this.counts.start();
}
count(key: string): number {
return this.pending[key] ?? 0;
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
export class CheckerInboxAndTasksComponent {
constructor() {}
}
+42 -40
View File
@@ -45,15 +45,17 @@
"hasMask": false,
"ao": 0,
"ks": { "a": { "a": 0, "k": [
-17.052704811096184,
-15.0467209815979
0,
0,
0
], "ix": 1 }, "s": { "a": 0, "k": [
362.228,
362.228,
394,
394,
100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
188.2303284168625,
195.49656352277756
250,
250,
0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [
{
@@ -341,16 +343,16 @@
"g": { "p": 3, "k": { "a": 0, "k": [
0,
1,
0.4470588235294118,
0,
0.6,
0.2,
0.485,
0.9725490196078431,
0.2235294117647059,
0.5019607843137255,
1,
0.9411764705882353,
0,
1
0.6,
0.2,
1,
1,
0.6,
0.2
], "ix": 8 } },
"t": 1,
"a": { "a": 0, "k": 0 },
@@ -363,7 +365,7 @@
"lj": 1,
"ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2, "ix": 10 }
"w": { "a": 0, "k": 2.5, "ix": 10 }
},
{
"ty": "fl",
@@ -402,8 +404,8 @@
"o": {
"a": 1,
"k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
{ "s": [360], "t": 90.0000036657751 }
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 },
{ "s": [388], "t": 90.0000036657751 }
],
"ix": 3
},
@@ -437,7 +439,7 @@
250,
250,
0
], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [
{
"ty": "gr",
@@ -723,16 +725,16 @@
], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [
0,
0,
0.7764705882352941,
1,
1,
1,
0.485,
0.24705882352941178,
0.38823529411764707,
1,
1,
0.49411764705882355,
0,
1,
1,
1,
1,
1
], "ix": 8 } },
"t": 1,
@@ -746,7 +748,7 @@
"lj": 1,
"ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 0.5, "ix": 10 }
"w": { "a": 0, "k": 2.5, "ix": 10 }
},
{
"ty": "fl",
@@ -813,14 +815,14 @@
0,
0
], "ix": 1 }, "s": { "a": 0, "k": [
362.228,
362.228,
330,
330,
100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
250,
250,
0
], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [
{
"ty": "gr",
@@ -1106,17 +1108,17 @@
], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [
0,
1,
0.8235294117647058,
0,
0.0784,
0.7059,
0.2118,
0.485,
0.5019607843137255,
0.8117647058823529,
0.5019607843137255,
0.0784,
0.7059,
0.2118,
1,
0,
0.8,
1
0.0784,
0.7059,
0.2118
], "ix": 8 } },
"t": 1,
"a": { "a": 0, "k": 0 },
@@ -1129,7 +1131,7 @@
"lj": 1,
"ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 0.5, "ix": 10 }
"w": { "a": 0, "k": 2.5, "ix": 10 }
},
{
"ty": "fl",
@@ -1168,8 +1170,8 @@
"o": {
"a": 1,
"k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
{ "s": [360], "t": 90.0000036657751 }
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 },
{ "s": [332], "t": 90.0000036657751 }
],
"ix": 3
},
+6 -6
View File
@@ -325,10 +325,10 @@
};
document.head.appendChild(script);
// Progress simulation stages (Guaranteed ~2 seconds duration on refresh)
// Progress simulation stages (Guaranteed ~4 seconds duration on refresh: 2% every 90ms reaches 90% in about 4s)
var interval = setInterval(function () {
if (progress < 90) {
progress += 5;
progress += 2;
} else if (angularReady && progress < 100) {
progress += 5;
}
@@ -351,7 +351,7 @@
}
}, 90);
// Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation
// Enforce minimum hold time (4000ms = 4 seconds) so refresh always shows the animation
setTimeout(function () {
minTimePassed = true;
if (angularReady) {
@@ -359,7 +359,7 @@
if (bar) bar.style.width = '100%';
dismissSplash();
}
}, 2000);
}, 4000);
function dismissSplash() {
if (dismissed) return;
@@ -387,12 +387,12 @@
}
};
// Safety fallback (Max 7s in case of backend network failure)
// Safety fallback (Max 9s in case of backend network failure)
setTimeout(function () {
angularReady = true;
minTimePassed = true;
dismissSplash();
}, 7000);
}, 9000);
})();
</script>