From 1b70fb4534eb44fc6124ac728cb39a6f54863b76 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 19 Oct 2026 12:18:32 +0530 Subject: [PATCH] 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 --- src/app/app.module.ts | 2 + src/app/core/shell/shell.component.html | 45 ++ .../cic-api-mapping.component.html | 417 +++++++++++ .../cic-api-mapping.component.ts | 308 ++++++++ .../cic-bureau-config.component.html | 234 ++++++ .../cic-bureau-config.component.ts | 178 +++++ .../cic-consents/cic-consents.component.html | 253 +++++++ .../cic-consents/cic-consents.component.ts | 187 +++++ .../cic-dashboard.component.html | 169 +++++ .../cic-dashboard/cic-dashboard.component.ts | 83 +++ .../cic-enquiry-detail.component.html | 96 +++ .../cic-enquiry-detail.component.ts | 56 ++ .../cic-enquiry-history.component.html | 127 ++++ .../cic-enquiry-history.component.ts | 137 ++++ .../cic-new-enquiry.component.html | 297 ++++++++ .../cic-new-enquiry.component.ts | 260 +++++++ .../cic-report-view.component.html | 164 +++++ .../cic-report-view.component.ts | 108 +++ src/app/credit-bureau/cic-shared.scss | 681 ++++++++++++++++++ src/app/credit-bureau/cic.service.ts | 253 +++++++ .../credit-bureau-routing.module.ts | 82 +++ src/app/credit-bureau/credit-bureau.module.ts | 35 + .../menu-access/menu-access.component.ts | 1 + 23 files changed, 4173 insertions(+) create mode 100644 src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.html create mode 100644 src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.ts create mode 100644 src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.html create mode 100644 src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts create mode 100644 src/app/credit-bureau/cic-consents/cic-consents.component.html create mode 100644 src/app/credit-bureau/cic-consents/cic-consents.component.ts create mode 100644 src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html create mode 100644 src/app/credit-bureau/cic-dashboard/cic-dashboard.component.ts create mode 100644 src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.html create mode 100644 src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.ts create mode 100644 src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.html create mode 100644 src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.ts create mode 100644 src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.html create mode 100644 src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.ts create mode 100644 src/app/credit-bureau/cic-report-view/cic-report-view.component.html create mode 100644 src/app/credit-bureau/cic-report-view/cic-report-view.component.ts create mode 100644 src/app/credit-bureau/cic-shared.scss create mode 100644 src/app/credit-bureau/cic.service.ts create mode 100644 src/app/credit-bureau/credit-bureau-routing.module.ts create mode 100644 src/app/credit-bureau/credit-bureau.module.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index eba0b2e26..00ebb2abc 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index dab25a011..3223ce52d 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -407,6 +407,10 @@ Loan Origination + + Credit Bureau + + Advances @@ -508,6 +512,11 @@ >Loans + + Credit BureauCIC + + + +
+ +
+
Bureau Enquiry
+ + + + +
+ +
+
CIC Setup
+ + +
+
+
+
diff --git a/src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.html b/src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.html new file mode 100644 index 000000000..44b1b042b --- /dev/null +++ b/src/app/credit-bureau/cic-api-mapping/cic-api-mapping.component.html @@ -0,0 +1,417 @@ + + +
+ + +
+
+
+ + +
+
+ + +
+
+ @if (bureau) { + {{ bureau.environment }} + {{ + bureau.whitelisted ? 'Whitelisted' : 'Not whitelisted' + }} + @if (endpoint && !endpoint.exists) { + Not configured yet + } + @if (dirty) { + Unsaved changes + } + } +
+
+
+ + @if (error) { +
{{ error }}
+ } + @if (info) { +
{{ info }}
+ } + @if (bureau?.environment === 'SIMULATOR') { +
+ In SIMULATOR mode the built-in test bureau answers in the shape of the seeded generic mapping. When moving to the + bureau's UAT / PRODUCTION, change the paths below to match the bureau's own API specification. +
+ } + + @if (loading) { +
Loading…
+ } + + @if (endpoint && !loading) { +
+ + + + +
+ + + @if (tab === 'request') { +
+

+ CBS field → bureau request + Paths like consumer.ids[0].number build nested JSON and arrays +

+
+ + + + + + + + + + + + + + @for (r of requestRows; track $index; let i = $index) { + + + + + + + + + + } + +
#CBS fieldBureau JSON pathTransformFixed / default valueMandatory
{{ i + 1 }} + + + + + + + + + + + + + +
+
+
+ + A mandatory field missing on the customer stops the enquiry before the bureau is called. +
+
+ } + + + @if (tab === 'response') { +
+

+ Bureau response → CBS result + "score" drives the score shown everywhere; "hit" = false marks a no-hit; "errorCode" marks a failure +

+
+ + + + + + + + + + + + + @for (r of responseRows; track $index; let i = $index) { + + + + + + + + + } + +
#Bureau response JSON pathCBS result fieldTransformDefault
{{ i + 1 }} + + + + + + + + + +
+
+ + @for (f of resultFieldList; track f.key) { + + } + +
+ + Standard result fields: {{ resultFieldList.length }}; any other simple name is kept as an extra + field. +
+
+ } + + + @if (tab === 'endpoint') { +
+

Endpoint

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

+ Preview + builds the request from the saved mapping for a real customer — nothing is sent +

+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + @if (previewResult) { + @if (previewResult.missingMandatory?.length) { +
+ This customer would be refused — missing mandatory fields: +
    + @for (m of previewResult.missingMandatory; track m) { +
  • {{ m }}
  • + } +
+
+ } @else { +
All mandatory fields are present for this customer.
+ } +
+
+

Request body sensitive values masked

+
{{ prettyBody(previewResult.body) }}
+
+
+

Call

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

CIC Whitelist & Configuration

+

+ Choose which Credit Information Companies this bank may enquire, and how each is reached. Only whitelisted + bureaus appear on the enquiry screen. +

+
+ +
+ + @if (error) { +
{{ error }}
+ } + @if (info) { +
{{ info }}
+ } + + @if (loading && !bureaus.length) { +
Loading…
+ } + +
+ @for (b of bureaus; track b.id) { +
+
+
+
+ {{ b.code }} + {{ b.environment }} + @if (b.isDefault) { + Default + } +
+
{{ b.name }}
+
+ +
+ +
+
Base URL
+
{{ b.environment === 'SIMULATOR' ? '(simulator)' : b.baseUrl || '— not set —' }}
+
Member code
+
{{ b.memberCode || '—' }}
+
Authentication
+
+ {{ authLabels[b.authType] || b.authType }} + @if (b.authType !== 'NONE') { + {{ + b.hasSecret ? 'credential stored' : 'no credential' + }} + } +
+
Endpoints
+
{{ b.activeEndpoints }} active
+
Report reuse
+
{{ b.reportValidityDays ? 'within ' + b.reportValidityDays + ' days' : 'always pull fresh' }}
+
Charge / enquiry
+
₹{{ b.enquiryCharge | number: '1.0-2' }}
+
Last test
+
+ @if (b.lastTestStatus) { + {{ b.lastTestStatus }} + {{ b.lastTestedOn | slice: 0 : 16 }} +
{{ b.lastTestMessage }}
+ } @else { + never + } +
+
Enquiries (30d)
+
{{ b.enquiriesLast30Days }}
+
+ +
+ + + API Mapping +
+
+ } +
+ +
+ Going live with a bureau: 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. +
+ + @if (editing) { +
+
+

Configure {{ editing.code }}

+
+
+ + +
+
+ + +
+
+ + + Endpoint paths from API Mapping are appended to this. PRODUCTION requires https://. +
+
+ + +
+
+ + +
+ @if (form.authType === 'BASIC') { +
+ + +
+ } + @if (form.authType === 'API_KEY') { +
+ + +
+ } + @if (form.authType === 'OAUTH2_CLIENT_CREDENTIALS') { +
+ + +
+
+ + +
+ } + @if (needsSecret(form.authType)) { +
+ + + @if (editing.hasSecret) { + + } +
+ } +
+ + +
+
+ + + 0 = always pull a fresh report. Reuse avoids a second hard enquiry and its charge. +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ } +
diff --git a/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts b/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts new file mode 100644 index 000000000..97e1981c6 --- /dev/null +++ b/src/app/credit-bureau/cic-bureau-config/cic-bureau-config.component.ts @@ -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 = { + 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)); + } +} diff --git a/src/app/credit-bureau/cic-consents/cic-consents.component.html b/src/app/credit-bureau/cic-consents/cic-consents.component.html new file mode 100644 index 000000000..eaaf21331 --- /dev/null +++ b/src/app/credit-bureau/cic-consents/cic-consents.component.html @@ -0,0 +1,253 @@ + + +
+
+
+

Consent Register

+

Borrowers' consents to pull their credit report. No enquiry runs without an active, unexpired consent.

+
+
+ +
+
+ + @if (error) { +
{{ error }}
+ } + @if (info) { +
{{ info }}
+ } + + @if (capturing) { +
+

Record a consent

+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ @if (form.consentMode === 'OTP') { +
+ + +
+ } @else { +
+ + +
+ } +
+ + +
+
+ + +
+
+
+ + +
+ + +
+ } + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + @if (loading) { +
Loading…
+ } @else if (!items.length) { +
No consent recorded yet.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (c of items; track c.id) { + + + + + + + + + + + + } + +
Consent refCustomerBureauPurposeModeValidStatusCaptured by
{{ c.consentRef }} + {{ c.clientName }} #{{ c.clientId }} + @if (c.applicationNo) { +
App {{ c.applicationNo }}
+ } +
{{ c.bureauCode }}{{ c.purpose }}{{ c.consentMode }}{{ c.validFrom }} → {{ c.validUntil }} + {{ c.status }} + + {{ c.capturedByName }} +
{{ c.capturedOn | slice: 0 : 16 }}
+
+ + @if (c.status === 'ACTIVE') { + + } +
+
+
+ {{ offset + 1 }}–{{ offset + items.length }} of {{ total }} +
+ + +
+
+ } +
+ + @if (revoking) { +
+
+

Revoke consent {{ revoking.consentRef }}

+

+ {{ revoking.clientName }} — no further enquiry can run under this consent. Enquiries already made are not + affected. +

+
+ + +
+
+ + +
+
+
+ } + + @if (viewing) { +
+
+

Consent {{ viewing.consentRef }}

+
+
Customer
+
{{ viewing.clientName }} (#{{ viewing.clientId }})
+
Bureau
+
{{ viewing.bureauCode }}
+
Purpose / mode
+
{{ viewing.purpose }} · {{ viewing.consentMode }}
+
Document / OTP ref
+
{{ viewing.documentRef || viewing.otpReference || '—' }}
+
Valid
+
{{ viewing.validFrom }} → {{ viewing.validUntil }}
+
Status
+
{{ viewing.status }}{{ viewing.revokedReason ? ' — ' + viewing.revokedReason : '' }}
+
Captured
+
+ {{ viewing.capturedOn | slice: 0 : 19 }} by {{ viewing.capturedByName }} ({{ + viewing.ipAddress || 'IP n/a' + }}) +
+
+
+ +
+ {{ viewing.consentText }} +
+
+
+
+
+ } +
diff --git a/src/app/credit-bureau/cic-consents/cic-consents.component.ts b/src/app/credit-bureau/cic-consents/cic-consents.component.ts new file mode 100644 index 000000000..a9b13914d --- /dev/null +++ b/src/app/credit-bureau/cic-consents/cic-consents.component.ts @@ -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.')) + }); + } +} diff --git a/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html b/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html new file mode 100644 index 000000000..cdd9cd287 --- /dev/null +++ b/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.html @@ -0,0 +1,169 @@ + + +
+
+
+

Credit Bureau Dashboard

+

+ Enquiries to the Credit Information Companies (CIBIL, Experian, Equifax, CRIF High Mark) over the last 30 days. +

+
+
+ + New Enquiry +
+
+ + @if (error) { +
{{ error }}
+ } + @if (loading && !data) { +
Loading…
+ } + + @if (data) { + @if (whitelistedCount === 0) { +
+ No credit bureau is whitelisted yet, so no enquiry can be run. + Open CIC Whitelist & Configuration to enable one. +
+ } + +
+
+
Enquiries today
+
{{ data.enquiriesToday }}
+
{{ data.enquiriesLast30Days }} in the last 30 days
+
+
+
Success rate
+
{{ successRate }}%
+
{{ statusCount('FAILED') }} failed · {{ statusCount('NO_HIT') }} no-hit
+
+
+
Average score
+
+ {{ data.averageScoreLast30Days ? (data.averageScoreLast30Days | number: '1.0-0') : '—' }} +
+
{{ scoreBand(data.averageScoreLast30Days).label }}
+
+
+
Active consents
+
{{ data.activeConsents }}
+
+ Charges ₹{{ data.chargesLast30Days | number: '1.0-2' }} · {{ statusCount('REUSED') }} reports reused +
+
+
+ +
+
+

+ Bureaus {{ whitelistedCount }} of {{ data.bureaus.length }} whitelisted +

+
+ + + + + + + + + + + + @for (b of data.bureaus; track b.id) { + + + + + + + + } + +
BureauWhitelistedEnvironmentEnquiries (30d)Failed
+ {{ b.code }} + @if (b.isDefault) { + Default + } +
{{ b.name }}
+
+ {{ b.whitelisted ? 'Yes' : 'No' }} + + {{ b.environment }} + {{ b.enquiries }}{{ b.failed }}
+
+
+ +
+

Score spread successful enquiries, 30 days

+ @for (band of data.scoreBandsLast30Days; track band.label) { +
+
+ {{ band.label }}{{ band.count }} +
+
+
+ } +
+
+ +
+

+ Recent enquiries + View all +

+ @if (!data.recent?.length) { +
No enquiry yet.
+ } @else { +
+ + + + + + + + + + + + + + @for (e of data.recent; track e.id) { + + + + + + + + + + } + +
ReferenceCustomerBureauStatusScoreByWhen
{{ e.enquiryRef }}{{ e.clientName }}{{ e.bureauCode }} + {{ e.status }} + + {{ e.score ?? '—' }} + {{ e.requestedByName }}{{ e.requestedOn | slice: 0 : 16 }}
+
+ } +
+ } +
diff --git a/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.ts b/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.ts new file mode 100644 index 000000000..273de9f6f --- /dev/null +++ b/src/app/credit-bureau/cic-dashboard/cic-dashboard.component.ts @@ -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 + ]); + } +} diff --git a/src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.html b/src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.html new file mode 100644 index 000000000..f7edac05b --- /dev/null +++ b/src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.html @@ -0,0 +1,96 @@ + + +
+
+
+

Enquiry {{ enquiry?.enquiryRef ?? '' }}

+

{{ enquiry?.clientName }} · {{ enquiry?.bureauName ?? enquiry?.bureauCode }}

+
+
+ Back to history + + @if (enquiry) { + Enquire again + } +
+
+ + @if (error) { +
{{ error }}
+ } + + @if (enquiry) { +
+
+
Customer
+
+ {{ enquiry.clientName }} (client #{{ enquiry.clientId }}) +
+
Bureau / environment
+
{{ enquiry.bureauCode }} · {{ enquiry.environment }}
+
Type / purpose
+
{{ enquiry.enquiryType }} · {{ enquiry.purpose }}
+
Amount
+
₹{{ enquiry.loanAmount | number: '1.0-2' }}
+
Loan application
+
{{ enquiry.applicationNo || '—' }}
+
Consent
+
{{ enquiry.consentRef || '—' }}
+ @if (enquiry.reusedFromRef) { +
Reused report of
+
{{ enquiry.reusedFromRef }}
+ } +
Charge
+
₹{{ enquiry.chargeAmount ?? 0 | number: '1.0-2' }}
+
Requested
+
{{ enquiry.requestedOn | slice: 0 : 19 }} by {{ enquiry.requestedByName }}
+
Response time
+
+ {{ enquiry.durationMs ?? 0 }} ms + @if (enquiry.httpStatus) { + · HTTP {{ enquiry.httpStatus }} + } +
+
+
+ +
+ + + +
+ + @if (tab === 'report') { + + } + @if (tab === 'request') { +
+

+ Request PAN, mobile and e-mail are masked; credentials are never stored +

+
{{ pretty(enquiry.requestMasked) }}
+
+ } + @if (tab === 'response') { +
+

Raw response as returned by the bureau

+
{{ pretty(enquiry.responseRaw) }}
+
+ } + } +
diff --git a/src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.ts b/src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.ts new file mode 100644 index 000000000..f156610b0 --- /dev/null +++ b/src/app/credit-bureau/cic-enquiry-detail/cic-enquiry-detail.component.ts @@ -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(); + } +} diff --git a/src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.html b/src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.html new file mode 100644 index 000000000..13f05d004 --- /dev/null +++ b/src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.html @@ -0,0 +1,127 @@ + + +
+
+
+

Enquiry History

+

Every credit bureau enquiry made from this bank, with its outcome and score.

+
+
+ + New Enquiry +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + @if (error) { +
{{ error }}
+ } + +
+ @if (loading) { +
Loading…
+ } @else if (!items.length) { +
No enquiry matches these filters.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (e of items; track e.id) { + + + + + + + + + + + + } + +
ReferenceCustomerBureauPurposeAmountStatusScoreRequested byWhen
+ {{ e.enquiryRef }} + @if (e.environment === 'SIMULATOR') { + SIM + } + + {{ e.clientName }} + @if (e.applicationNo) { +
App {{ e.applicationNo }}
+ } +
{{ e.bureauCode }}{{ e.purpose }}₹{{ e.loanAmount | number: '1.0-0' }} + {{ e.status }} + + {{ e.score ?? '—' }} + {{ e.requestedByName }}{{ e.requestedOn | slice: 0 : 16 }}
+
+
+ {{ offset + 1 }}–{{ offset + items.length }} of {{ total }} +
+ + +
+
+ } +
+
diff --git a/src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.ts b/src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.ts new file mode 100644 index 000000000..ff185744d --- /dev/null +++ b/src/app/credit-bureau/cic-enquiry-history/cic-enquiry-history.component.ts @@ -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); + } +} diff --git a/src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.html b/src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.html new file mode 100644 index 000000000..b5d7b331c --- /dev/null +++ b/src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.html @@ -0,0 +1,297 @@ + + +
+
+
+

New Credit Bureau Enquiry

+

+ Pull a customer's credit report from a whitelisted Credit Information Company, under their recorded consent. +

+
+ +
+ +
+
1Customer
+
2Consent
+
3Enquiry
+
+ + @if (error) { +
{{ error }}
+ } + @if (info) { +
{{ info }}
+ } + +
+
+ +
+

+ 1. Customer + @if (client) { + + } +

+ @if (!client) { +
+
+ + +
+
+ + +
+
+ } @else { +
+
+
{{ client.name }}
+
+ Client #{{ client.id }} + @if (client.office) { + · {{ client.office }} + } +
+
+ @if (loadingProfile) { + Loading… + } +
+ @if (profile) { + @if (missingEssentials.length) { +
+ Missing on the customer record: {{ missingEssentials.join(', ') }}. Bureaus match a customer on these — update the customer's KYC before enquiring, or the enquiry will + be refused. +
+ } +
+ Data that will be sent (masked) +
+ @for (r of dataRows(); track r.label) { +
{{ r.label }}
+
{{ r.value ?? '—' }}
+ } +
+
+ } + } +
+ + + @if (client && profile) { +
+

2. Customer consent

+ @if (profile.validConsent) { +
+ Consent {{ profile.validConsent.consentRef }} ({{ profile.validConsent.consentMode }}) on record, + valid until {{ profile.validConsent.validUntil }}. +
+ @if (!showConsentForm) { + + } + } @else { +
+ 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. +
+ } + + @if (showConsentForm) { +
+
+ + +
+ @if (consent.consentMode === 'SIGNED_FORM') { +
+ + +
+ } @else if (consent.consentMode === 'OTP') { +
+ + +
+ } @else { +
+ + +
+ } +
+ + +
+
+ + +
+
+
+ + +
+ +
+ + @if (profile.validConsent) { + + } +
+ } +
+ } + + + @if (client && profile) { +
+

3. Enquiry

+
+
+ + + @if (selectedBureau) { + + {{ selectedBureau.environment }} · reuses a report up to {{ selectedBureau.reportValidityDays }} days + old · charge ₹{{ selectedBureau.enquiryCharge | number: '1.0-2' }} + + } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + @if (!profile.validConsent) { + Record the customer's consent first. + } +
+
+ } +
+ + +
+ @if (result) { +
+

+ Result + Full details +

+
+ + } @else if (profile?.recentEnquiries?.length) { +
+

Previous enquiries of this customer

+
+ + + + + + + + + + + + @for (e of profile.recentEnquiries; track e.id) { + + + + + + + + } + +
ReferenceBureauStatusScoreWhen
{{ e.enquiryRef }}{{ e.bureauCode }} + {{ e.status }} + {{ e.score ?? '—' }}{{ e.requestedOn | slice: 0 : 16 }}
+
+
+ } @else { +
+ The credit report appears here once the enquiry is run. +
+ Bureaus in SIMULATOR mode answer with a realistic test report — no real bureau is contacted and no + charge applies. +
+
+ } +
+
+
diff --git a/src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.ts b/src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.ts new file mode 100644 index 000000000..dc6e9b434 --- /dev/null +++ b/src/app/credit-bureau/cic-new-enquiry/cic-new-enquiry.component.ts @@ -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] })); + } +} diff --git a/src/app/credit-bureau/cic-report-view/cic-report-view.component.html b/src/app/credit-bureau/cic-report-view/cic-report-view.component.html new file mode 100644 index 000000000..56a03e5b4 --- /dev/null +++ b/src/app/credit-bureau/cic-report-view/cic-report-view.component.html @@ -0,0 +1,164 @@ + + +@if (enquiry.status === 'FAILED') { +
+ Enquiry failed. {{ enquiry.errorMessage }} + @if (enquiry.httpStatus) { +
HTTP status {{ enquiry.httpStatus }}
+ } +
+} +@if (enquiry.status === 'NO_HIT') { +
No record found. {{ enquiry.errorMessage }}
+} +@if (enquiry.status === 'REUSED') { +
{{ enquiry.errorMessage }}
+} + +@if (enquiry.status === 'SUCCESS' || enquiry.status === 'REUSED') { +
+
+
+
+
+ {{ enquiry.score ?? '—' }}{{ band.label }} +
+
+
+
+
+ {{ enquiry.status }} + {{ enquiry.bureauCode }} + @if (enquiry.environment === 'SIMULATOR') { + SIMULATOR — not a real bureau report + } +
+
+
Report / control no.
+
{{ result.reportId ?? '—' }}
+
Score model
+
{{ result.scoreVersion ?? '—' }}
+
Enquiry reference
+
{{ enquiry.enquiryRef }}
+
+
+
+
+ +
+
+
Accounts
+
{{ result.totalAccounts ?? '—' }}
+
{{ result.activeAccounts ?? 0 }} active
+
+
+
Outstanding
+
₹{{ result.totalOutstanding ?? 0 | number: '1.0-0' }}
+
across active accounts
+
+
+
Overdue
+
+ ₹{{ result.totalOverdue ?? 0 | number: '1.0-0' }} +
+
+ {{ result.overdueAccounts ?? 0 }} account(s) · max DPD {{ result.maxDpd ?? 0 }} + @if ((result.writtenOffAccounts ?? 0) > 0) { + · {{ result.writtenOffAccounts }} written-off + } +
+
+
+
Enquiries (6 months)
+
+ {{ result.enquiriesLast6Months ?? '—' }} +
+
credit hunger indicator
+
+
+ + @if (extraFields.length) { +
+

Other mapped fields

+
+ @for (f of extraFields; track f.key) { +
{{ f.key }}
+
{{ f.value ?? '—' }}
+ } +
+
+ } + +
+

+ Credit accounts {{ accounts.length }} reported +

+ @if (!accounts.length) { +
The bureau reported no account (or the response mapping has no "accounts" field).
+ } @else { +
+ + + + @for (k of keys(accounts[0]); track k) { + + } + + + + @for (a of accounts; track $index) { + + @for (k of keys(accounts[0]); track k) { + + } + + } + +
{{ k }}
+ @if (k === 'status') { + {{ a[k] }} + } @else { + {{ a[k] }} + } +
+
+ } +
+ +
+

+ Recent enquiries by other lenders {{ enquiries.length }} +

+ @if (!enquiries.length) { +
No enquiry reported.
+ } @else { +
+ + + + @for (k of keys(enquiries[0]); track k) { + + } + + + + @for (q of enquiries; track $index) { + + @for (k of keys(enquiries[0]); track k) { + + } + + } + +
{{ k }}
{{ q[k] }}
+
+ } +
+} diff --git a/src/app/credit-bureau/cic-report-view/cic-report-view.component.ts b/src/app/credit-bureau/cic-report-view/cic-report-view.component.ts new file mode 100644 index 000000000..5a9922715 --- /dev/null +++ b/src/app/credit-bureau/cic-report-view/cic-report-view.component.ts @@ -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 = { + 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) : []; + } +} diff --git a/src/app/credit-bureau/cic-shared.scss b/src/app/credit-bureau/cic-shared.scss new file mode 100644 index 000000000..ef56120eb --- /dev/null +++ b/src/app/credit-bureau/cic-shared.scss @@ -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); +} diff --git a/src/app/credit-bureau/cic.service.ts b/src/app/credit-bureau/cic.service.ts new file mode 100644 index 000000000..995ac8a1a --- /dev/null +++ b/src/app/credit-bureau/cic.service.ts @@ -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; + cbsFields: Record; + resultFields: Record; + transforms: Record; + 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 { + 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 { + return this.http.get(`${this.base}/catalog`, this.silent); + } + + bureaus(whitelistedOnly = false): Observable { + return this.http.get(`${this.base}/bureaus`, { + ...this.silent, + params: new HttpParams().set('whitelistedOnly', String(whitelistedOnly)) + }); + } + + updateBureau(id: number, body: any): Observable { + return this.http.put(`${this.base}/bureaus/${id}`, body, this.silent); + } + + setWhitelisted(id: number, whitelisted: boolean): Observable { + return this.http.put(`${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 { + return this.http.get(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, this.silent); + } + + saveEndpoint(bureauId: number, type: string, body: any): Observable { + return this.http.put(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, body, this.silent); + } + + preview(bureauId: number, body: any): Observable { + return this.http.post(`${this.base}/bureaus/${bureauId}/preview`, body, this.silent); + } + + clientProfile(clientId: number, bureauId?: number | null): Observable { + let params = new HttpParams(); + if (bureauId) params = params.set('bureauId', String(bureauId)); + return this.http.get(`${this.base}/clients/${clientId}/profile`, { ...this.silent, params }); + } + + consents(filter: Record): Observable> { + return this.http.get>(`${this.base}/consents`, { ...this.silent, params: this.params(filter) }); + } + + createConsent(body: any): Observable { + return this.http.post(`${this.base}/consents`, body, this.silent); + } + + revokeConsent(id: number, reason: string): Observable { + return this.http.post(`${this.base}/consents/${id}/revoke`, { reason }, this.silent); + } + + enquiries(filter: Record): Observable> { + return this.http.get>(`${this.base}/enquiries`, { + ...this.silent, + params: this.params(filter) + }); + } + + enquiry(id: number): Observable { + return this.http.get(`${this.base}/enquiries/${id}`, this.silent); + } + + enquire(body: any): Observable { + return this.http.post(`${this.base}/enquiries`, body, this.silent); + } + + dashboard(): Observable { + return this.http.get(`${this.base}/dashboard`, this.silent); + } + + private params(filter: Record): 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; + } +} diff --git a/src/app/credit-bureau/credit-bureau-routing.module.ts b/src/app/credit-bureau/credit-bureau-routing.module.ts new file mode 100644 index 000000000..a57cda8a0 --- /dev/null +++ b/src/app/credit-bureau/credit-bureau-routing.module.ts @@ -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 {} diff --git a/src/app/credit-bureau/credit-bureau.module.ts b/src/app/credit-bureau/credit-bureau.module.ts new file mode 100644 index 000000000..89c01faf7 --- /dev/null +++ b/src/app/credit-bureau/credit-bureau.module.ts @@ -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 {} diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts index c5607a687..d0dc53938 100644 --- a/src/app/platform-admin/menu-access/menu-access.component.ts +++ b/src/app/platform-admin/menu-access/menu-access.component.ts @@ -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' },