Compare commits

..
Author SHA1 Message Date
sonaliandClaude Sonnet 5.5 ec550b1798 feat(loan-products): Gold, Warehouse Receipt and Loans (EMI) product creation, view and edit
Advances A/C
- Loan against Gold: Security & Gold tab (valuation basis, appraiser, custody, revaluation on NPA,
  partial release, disbursement mode and cash limit, LTV alert, auction notice and reserve price),
  Maximum LTV cap, 1-12 month tenure band, lien/deposit fields hidden and not sent.
- Warehouse Receipt: Security & Warehouse tab (receipt type, commodity groups, valuation price source,
  inspection, insurance, disposal rules), 1-12 month tenure band, Commodity collateral type.
- Category follows the sub type; Priority Sector and CIBIL follow the category (officer's choice kept).
- Edit keeps the saved charges (filling the currency used to empty the list).
- View page shows the saved Advances settings; Review and the maker-checker diff carry the new fields.

Loans A/C (EMI)
- Edit opens in the same wizard and fills every tab from the saved record; View shows the saved settings
  (eligibility, security and guarantors, disbursement, repayment, interest, prepayment).
- Security conditions follow the sub type (property, vehicle, gold, unsecured); the sub type is now passed
  to the Security step, so its per-type defaults apply.
- Tenure band and age limits per sub type from the bank's loan policy (max 120 months); member,
  guarantor-member and income-multiple eligibility fields.
- Repayment & EMI is common to all EMI loans; cheques (PDC) are the default mode, per the policy.
- Floating-rate foreclosure for an individual is locked at nil (RBI); the duplicate "release on closure" and the
  static collateral rules table are removed.
- Term loan no longer sends the deposit-cover rule or "out of order" (CC / OD only).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:54:36 +05:30
sonali 47785f59bc Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into feature/advanceLoanProduct 2026-10-16 10:34:42 +05:30
sonali 127c2a7d01 implemented the Edit product functionality for the RD,FD,DDS,LIC 2026-10-16 10:32:18 +05:30
87 changed files with 4731 additions and 5012 deletions
-2
View File
@@ -73,7 +73,6 @@ 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';
@@ -190,7 +189,6 @@ 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)="isCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="isGlCreditOnly(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]="isCreditOnly(row)"
[disabled]="isGlCreditOnly(row)"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -1644,7 +1644,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="isGlCreditOnly(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]="isCreditOnly(row)"
[disabled]="isGlCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -2344,20 +2344,6 @@ 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
@@ -5286,20 +5272,6 @@ 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.
@@ -5377,9 +5349,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// 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).
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
const allowedTypes = new Set([
'SD',
'CA',
@@ -5387,13 +5357,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
const invalidAccounts = [
...drRows,
...crRows.filter((r) => r.acTyp !== 'FD')
...crRows
].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(', ')}`,
'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.',
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
-45
View File
@@ -407,10 +407,6 @@
<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>
@@ -512,11 +508,6 @@
><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"
@@ -1671,42 +1662,6 @@
</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 -->
@@ -1,417 +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/.
-->
<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>
@@ -1,308 +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 { 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 })
);
}
}
@@ -1,234 +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/.
-->
<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>
@@ -1,178 +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 { 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));
}
}
@@ -1,253 +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/.
-->
<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>
@@ -1,187 +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 { 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.'))
});
}
}
@@ -1,169 +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/.
-->
<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>
@@ -1,83 +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 { 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
]);
}
}
@@ -1,96 +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/.
-->
<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>
@@ -1,56 +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 { 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();
}
}
@@ -1,127 +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/.
-->
<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>
@@ -1,137 +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 { 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);
}
}
@@ -1,297 +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/.
-->
<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>
@@ -1,260 +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 { 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] }));
}
}
@@ -1,164 +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/.
-->
@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>
}
@@ -1,108 +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 { 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
@@ -1,681 +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/.
*/
/* 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
@@ -1,253 +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 { 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;
}
}
@@ -1,82 +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 { 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 {}
@@ -1,35 +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 { 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 {}
@@ -7,6 +7,27 @@
-->
<form [formGroup]="loansAccountDetailsForm">
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
<mat-option value="">All categories</mat-option>
@for (category of categories; track category.id) {
<mat-option [value]="category.id">{{ category.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
<mat-option value="">All types</mat-option>
@for (type of typeOptions; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
@@ -35,6 +56,8 @@
</mat-form-field>
</div>
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
@if (loanProductSelected) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
@@ -65,7 +65,6 @@ 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' },
@@ -96,6 +96,15 @@
>
Edit · Submit
</button>
<button
type="button"
class="lirr__icon-btn lirr__icon-btn--danger"
title="Delete draft"
aria-label="Delete draft"
(click)="deleteDraft(r, $event)"
>
<i class="fas fa-trash"></i>
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
@@ -522,3 +522,31 @@
color: #3730a3;
}
}
/* Icon-only action on a row (delete draft). */
.lirr__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--danger {
color: #dc2626;
border-color: #fecaca;
&:hover {
background: #fef2f2;
}
}
}
@@ -10,6 +10,8 @@ import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
@@ -33,6 +35,7 @@ export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
registers: RateRegister[] = [];
active?: RateRegister;
@@ -71,6 +74,22 @@ export class LoanInterestRateRegisterComponent implements OnInit {
}
}
/** Deletes a draft (a version that was never submitted) after a confirmation. */
deleteDraft(register: RateRegister, event: Event): void {
event.stopPropagation();
const label = register.name || `draft from ${this.formatShort(register.effectiveFrom)}`;
this.dialog
.open(DeleteDialogComponent, { data: { deleteContext: label } })
.afterClosed()
.subscribe((response: any) => {
if (response?.delete) {
this.service.deleteDraft(register.id);
this.snack.open('Draft deleted', 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
@@ -52,6 +52,13 @@ export class RateRegisterService {
}
}
/** Throws a draft away. Only a local, never-submitted draft can go; server versions are never touched. */
deleteDraft(id: number): void {
this.localDrafts = this.localDrafts.filter((d) => !(d.id === id && d.status === 'Draft'));
this.cache = this.cache.filter((d) => !(d.id === id && d.status === 'Draft'));
this.saveDrafts();
}
/** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void {
try {
@@ -9,7 +9,9 @@
<div class="lp-container">
<div class="lp-header">
<i class="fas fa-hand-holding-usd lp-header__icon"></i>
<h2 class="lp-header__title">{{ 'labels.heading.Create Loan Product' | translate }}</h2>
<h2 class="lp-header__title">
{{ isEdit ? 'Edit Loan Product' : ('labels.heading.Create Loan Product' | translate) }}
</h2>
</div>
<mat-tab-group
@@ -30,7 +32,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-coins lp-tab-icon"></i>
{{ 'labels.inputs.CURRENCY' | translate }}
@@ -43,10 +45,16 @@
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN
{{
advanceSubType === 'gold'
? 'SECURITY & GOLD'
: advanceSubType === 'warehouse'
? 'SECURITY & WAREHOUSE'
: 'SECURITY & LIEN'
}}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-security-lien-step
@@ -57,7 +65,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
@@ -69,7 +77,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -82,19 +90,7 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
@@ -105,7 +101,7 @@
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
@@ -118,27 +114,31 @@
}
@if (isLoanAccount) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-security-step></mifosx-loan-product-loan-security-step>
<mifosx-loan-product-loan-security-step
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-loan-security-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-amount-tenure-step></mifosx-loan-product-loan-amount-tenure-step>
<mifosx-loan-product-loan-amount-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-loan-amount-tenure-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI
@@ -148,7 +148,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -161,19 +161,7 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE
@@ -184,12 +172,27 @@
</mat-tab>
}
<!-- Common to both Advances A/C and Loans A/C (and visible even before a product type is chosen). -->
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<!--
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
@@ -206,7 +209,7 @@
}
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-undo lp-tab-icon"></i>
{{ 'labels.inputs.INTEREST REFUND' | translate }}
@@ -219,7 +222,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt lp-tab-icon"></i>
{{ 'labels.inputs.PAYMENT ALLOCATION' | translate }}
@@ -240,7 +243,7 @@
}
@if (!isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
@@ -254,7 +257,7 @@
}
-->
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-receipt lp-tab-icon"></i>
{{ 'labels.inputs.CHARGES' | translate }}
@@ -270,7 +273,7 @@
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-clock lp-tab-icon"></i>
{{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }}
@@ -291,7 +294,7 @@
}
-->
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-book lp-tab-icon"></i>
{{ 'labels.inputs.ACCOUNTING' | translate }}
@@ -302,12 +305,15 @@
[accountingRuleData]="accountingRuleData"
[loanProductFormValid]="loanProductFormValid"
[deferredIncomeRecognition]="deferredIncomeRecognition"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')?.value"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')?.value"
[productGlAccountId]="productGlAccount"
></mifosx-loan-product-accounting-step>
</div>
</mat-tab>
@if (loanProductFormValid) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-eye lp-tab-icon"></i>
{{ 'labels.inputs.PREVIEW' | translate }}
@@ -316,7 +322,7 @@
<mifosx-loan-product-preview-step
[loanProductsTemplate]="loanProductsTemplate"
[accountingRuleData]="accountingRuleData"
[loanProduct]="loanProduct"
[loanProduct]="previewLoanProduct"
(submitEvent)="submit()"
></mifosx-loan-product-preview-step>
</div>
@@ -324,6 +330,25 @@
}
</mat-tab-group>
@if (hasProductType && !loanProductFormValid) {
<div class="lp-incomplete">
<i class="fas fa-circle-info"></i>
<div>
<strong>The Review tab appears once every tab is complete.</strong>
Still to finish:
@for (item of incompleteTabs; track item.tab; let last = $last) {
<b>{{ item.tab }}</b>
@if (item.fields.length > 0) {
({{ item.fields.join(', ') }})
}
@if (!last) {
,
}
}
</div>
</div>
}
<div class="lp-action-bar">
<button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel">
{{ 'labels.buttons.Cancel' | translate }}
@@ -336,7 +361,12 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
<button
mat-raised-button
color="primary"
[disabled]="!hasProductType"
(click)="selectedTabIndex = selectedTabIndex + 1"
>
{{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
@@ -344,7 +374,7 @@
@if (loanProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
{{ isEdit ? 'Save changes' : ('labels.buttons.Submit' | translate) }}
</button>
}
</div>
@@ -90,3 +90,27 @@ $primary: #1353a0;
gap: 12px;
}
}
/* Tells the user which tabs still block the Review tab. */
.lp-incomplete {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 16px 0 0;
padding: 10px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
line-height: 1.5;
i {
margin-top: 3px;
color: #d97706;
}
b {
color: #92400e;
}
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
@@ -84,7 +84,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
LoanProductPreviewStepComponent
]
})
export class CreateLoanProductComponent implements OnInit {
export class CreateLoanProductComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
@@ -135,6 +135,8 @@ export class CreateLoanProductComponent implements OnInit {
loanProductAccountingStep: LoanProductAccountingStepComponent;
loanProductsTemplate: any;
/** True when this wizard edits a saved product (the edit route) instead of creating one. */
isEdit = false;
accountingRuleData: string[] = [];
itemsByDefault: any[] = [];
selectedTabIndex = 0;
@@ -158,22 +160,382 @@ export class CreateLoanProductComponent implements OnInit {
constructor() {
const loanProducts = this.loanProducts;
this.route.data.subscribe((data: { loanProductsTemplate: any; configurations: any }) => {
this.loanProductsTemplate = data.loanProductsTemplate;
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.route.data.subscribe(
(data: { loanProductsTemplate: any; loanProductAndTemplate: any; configurations: any }) => {
// The edit route resolves the saved product together with the template options.
this.isEdit = !!data.loanProductAndTemplate;
this.loanProductsTemplate = data.loanProductAndTemplate ?? data.loanProductsTemplate;
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
});
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
if (!this.isEdit) {
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
}
}
);
}
ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.buildAdvancedPaymentAllocation();
// Advances and Loans A/C (EMI) products are filled into the curated tabs. A product made before the type was saved
// uses the old editor instead of an empty form.
const savedGroup = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
if (this.isEdit && savedGroup !== 'advances' && savedGroup !== 'loans') {
this.router.navigate(['../edit-legacy'], { relativeTo: this.route });
}
}
ngAfterViewInit(): void {
if (this.isEdit) {
this.populateWhenReady(0);
}
}
/** The tabs of the product's type appear after the Details step sets the type; fill them once they exist. */
private populateWhenReady(attempt: number): void {
const group = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
const ready =
group === 'advances'
? !!(
this.loanProductSecurityLienStep &&
this.loanProductAdvanceLimitTenureStep &&
this.loanProductAdvanceInterestStep &&
this.loanProductRenewalClosureStep &&
this.loanProductPenalNpaStep &&
(!this.isOverdraftFacility || this.loanProductChequeBookStep)
)
: group === 'loans'
? !!(
this.loanProductLoanSecurityStep &&
this.loanProductLoanAmountTenureStep &&
this.loanProductLoanRepaymentStep &&
this.loanProductLoanInterestStep &&
this.loanProductLoanPrepaymentStep &&
this.loanProductPenalNpaStep
)
: false;
if (!ready) {
if (attempt < 60) {
setTimeout(() => this.populateWhenReady(attempt + 1), 150);
}
return;
}
if (group === 'loans') {
this.populateLoans();
} else {
this.populateAdvance();
}
}
/** Reverse of assembleLoanProduct: the saved core fields and the extension record go back into the Loans tabs. */
private populateLoans(): void {
const p = this.loanProductsTemplate;
const e = p.advanceExtensions ?? {};
const repayment = this.loanProductLoanRepaymentStep;
const interest = this.loanProductLoanInterestStep;
const frequency = repayment.frequencyOptions.find((o) => o.fineractId === p.repaymentFrequencyType?.id)?.id;
const daysInYear = interest.daysInYearOptions.find((o) => o.fineractId === p.daysInYearType?.id)?.id;
const daysInMonth = interest.daysInMonthOptions.find((o) => o.fineractId === p.daysInMonthType?.id)?.id;
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
currencyCode: p.currency?.code,
digitsAfterDecimal: p.currency?.decimalPlaces
});
this.fill(this.loanProductLoanSecurityStep.loanProductLoanSecurityForm, {
securityType: e.securityType,
securityMandatory: e.securityMandatory,
margin: e.margin,
guarantorsRequired: e.guarantorsRequired,
coApplicant: e.coApplicant,
thirdPartySecurity: e.thirdPartySecurity,
legalOpinionRequired: e.legalOpinionRequired,
propertyInsuranceRequired: e.propertyInsuranceRequired,
registerCharge: e.registerCharge,
autoReleaseOnClosure: e.releaseSecurityOnClosure,
allowMultipleSecurities: e.allowMultipleSecurities,
allowThirdPartyDeposit: e.allowThirdPartyDeposit
});
this.fill(this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenureForm, {
minAmount: p.minPrincipal,
defaultAmount: p.principal,
maxAmount: p.maxPrincipal,
minTenure: p.minNumberOfRepayments,
defaultTenure: p.numberOfRepayments,
maxTenure: p.maxNumberOfRepayments,
minAge: e.minAge,
maxAgeAtMaturity: e.maxAgeAtMaturity,
minCibil: e.minCibil,
minMonthlyIncome: e.minMonthlyIncome,
maxFoir: e.maxFoir,
maxLoansPerCustomer: e.maxLoansPerCustomer,
memberRequired: e.memberRequired,
guarantorMustBeMember: e.guarantorMustBeMember,
incomeMultiple: e.incomeMultiple,
disbursementType: e.disbursementType,
// The tranche count only applies to a multiple disbursement; a single one leaves the field at its own default.
maxTranches: e.disbursementType === 'multiple' ? p.maxTrancheCount : undefined,
disbursementMode: e.disbursementMode,
undisbursedInterest: e.undisbursedInterest,
preEmiInterest: e.preEmiInterest
});
this.fill(repayment.loanProductLoanRepaymentForm, {
repaymentType: e.repaymentType,
repaidEvery: p.repaymentEvery,
frequency,
minDaysToFirstRepayment: p.minimumDaysBetweenDisbursalAndFirstRepayment,
emiDueDay: e.emiDueDay,
holidayRule: e.holidayRule,
moratoriumMonths: e.moratoriumMonths,
moratoriumInterest: e.moratoriumInterest,
variableInstallments: e.variableInstallments,
repaymentMode: e.repaymentMode,
linkedAccountType: e.linkedAccountType,
pdcCollection: e.pdcCollection,
mandateRequired: e.mandateRequired,
appropriationOrder: e.appropriationOrder,
advanceEmi: e.advanceEmi
});
this.fill(interest.loanProductLoanInterestForm, {
interestType: e.rateType,
registerParticular: e.registerParticular,
applyCibilAdjustment: e.applyCibilAdjustment,
spread: e.spread,
rateResolvedAt: e.rateResolvedAt,
minRate: p.minInterestRatePerPeriod,
defaultRate: p.interestRatePerPeriod,
maxRate: p.maxInterestRatePerPeriod,
calcMethod: e.interestCalcMethod,
daysInYear,
daysInMonth,
calcPeriod: e.calcPeriod,
interestStartsFrom: e.interestStartsFrom,
compounding: e.compounding,
benchmark: e.benchmark,
resetFrequency: e.resetFrequency,
resetChange: e.resetChange
});
this.fill(this.loanProductLoanPrepaymentStep.loanProductLoanPrepaymentForm, {
partPrepaymentAllowed: e.partPrepaymentAllowed,
minPartPayment: e.minPartPayment,
allowedOn: e.allowedOn,
partPaymentsPerYear: e.partPaymentsPerYear,
partPrepaymentMode: e.partPrepaymentMode,
foreclosureFloatingIndividual: e.foreclosureFloatingIndividual,
foreclosureFixedIndividual: e.foreclosureFixedIndividual,
foreclosureNonIndividual: e.foreclosureNonIndividual,
foreclosureTakeover: e.foreclosureTakeover,
lockInMonths: e.lockInMonths,
interestOnPreclosure: e.interestOnPreclosure,
foreclosureQuoteValidDays: e.foreclosureQuoteValidDays,
autoCloseOnZeroBalance: e.autoCloseOnZeroBalance,
nocLetter: e.nocLetter,
restructuring: e.restructuring,
topUpAllowed: e.topUpAllowed,
balanceTransferOutAllowed: e.balanceTransferOutAllowed
});
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
penalApplicable: e.penalApplicable,
penalRate: e.penalRate,
penalAppliedOn: e.penalAppliedOn,
penalGraceDays: e.penalGraceDays,
delinquencyBucketId: p.delinquencyBucket?.id,
npaDays: p.overdueDaysForNPA,
npaTrigger: e.npaTrigger,
interestAfterNpa: e.interestAfterNpa,
smaStartAfterMonths: e.smaStartAfterMonths,
inArrearsTolerance: p.inArrearsTolerance,
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears
});
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
undebitedInterestAccountId: e.undebitedInterestGlId
});
// Filling the currency empties the Charges list; put the saved charges back, or saving would detach them.
if (this.loanProductChargesStep) {
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
}
}
/** Sets the controls and marks them touched-by-user so a sub type default does not overwrite the saved value. */
private fill(form: UntypedFormGroup | undefined, values: Record<string, any>): void {
if (!form) {
return;
}
Object.keys(values).forEach((name) => {
const control = form.get(name);
const value = values[name];
if (control && value !== undefined && value !== null) {
// A YES / NO select is saved as true / false; give it the word the select understands.
const isYesNoSelect = control.value === 'YES' || control.value === 'NO';
control.setValue(isYesNoSelect && typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value);
control.markAsDirty();
}
});
}
/** Reverse of assembleAdvanceProduct: the saved core fields and the extension record go back into the tabs. */
private populateAdvance(): void {
const p = this.loanProductsTemplate;
const e = p.advanceExtensions ?? {};
const split = (value: any) =>
value
? String(value)
.split('|')
.filter((v) => v !== '')
: undefined;
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
currencyCode: p.currency?.code,
digitsAfterDecimal: p.currency?.decimalPlaces
});
this.fill(this.loanProductSecurityLienStep.loanProductSecurityLienForm, {
securityType: e.securityType,
eligibleAmountBasis: e.eligibleAmountBasis,
margin: e.margin,
lienMarkingStage: e.lienMarkingStage,
eligibleDepositProducts: split(e.eligibleDepositProducts),
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
autoReleaseOnClosure: e.lienAutoReleaseOnClosure,
allowMultipleDeposits: e.lienAllowMultipleDeposits,
adjustFromMaturity: e.lienAdjustFromMaturity,
allowThirdPartyDeposit: e.lienAllowThirdParty,
allowRenewalUnderLien: e.lienAllowRenewalUnderLien,
lienCertificateRequired: e.lienCertificateRequired,
lienCertificatePrefix: e.lienCertificatePrefix,
printOnDisbursement: e.printCertificateOnDisbursement,
goldMaxLtv: e.goldMaxLtv,
goldMinPurityCarat: e.goldMinPurityCarat,
goldRateSource: e.goldRateSource,
goldMaxPerGram: e.goldMaxPerGram,
goldMinNetWeight: e.goldMinNetWeight,
goldMaxNetWeightPerCustomer: e.goldMaxNetWeightPerCustomer,
goldAllowedItems: e.goldAllowedItems,
goldValuationCertificateRequired: e.goldValuationCertificateRequired,
goldMaxValuationPerAppraiser: e.goldMaxValuationPerAppraiser,
goldPacketPrefix: e.goldPacketPrefix,
goldRevalueOnNpa: e.goldRevalueOnNpa,
goldRevaluationChargeApplicable: e.goldRevaluationChargeApplicable,
insuranceRequired: e.insuranceRequired,
goldAuctionNoticeDays: e.goldAuctionNoticeDays,
goldPartialReleaseAllowed: e.goldPartialReleaseAllowed,
goldThirdPartyAllowed: e.goldThirdPartyAllowed,
goldDisbursementMode: e.goldDisbursementMode,
goldCashDisbursementLimit: e.goldCashDisbursementLimit,
goldLtvBreachAlert: e.goldLtvBreachAlert,
goldAuctionReservePct: e.goldAuctionReservePct,
wrMaxLtv: e.wrMaxLtv,
wrReceiptType: e.wrReceiptType,
wrTenureWithinReceiptValidity: e.wrTenureWithinReceiptValidity,
wrPledgeMarkedBeforeDisbursement: e.wrPledgeMarkedBeforeDisbursement,
wrCommodityGroups: split(e.wrCommodityGroups),
wrValuationPriceSource: e.wrValuationPriceSource,
wrMaxLoanPerQuintal: e.wrMaxLoanPerQuintal,
wrStockInspectionMonths: e.wrStockInspectionMonths,
wrLtvBreachAlert: e.wrLtvBreachAlert,
wrPartialReleaseAllowed: e.wrPartialReleaseAllowed,
wrDisposalNoticeDays: e.wrDisposalNoticeDays,
wrDisposalReservePct: e.wrDisposalReservePct
});
this.fill(this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenureForm, {
facilityType: e.facilityType,
minLimit: p.minPrincipal,
defaultLimit: p.principal,
maxLimit: p.maxPrincipal,
minTenure: p.minNumberOfRepayments,
defaultTenure: p.numberOfRepayments,
maxTenure: p.maxNumberOfRepayments,
repaymentType: e.repaymentType,
appropriationOrder: e.appropriationOrder,
debitBalanceAllow: e.debitBalanceAllow,
allowDebitTransAfterDueDate: e.allowDebitAfterDueDate,
branchLevelAccountType: e.branchLevelAccountType
});
this.fill(this.loanProductAdvanceInterestStep.loanProductAdvanceInterestForm, {
interestBasis: e.interestBasis,
registerParticular: e.registerParticular,
spread: e.spread,
calcMethod: e.interestCalcMethod,
interestChargedOn: e.interestChargedOn,
interestOnCreditBalance: e.interestOnCreditBalance,
postingFrequency: e.interestPostingFrequency,
postingDay: e.interestPostingDay
});
this.fill(this.loanProductRenewalClosureStep.loanProductRenewalClosureForm, {
renewalAllowed: e.renewalAllowed,
renewalPeriodMonths: e.renewalPeriodMonths,
renewalGraceDays: e.renewalGraceDays,
preclosureAllowed: e.preclosureAllowed,
lockInMonths: e.lockInMonths,
interestOnPreclosure: e.interestOnPreclosure
});
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
penalApplicable: e.penalApplicable,
penalRate: e.penalRate,
penalAppliedOn: e.penalAppliedOn,
penalGraceDays: e.penalGraceDays,
delinquencyBucketId: p.delinquencyBucket?.id,
npaDays: p.overdueDaysForNPA,
npaTrigger: e.npaTrigger,
interestAfterNpa: e.interestAfterNpa,
smaStartAfterMonths: e.smaStartAfterMonths,
stockStatementValidityDays: e.stockStatementValidityDays,
inArrearsTolerance: p.inArrearsTolerance,
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears,
keepStandardIfDepositCovers: e.npaKeepStandardIfDepositCovers,
npaOutOfOrderBasis: e.npaOutOfOrderBasis,
cropDuration: e.cropDuration
});
if (this.isOverdraftFacility && this.loanProductChequeBookStep) {
this.fill(this.loanProductChequeBookStep.loanProductChequeBookForm, {
chequeType: e.chequeType,
bookSizes: split(e.chequeBookSizes),
defaultBookSize: e.defaultChequeBookSize !== undefined ? String(e.defaultChequeBookSize) : undefined,
maxBooksOutstanding: e.chequeMaxBooksOutstanding,
firstBookFree: e.chequeFirstBookFree,
freeBooksPerYear: e.chequeFreeBooksPerYear,
namePrinted: e.chequeNamePrinted,
ppsThreshold: e.ppsThreshold,
ppsMandatoryAbove: e.ppsMandatoryAbove,
stopPaymentAllowed: e.chequeStopPaymentAllowed,
maintainStockRegister: e.chequeMaintainStockRegister,
allowWithdrawalSlip: e.chequeAllowWithdrawalSlip,
blockNpa: e.chequeBlockNpa,
blockLimitExpired: e.chequeBlockLimitExpired,
blockOverDp: e.chequeBlockOverDp,
blockFrozen: e.chequeBlockFrozen,
blockLienReleased: e.chequeBlockLienReleased
});
}
// The nine GLs: the core ones are filled by the Accounting step from the saved mappings; the suspense GL is
// kept on the extension record.
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
undebitedInterestAccountId: e.undebitedInterestGlId
});
// Filling the currency above empties the Charges list (a new currency invalidates it); put the saved charges back,
// or saving the edit would detach every charge from the product.
if (this.loanProductChargesStep) {
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
}
}
get loanProductDetailsForm() {
@@ -305,6 +667,12 @@ export class CreateLoanProductComponent implements OnInit {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
/** True once the Details step has both a product type and a sub type; every other tab stays locked until then. */
get hasProductType(): boolean {
const details = this.loanProductDetailsForm;
return !!details?.get('productTypeGroup')?.value && !!details?.get('productTypeSubGroup')?.value;
}
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
@@ -316,14 +684,14 @@ export class CreateLoanProductComponent implements OnInit {
}
get totalTabs(): number {
let tabs = 4; // details, currency, charges, accounting (always)
let tabs = 5; // details, currency, penal & npa, charges, accounting (always)
if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
// security & lien, limit & tenure, interest, renewal & closure
tabs += 4;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else if (this.isLoanAccount) {
// security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
tabs += 6;
// security & collateral, amount & tenure, repayment & emi, interest, prepayment & closure
tabs += 5;
}
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
// else {
@@ -334,9 +702,114 @@ export class CreateLoanProductComponent implements OnInit {
return tabs;
}
/**
* The tabs that still block the Review tab, each with the fields to fix. The Review tab only appears once every
* tab is valid, and a tab that was never opened or a missing field is otherwise invisible to the user.
*/
get incompleteTabs(): { tab: string; fields: string[] }[] {
const humanize = (name: string) =>
name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/Id$/, '')
.replace(/^./, (ch) => ch.toUpperCase());
const tabs: [string, any][] = [
[
'Details',
this.loanProductDetailsForm
],
[
'Currency',
this.loanProductCurrencyForm
]
];
if (this.isAdvanceProduct) {
tabs.push(
[
'Security & Lien',
this.loanProductSecurityLienStep?.loanProductSecurityLienForm
],
[
'Limit & Tenure',
this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm
],
[
'Interest',
this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm
],
[
'Renewal & Closure',
this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm
]
);
if (this.isOverdraftFacility) {
tabs.push([
'Cheque Book',
this.loanProductChequeBookStep?.loanProductChequeBookForm
]);
}
} else if (this.isLoanAccount) {
tabs.push(
[
'Security & Collateral',
this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm
],
[
'Amount & Tenure',
this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm
],
[
'Repayment & EMI',
this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm
],
[
'Interest',
this.loanProductLoanInterestStep?.loanProductLoanInterestForm
],
[
'Prepayment & Closure',
this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm
]
);
}
tabs.push(
[
'Penal & NPA',
this.loanProductPenalNpaStep?.loanProductPenalNpaForm
],
[
'Accounting',
this.loanProductAccountingStep?.loanProductAccountingForm
]
);
return tabs
.filter(
([
,
form
]) => !form || form.invalid
)
.map(
([
tab,
form
]) => ({
tab,
fields: form
? Object.keys(form.controls)
.filter((key) => form.controls[key].invalid)
.slice(0, 4)
.map(humanize)
: []
})
);
}
get loanProductFormValid() {
const base =
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductAccountingForm.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
@@ -345,7 +818,6 @@ export class CreateLoanProductComponent implements OnInit {
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
@@ -359,7 +831,6 @@ export class CreateLoanProductComponent implements OnInit {
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
);
}
@@ -375,6 +846,33 @@ export class CreateLoanProductComponent implements OnInit {
// return base && loansValid;
}
private previewKey = '';
private previewProduct: any = {};
/**
* The product the Review tab shows. It is rebuilt only when a form value changes, so the summary gets a stable object
* (it fills in currency, schedule type and so on on the object it receives). Submit still uses the fresh `loanProduct`.
*/
get previewLoanProduct(): any {
const key = JSON.stringify(
Object.keys(this)
.filter((name) => /Step$/.test(name))
.map((name) => {
const step = (this as any)[name];
return step
? Object.keys(step)
.filter((prop) => /Form$/.test(prop))
.map((prop) => step[prop]?.getRawValue?.())
: null;
})
);
if (key !== this.previewKey) {
this.previewKey = key;
this.previewProduct = this.loanProduct;
}
return this.previewProduct;
}
get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
@@ -535,9 +1033,14 @@ export class CreateLoanProductComponent implements OnInit {
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent. "Out of order" (CC / OD only) and the
// deposit-cover rule do not apply to a term loan, so neither is sent.
cropDuration: pn.cropDuration || undefined,
securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant,
@@ -554,6 +1057,9 @@ export class CreateLoanProductComponent implements OnInit {
minMonthlyIncome: num(at.minMonthlyIncome),
maxFoir: num(at.maxFoir),
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
memberRequired: at.memberRequired,
guarantorMustBeMember: at.guarantorMustBeMember,
incomeMultiple: num(at.incomeMultiple),
disbursementType: at.disbursementType,
disbursementMode: at.disbursementMode,
undisbursedInterest: at.undisbursedInterest,
@@ -591,7 +1097,6 @@ export class CreateLoanProductComponent implements OnInit {
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
@@ -613,6 +1118,9 @@ export class CreateLoanProductComponent implements OnInit {
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lien = this.loanProductSecurityLienStep.isLienSubType;
const gold = this.loanProductSecurityLienStep.isGold ? sec.gold : null;
const wr = this.loanProductSecurityLienStep.isWarehouse ? sec.warehouse : null;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
@@ -688,10 +1196,62 @@ export class CreateLoanProductComponent implements OnInit {
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
...(lien
? {
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement
}
: {}),
...(gold
? {
goldMaxLtv: num(gold.maxLtv),
goldMinPurityCarat: num(gold.minPurityCarat),
goldRateSource: gold.rateSource,
goldMaxPerGram: num(gold.maxPerGram),
goldMinNetWeight: num(gold.minNetWeight),
goldMaxNetWeightPerCustomer: num(gold.maxNetWeightPerCustomer),
goldAllowedItems: gold.allowedItems,
goldAppraiserRequired: true,
goldValuationCertificateRequired: gold.valuationCertificateRequired === 'YES',
goldMaxValuationPerAppraiser: num(gold.maxValuationPerAppraiser),
goldPacketPrefix: gold.packetPrefix || undefined,
goldRevalueOnNpa: gold.revalueOnNpa,
goldRevaluationChargeApplicable: gold.revaluationChargeApplicable,
insuranceRequired: gold.insuranceRequired === 'YES',
goldAuctionNoticeDays: num(gold.auctionNoticeDays),
goldPartialReleaseAllowed: gold.partialReleaseAllowed,
goldThirdPartyAllowed: gold.thirdPartyAllowed,
goldDisbursementMode: gold.disbursementMode,
goldCashDisbursementLimit:
gold.disbursementMode === 'account_credit_cash_limit' ? num(gold.cashDisbursementLimit) : undefined,
goldLtvBreachAlert: num(gold.ltvBreachAlert),
goldAuctionReservePct: num(gold.auctionReservePct),
goldRedeemBeforeAuction: true
}
: {}),
...(wr
? {
wrMaxLtv: num(wr.maxLtv),
wrReceiptType: wr.receiptType,
wrWdraRegistered: true,
wrAssayCertificateRequired: true,
wrTenureWithinReceiptValidity: wr.tenureWithinReceiptValidity,
wrPledgeMarkedBeforeDisbursement: wr.pledgeMarkedBeforeDisbursement,
wrCommodityGroups: (wr.commodityGroups ?? []).join('|'),
wrValuationPriceSource: wr.valuationPriceSource,
wrMaxLoanPerQuintal: num(wr.maxLoanPerQuintal),
wrStockInspectionMonths: num(wr.stockInspectionMonths),
insuranceRequired: wr.insuranceRequired === 'YES',
wrLtvBreachAlert: num(wr.ltvBreachAlert),
wrPartialReleaseAllowed: wr.partialReleaseAllowed,
wrDisposalNoticeDays: num(wr.disposalNoticeDays),
wrDisposalReservePct: num(wr.disposalReservePct),
wrRedeemBeforeDisposal: true
}
: {}),
interestBasis: intr.interestBasis,
registerParticular: intr.registerParticular || undefined,
spread: num(intr.spread),
@@ -702,7 +1262,14 @@ export class CreateLoanProductComponent implements OnInit {
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
stockStatementValidityDays: num(pn.stockStatementValidityDays),
interestAfterNpa: pn.interestAfterNpa,
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent.
npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined,
cropDuration: pn.cropDuration || undefined,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
@@ -716,14 +1283,18 @@ export class CreateLoanProductComponent implements OnInit {
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
// ---- remaining wizard fields ----
// security & lien extras (deposit list joined; rule checkboxes)
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
// security & lien extras (deposit list joined; rule checkboxes), only for a deposit / policy sub type
...(lien
? {
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien
}
: {}),
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
@@ -736,15 +1307,13 @@ export class CreateLoanProductComponent implements OnInit {
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
...(lien ? { npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers } : {}),
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
@@ -771,6 +1340,19 @@ export class CreateLoanProductComponent implements OnInit {
delete loanProduct['daysInYearCustomStrategy'];
}
if (this.isEdit) {
this.productsService.updateLoanProduct(this.loanProductsTemplate.id, loanProduct).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Loan product update request submitted successfully for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
return;
}
this.productsService.createLoanProduct(loanProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -0,0 +1,79 @@
/**
* 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/.
*/
/**
* A saved Advances / Loans product keeps most of its settings in an extension record (`advanceExtensions`), under the
* same names the edit form submits. Putting those next to the core fields gives every submitted field a current value
* to compare against, so a pending edit shows only what really changed. Core fields win on a name clash.
*
* The product read leaves out a core number that is zero, while the edit form always sends it, so that one is filled in
* as 0 rather than reading as "newly set" on every edit.
*/
export function withExtensions(product: any): any {
if (!product) {
return product;
}
const merged = product.advanceExtensions ? { ...product.advanceExtensions, ...product } : { ...product };
if (merged.inArrearsTolerance === undefined || merged.inArrearsTolerance === null) {
merged.inArrearsTolerance = 0;
}
// Same for the arrears grace: a Loans A/C (EMI) product counts arrears from day one, and the read leaves a zero out.
if (merged.graceOnArrearsAgeing === undefined || merged.graceOnArrearsAgeing === null) {
merged.graceOnArrearsAgeing = 0;
}
return merged;
}
/** Core accounting mapping name on the product read -> the payload name the Review reader uses. */
const GL_MAPPING_KEYS: Record<string, string> = {
loanPortfolioAccount: 'loanPortfolioAccountId',
fundSourceAccount: 'fundSourceAccountId',
interestOnLoanAccount: 'interestOnLoanAccountId',
incomeFromFeeAccount: 'incomeFromFeeAccountId',
incomeFromPenaltyAccount: 'incomeFromPenaltyAccountId',
writeOffAccount: 'writeOffAccountId',
receivableInterestAccount: 'receivableInterestAccountId',
overpaymentLiabilityAccount: 'overpaymentLiabilityAccountId'
};
/** A date the API sends as [2026, 10, 15] shown as 15-10-2026. */
function dateText(value: any): string | undefined {
if (!Array.isArray(value) || value.length < 3) {
return value ?? undefined;
}
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
}
/**
* A saved Advances product as the read-only Review page needs it: the flat field names the create wizard submits
* (core fields plus the extension record), the GL mappings as ids with a matching option list so each shows by name,
* and the attached charges as one readable line. Pure; the product object is not changed.
*/
export function reviewProductOf(product: any): { product: any; template: any } {
const flat: any = { ...withExtensions(product) };
const accounts: any[] = [];
Object.keys(GL_MAPPING_KEYS).forEach((name) => {
const account = product?.accountingMappings?.[name];
if (account?.id !== undefined) {
flat[GL_MAPPING_KEYS[name]] = account.id;
accounts.push({ id: account.id, glCode: account.glCode, name: account.name });
}
});
flat.currencyCode = product?.currency?.code;
flat.digitsAfterDecimal = product?.currency?.decimalPlaces;
flat.startDate = dateText(product?.startDate);
flat.closeDate = dateText(product?.closeDate);
flat.delinquencyBucketId = undefined;
flat.chargesSummary = (product?.charges ?? []).map((charge: any) => `${charge.name} (${charge.amount})`).join(', ');
return { product: flat, template: { accountingMappingOptions: { assetAccountOptions: accounts } } };
}
@@ -214,3 +214,242 @@ $gray-900: #202124;
grid-template-columns: 1fr;
}
}
/* Penal / SMA / NPA / ECL cards: each gets its own accent so they read as separate blocks. */
.pn-card {
border-left-width: 4px;
&--penal {
border-left-color: #0ea5e9;
}
&--sma {
border-left-color: #f59e0b;
}
&--npa {
border-left-color: #dc2626;
}
&--ecl {
border-left-color: #6366f1;
}
}
.pn-stages {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 16px;
}
.pn-stage {
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
&--0 {
background: #fef9c3;
color: #854d0e;
}
&--1 {
background: #ffedd5;
color: #9a3412;
}
&--2 {
background: #fee2e2;
color: #991b1b;
}
}
/* ECL card: collapsed by default, grouped, plain wording. */
.pn-ecl-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 0;
background: #f8fafc;
color: #1e293b;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
&__title {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
i {
color: #6366f1;
}
}
&__tag {
padding: 2px 8px;
border-radius: 999px;
background: #e0e7ff;
color: #3730a3;
font-size: 11px;
font-weight: 600;
}
&__summary {
flex: 1;
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.pn-ecl-body {
padding: 4px 16px 16px;
}
.pn-ecl-intro {
margin: 8px 0 16px;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.pn-ecl-group {
margin-bottom: 20px;
h4 {
margin: 0 0 12px;
color: #1e293b;
font-size: 14px;
font-weight: 600;
}
}
.pn-ecl-row {
margin-top: 12px;
}
.pn-ecl-check {
display: flex;
align-items: center;
min-height: 40px;
}
/* Eligibility & Documents tab. */
.ed-body {
padding: 16px;
}
.ed-count {
margin-left: auto;
padding: 2px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
}
.ed-rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.ed-rule {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
&__text {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #1e293b;
font-size: 14px;
font-weight: 600;
}
small {
color: #64748b;
font-size: 12px;
}
}
}
.ed-group {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #475569;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
i {
color: #6366f1;
}
}
}
.ed-docs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.ed-doc {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #334155;
font-size: 14px;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--on {
border-color: #a5b4fc;
background: #eef2ff;
color: #1e1b4b;
}
}
@@ -38,18 +38,12 @@
<div class="lpa-group">
<div class="lpa-group__header">
<h4>{{ section.title }}</h4>
<button
type="button"
class="lpa-add-other-btn"
(click)="addOtherGlMapping(section.otherMappingsControlName)"
>
+ Add Other
</button>
</div>
<div class="lpa-list">
@for (row of section.rows; track row.key) {
@if (
(!row.accrualOnly || isAccrualAccounting()) &&
basicControls.has(row.controlName) &&
(!row.accrualOnly || isAccrualAccounting() || basicControls.has(row.controlName)) &&
(!row.deferredOnly || isDeferredOnly()) &&
(!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) &&
(!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) &&
@@ -78,334 +72,6 @@
}
}
</div>
<!-- Other GL Mappings -->
<div class="lpa-list" [formArrayName]="section.otherMappingsControlName">
@for (
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
track $index;
let i = $index
) {
<div class="lpa-other-row-wrap">
<div class="lpa-row lpa-row--other" [formGroupName]="i">
<div class="lpa-row__meta lpa-row__meta--other">
<span
class="lpa-pill"
[class.lpa-pill--green]="section.pillClass === 'green'"
[class.lpa-pill--amber]="section.pillClass === 'amber'"
[class.lpa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<input
class="lpa-row__name lpa-row__name--other-account"
formControlName="accountName"
placeholder="Enter account name..."
type="text"
/>
</div>
<div class="lpa-row__actions lpa-row__actions--other">
<mat-form-field class="lpa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>GL Code</mat-label>
<mat-select
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
>
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
<mat-option [value]="glAccount.id">
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<button
type="button"
class="lpa-remove-btn lpa-remove-btn--side"
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
>
<i class="fas fa-times"></i>
</button>
</div>
}
</div>
</div>
}
<!-- Advanced Accounting Rules Section -->
@if (loanProductAccountingForm.value.accountingRule >= 2 && loanProductAccountingForm.value.accountingRule <= 4) {
<div class="lpa-group">
<div class="lpa-group__header">
<h4>Advanced Accounting Rules</h4>
</div>
<div class="lpa-checkbox-row">
<mat-checkbox formControlName="advancedAccountingRules">{{
'labels.heading.Advanced Accounting Rules' | translate
}}</mat-checkbox>
</div>
@if (loanProductAccountingForm.value.advancedAccountingRules) {
<!-- Payment Fund Sources -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value">
<ng-container matColumnDef="paymentTypeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="fundSourceAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.fundSourceAccountId | find: assetAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Fees to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (feeToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="feeToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.incomeAccountId | find: incomeAndLiabilityAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Penalties to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Charge-off Reasons to Expense -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Charge-off reasons to Expense accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
[disabled]="!allowAddChargeOffReasonExpense"
(click)="add('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (chargeOffReasonToExpenseAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="chargeOffReasonToExpenseAccountMappings.value">
<ng-container matColumnDef="chargeOffReasonCodeValueId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Charge-off reason' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{
chargeOffReasonExpense.chargeOffReasonCodeValueId
| find: chargeOffReasonOptions : 'id' : 'name'
}}
</td>
</ng-container>
<ng-container matColumnDef="expenseAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Expense Account' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{ chargeOffReasonExpense.expenseAccountId | find: expenseAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="chargeOffReasonExpenseDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: chargeOffReasonExpenseDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Advanced Mapping Rule Components -->
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'BuydownFeeClassificationToIncome'"
[formArray]="buydownfeeClassificationToIncomeAccountMappings"
[textHeading]="'Buydown Fee classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="buydownFeeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'CapitalizedIncomeClassificationToIncome'"
[formArray]="capitalizedIncomeClassificationToIncomeAccountMappings"
[textHeading]="'Capitalized Income classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="capitalizedIncomeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'WriteOff Reason'"
[formType]="'WriteOffReasonToExpense'"
[formArray]="writeOffReasonsToExpenseMappings"
[textHeading]="'WriteOff reasons to Expense accounts'"
[expenseAccountData]="expenseAccountData"
[accountingMappingOptions]="writeOffReasonOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
}
</div>
}
</div>
@@ -104,6 +104,23 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
@Input() accountingRuleData: any;
@Input() loanProductFormValid: boolean;
@Input() deferredIncomeRecognition: DeferredIncomeRecognition;
/** The product's category / type and its portfolio GL, from the Details step; they drive the default GL mapping. */
@Input() productTypeGroup: string | null = null;
@Input() productTypeSubGroup: string | null = null;
@Input() productGlAccountId: number | string | null = null;
/** The mappings every product needs; the rest default from these and sit behind "Show all mappings". */
readonly basicControls = new Set<string>([
'loanPortfolioAccountId',
'fundSourceAccountId',
'interestOnLoanAccountId',
'incomeFromFeeAccountId',
'incomeFromPenaltyAccountId',
'writeOffAccountId',
'receivableInterestAccountId',
'overpaymentLiabilityAccountId',
'undebitedInterestAccountId'
]);
public loanProductAccountingForm!: UntypedFormGroup;
@@ -151,6 +168,73 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
if (this.loanProductAccountingForm) {
this.setDeferredIncomeRecognitionControls();
}
if (changes['productTypeGroup'] || changes['productTypeSubGroup'] || changes['productGlAccountId']) {
this.applyDefaultMapping();
}
}
/** The id of the GL account with this GL code among all the accounting options, or null. */
private glIdByCode(code: string): number | null {
const all = [
...(this.assetAccountData ?? []),
...(this.incomeAccountData ?? []),
...(this.expenseAccountData ?? []),
...(this.liabilityAccountData ?? [])
];
return all.find((account: any) => String(account.glCode) === code)?.id ?? null;
}
/**
* Pre-fills the GL mappings from the product type: the portfolio GL is the product's own GL, interest income follows
* the loan term (Advances short, Loans medium, Housing long), the rest are the bank's shared GLs. A mapping the
* officer has already changed is left alone.
*/
applyDefaultMapping(): void {
// A saved product keeps its own mappings; one saved without any still gets the defaults.
const hasSavedMappings = !!this.loanProductsTemplate?.id && !!this.loanProductsTemplate?.accountingMappings;
if (!this.loanProductAccountingForm || !this.assetAccountData || hasSavedMappings) {
return;
}
const isAdvance = this.productTypeGroup === 'advances';
const interestCode = isAdvance ? '400301' : this.productTypeSubGroup === 'housing' ? '400303' : '400302';
const cash = this.glIdByCode('100101');
const interest = this.glIdByCode(interestCode);
const fee = this.glIdByCode('400106') ?? this.glIdByCode('400101');
const penal = this.glIdByCode('400304') ?? this.glIdByCode('400101');
const writeOff = this.glIdByCode('500503');
const receivable = this.glIdByCode('100601');
const overpayment = this.glIdByCode('200907');
const suspense = this.glIdByCode('200908');
const defaults: Record<string, number | string | null> = {
loanPortfolioAccountId: this.productGlAccountId || null,
fundSourceAccountId: cash,
transfersInSuspenseAccountId: cash,
interestOnLoanAccountId: interest,
incomeFromFeeAccountId: fee,
incomeFromPenaltyAccountId: penal,
incomeFromRecoveryAccountId: interest,
writeOffAccountId: writeOff,
goodwillCreditAccountId: writeOff,
chargeOffFraudExpenseAccountId: writeOff,
chargeOffExpenseAccountId: writeOff,
incomeFromChargeOffPenaltyAccountId: penal,
incomeFromChargeOffFeesAccountId: fee,
incomeFromChargeOffInterestAccountId: interest,
incomeFromGoodwillCreditInterestAccountId: interest,
incomeFromGoodwillCreditFeesAccountId: fee,
incomeFromGoodwillCreditPenaltyAccountId: penal,
receivableInterestAccountId: receivable,
receivableFeeAccountId: receivable,
receivablePenaltyAccountId: receivable,
overpaymentLiabilityAccountId: overpayment,
undebitedInterestAccountId: suspense
};
Object.keys(defaults).forEach((name) => {
const control = this.loanProductAccountingForm.get(name);
if (control && !control.dirty && defaults[name]) {
control.setValue(defaults[name]);
}
});
}
ngOnInit() {
@@ -177,6 +261,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
this.loanProductAccountingForm.get('accountingRule').disable();
this.buildAccountingSectionConfigs();
this.applyDefaultMapping();
const accountingMappings = this.loanProductsTemplate.accountingMappings;
this.setDeferredIncomeRecognitionControls();
@@ -277,6 +362,33 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId'
];
// CBS-specific mappings the backend does not accept yet: optional here so a product can be created without
// them (empty values are dropped from the payload in LoanProducts.buildPayload).
const optionalCbsFields = [
// derived by the backend from the nine mapped GLs (LoanGlMappingDefaults)
'transfersInSuspenseAccountId',
'incomeFromRecoveryAccountId',
'goodwillCreditAccountId',
'chargeOffFraudExpenseAccountId',
'chargeOffExpenseAccountId',
'incomeFromChargeOffPenaltyAccountId',
'incomeFromChargeOffFeesAccountId',
'incomeFromChargeOffInterestAccountId',
'incomeFromGoodwillCreditInterestAccountId',
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
@@ -289,7 +401,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
];
commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
this.loanProductAccountingForm.addControl(
field,
new UntypedFormControl('', optionalCbsFields.includes(field) ? null : Validators.required)
);
}
});
@@ -567,8 +682,20 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
} = this.loanProductAccountingForm.getRawValue();
const otherGlMappings = this.getOtherGlMappings();
// Only the nine mapped GLs are sent. The other Fineract placeholders (recovery, goodwill credit, charge-off ...)
// are derived by the backend. Interest Suspense has no core mapping, so it is kept on the satellite record.
const payload: any = {};
this.basicControls.forEach((name) => {
if (name !== 'undebitedInterestAccountId' && loanProductAccounting[name]) {
payload[name] = loanProductAccounting[name];
}
});
if (loanProductAccounting.undebitedInterestAccountId) {
payload.undebitedInterestGlId = loanProductAccounting.undebitedInterestAccountId;
}
return {
...loanProductAccounting,
accountingRule: loanProductAccounting.accountingRule,
...payload,
...(otherGlMappings ? { otherGlMappings } : {})
};
}
@@ -684,7 +811,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
inputLabel: 'Interest Receivable',
controlName: 'receivableInterestAccountId',
options: this.assetAccountData,
required: true,
required: false,
accrualOnly: true
},
{
@@ -61,12 +61,12 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
@if (tenureBand; as band) {
<mat-hint>{{ band.label }} policy: {{ band.min }}–{{ band.max }} months only</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
@if (tenureBand; as band) {
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
@@ -75,22 +75,31 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
@if (tenureBand; as band) {
<mat-hint>Between {{ band.min }} and {{ band.max }}</mat-hint>
}
@if (tenureBand; as band) {
<mat-error
>Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months</mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
@if (tenureBand; as band) {
<mat-hint>
{{ band.label }} policy: max {{ band.max }} months
@if (band.label === 'FD') {
(also capped at deposit maturity)
}
</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
@if (tenureBand; as band) {
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
@@ -110,21 +110,28 @@ export class LoanProductAdvanceLimitTenureStepComponent {
return this.loanProductAdvanceLimitTenureForm.getRawValue();
}
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
get isFdTenureLocked(): boolean {
return this._productSubType === 'fd';
/** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */
private readonly tenureBands: Record<string, { label: string; min: number; max: number; defaultTenure: number }> = {
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12 },
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 }
};
/** The policy band in force for the chosen sub type, or null when the sub type is open. */
get tenureBand(): { label: string; min: number; max: number; defaultTenure: number } | null {
return this.tenureBands[this._productSubType] ?? null;
}
get tenureMin(): number {
return this.isFdTenureLocked ? 12 : 1;
return this.tenureBand?.min ?? 1;
}
get tenureMax(): number | null {
return this.isFdTenureLocked ? 15 : null;
return this.tenureBand?.max ?? null;
}
/**
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
* FD (12–15 months) and Gold (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product.
*/
@@ -136,10 +143,11 @@ export class LoanProductAdvanceLimitTenureStepComponent {
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
if (this._productSubType === 'fd') {
const band = this.tenureBand;
if (band) {
const bounds = [
Validators.min(12),
Validators.max(15)
Validators.min(band.min),
Validators.max(band.max)
];
min.setValidators([
Validators.required,
@@ -150,9 +158,9 @@ export class LoanProductAdvanceLimitTenureStepComponent {
Validators.required,
...bounds
]);
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
min.setValue(band.min, { emitEvent: false });
def.setValue(band.defaultTenure, { emitEvent: false });
max.setValue(band.max, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
@@ -42,10 +42,6 @@
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@@ -66,10 +62,6 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
@@ -62,11 +62,9 @@ export class LoanProductChequeBookStepComponent {
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
@@ -105,6 +105,29 @@ export class LoanProductDetailsStepComponent implements OnInit {
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
// A saved Advances / Loans product comes back with its type in the extension record. Show it filled and
// lock it: changing the type would change every other tab.
const ext = this.loanProductsTemplate?.advanceExtensions;
if (this.isExistingProduct && ext?.productTypeGroup) {
this.loanProductDetailsForm.patchValue({
productTypeGroup: ext.productTypeGroup,
productTypeSubGroup: ext.productTypeSubGroup,
glAccount: ext.glAccount ?? '',
loanProductCategory: ext.loanProductCategory ?? this.loanProductDetailsForm.value.loanProductCategory,
prioritySector: ext.prioritySector ?? this.loanProductDetailsForm.value.prioritySector,
cibilAccountType: ext.cibilAccountType ?? this.loanProductDetailsForm.value.cibilAccountType,
kfsEnabled: this.yesNo(ext.kfsEnabled, this.loanProductDetailsForm.value.kfsEnabled),
aprEnabled: this.yesNo(ext.aprEnabled, this.loanProductDetailsForm.value.aprEnabled)
});
this.loanProductDetailsForm.get('productTypeGroup').disable({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').disable({ emitEvent: false });
}
}
/** A saved YES / NO choice comes back as true / false. */
private yesNo(saved: any, fallback: any): any {
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
}
/** True when editing a saved product; see ngOnInit. */
@@ -225,11 +248,77 @@ export class LoanProductDetailsStepComponent implements OnInit {
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
this.applyCategoryForSubType(subGroupId);
});
// The category sets the Priority Sector and CIBIL defaults. A change the officer makes here reaches this
// subscription; the sub type's own default (above) calls applyCategoryDefaults directly.
this.loanProductDetailsForm.get('loanProductCategory')?.valueChanges.subscribe((category: string) => {
this.applyCategoryDefaults(category);
});
}
/** Sub type -> the category that normally fits it. Sub types not listed leave the category as it is. */
private readonly categoryBySubType: Record<string, string> = {
fd: 'advances_deposits',
rd: 'advances_deposits',
dds: 'advances_deposits',
lic: 'advances_deposits',
gold: 'gold_loan',
warehouse: 'agriculture',
// Loans A/C (EMI)
term: 'other',
personal: 'personal',
housing: 'housing',
vehicle: 'personal',
education: 'personal',
business: 'msme',
gold_emi: 'gold_loan'
};
/**
* Category -> Priority Sector and CIBIL account type that normally go with it (RBI sector classification).
* Defaults only: the officer can still pick another value for either.
*/
private readonly defaultsByCategory: Record<string, { prioritySector: string; cibilAccountType: string }> = {
agriculture: { prioritySector: 'yes', cibilAccountType: 'other' },
msme: { prioritySector: 'yes', cibilAccountType: 'business_loan' },
personal: { prioritySector: 'no', cibilAccountType: 'other' },
housing: { prioritySector: 'no', cibilAccountType: 'other' },
advances_deposits: { prioritySector: 'no', cibilAccountType: 'loan_bank_deposits' },
gold_loan: { prioritySector: 'no', cibilAccountType: 'gold_loan' },
other: { prioritySector: 'no', cibilAccountType: 'other' }
};
/** Picking a sub type sets its usual category (unless the officer chose one), and the category's defaults follow. */
private applyCategoryForSubType(subGroupId: string): void {
const category = this.categoryBySubType[subGroupId];
if (!category) {
return;
}
const control = this.loanProductDetailsForm.get('loanProductCategory');
if (control && !control.dirty) {
control.setValue(category, { emitEvent: false });
this.applyCategoryDefaults(category);
}
}
/** Sets Priority Sector and CIBIL type for a category, leaving alone any the officer has already chosen. */
private applyCategoryDefaults(category: string): void {
const fit = this.defaultsByCategory[category];
if (!fit) {
return;
}
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
const control = this.loanProductDetailsForm.get(name);
if (control && !control.dirty) {
control.setValue(fit[name], { emitEvent: false });
}
});
}
get loanProductDetails() {
const loanProductDetailsFormData = this.loanProductDetailsForm.value;
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue();
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
const dateFormat = this.settingsService.dateFormat;
@@ -0,0 +1,60 @@
<!--
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/.
-->
<form [formGroup]="loanProductEligibilityDocsForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-check"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="ed-body">
<div class="ed-rules">
<label class="ed-rule">
<mat-checkbox formControlName="memberRequired"></mat-checkbox>
<span class="ed-rule__text">
<strong>Borrower must be a member</strong>
<small>Only members of the bank can take this product</small>
</span>
</label>
<label class="ed-rule">
<mat-checkbox formControlName="guarantorMustBeMember"></mat-checkbox>
<span class="ed-rule__text">
<strong>Guarantors must be members</strong>
<small>Applies when the product asks for guarantors</small>
</span>
</label>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-file-lines"></i>
<h3>Required Documents</h3>
<span class="ed-count">{{ selectedCount }} of {{ documentOptions.length }} selected</span>
</div>
<div class="ed-body">
@for (group of documentGroups; track group.title) {
<div class="ed-group">
<div class="ed-group__title"><i class="fas {{ group.icon }}"></i>{{ group.title }}</div>
<div class="ed-docs">
@for (doc of documentsOf(group); track doc.id) {
<label class="ed-doc" [class.ed-doc--on]="isSelected(doc.id)">
<mat-checkbox
[checked]="isSelected(doc.id)"
(change)="toggleDocument(doc.id, $event.checked)"
></mat-checkbox>
<span>{{ doc.label }}</span>
</label>
}
</div>
</div>
}
</div>
</div>
</form>
@@ -0,0 +1,163 @@
/**
* 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 { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Borrower eligibility and document checklist, common to Advances A/C and Loans A/C. Age, CIBIL, income and
* FOIR already live on the Loans amount/tenure step, so only the rules both categories share are held here.
* Sent as satellite params (LoanProductAdvanceExtService.ADVANCE_PARAMETERS).
*/
@Component({
selector: 'mifosx-loan-product-eligibility-docs-step',
templateUrl: './loan-product-eligibility-docs-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductEligibilityDocsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeGroup: UntypedFormControl;
loanProductEligibilityDocsForm: UntypedFormGroup;
readonly documentOptions = [
{ id: 'KYC', label: 'KYC (ID proof)' },
{ id: 'ADDRESS_PROOF', label: 'Address proof' },
{ id: 'INCOME_PROOF', label: 'Income proof / salary certificate' },
{ id: 'ITR', label: 'Income-tax returns (3 years)' },
{ id: 'DEMAND_PROMISSORY_NOTE', label: 'Demand promissory note' },
{ id: 'LOAN_AGREEMENT', label: 'Loan agreement' },
{ id: 'GUARANTEE_DEED', label: 'Guarantor agreement' },
{ id: 'SALARY_DEDUCTION_AUTHORITY', label: 'Employer salary-deduction authority' },
{ id: 'LETTER_OF_LIEN', label: 'Letter of lien and set-off' },
{ id: 'LETTER_OF_CONTINUITY', label: 'Letter of continuity' },
{ id: 'HYPOTHECATION_AGREEMENT', label: 'Hypothecation agreement' },
{ id: 'TITLE_DEED', label: 'Title deed / registry' },
{ id: 'VALUATION_REPORT', label: 'Valuation report' },
{ id: 'STOCK_STATEMENT', label: 'Stock statement' },
{ id: 'INSURANCE_POLICY', label: 'Insurance policy (joint name)' },
{ id: 'QUOTATION', label: 'Dealer quotation / estimate' }
];
/** The checklist grouped the way an officer thinks about the papers. Every document sits in exactly one group. */
readonly documentGroups: { title: string; icon: string; ids: string[] }[] = [
{ title: 'Identity and address', icon: 'fa-id-card', ids: [
'KYC',
'ADDRESS_PROOF'
] },
{ title: 'Income', icon: 'fa-money-bill-wave', ids: [
'INCOME_PROOF',
'ITR',
'SALARY_DEDUCTION_AUTHORITY'
] },
{
title: 'Agreements',
icon: 'fa-file-signature',
ids: [
'DEMAND_PROMISSORY_NOTE',
'LOAN_AGREEMENT',
'GUARANTEE_DEED',
'HYPOTHECATION_AGREEMENT'
]
},
{ title: 'Lien and continuity', icon: 'fa-lock', ids: [
'LETTER_OF_LIEN',
'LETTER_OF_CONTINUITY'
] },
{
title: 'Security papers',
icon: 'fa-shield-halved',
ids: [
'TITLE_DEED',
'VALUATION_REPORT',
'STOCK_STATEMENT',
'INSURANCE_POLICY',
'QUOTATION'
]
}
];
documentsOf(group: { ids: string[] }): { id: string; label: string }[] {
return this.documentOptions.filter((doc) => group.ids.includes(doc.id));
}
get selectedCount(): number {
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).length;
}
private readonly defaultDocuments: Record<string, string[]> = {
advances: [
'KYC',
'DEMAND_PROMISSORY_NOTE',
'LETTER_OF_LIEN',
'LETTER_OF_CONTINUITY'
],
loans: [
'KYC',
'ADDRESS_PROOF',
'INCOME_PROOF',
'LOAN_AGREEMENT',
'GUARANTEE_DEED'
]
};
constructor() {
this.loanProductEligibilityDocsForm = this.formBuilder.group({
memberRequired: [true],
guarantorMustBeMember: [true],
requiredDocuments: [[]]
});
}
ngOnInit() {
if (this.productTypeGroup) {
this.applyDefaultDocuments(this.productTypeGroup.value);
this.productTypeGroup.valueChanges.subscribe((group: string) => this.applyDefaultDocuments(group));
}
}
/** Pre-ticks the usual checklist for the category until the user edits it. */
private applyDefaultDocuments(group: string): void {
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
if (control && !control.dirty && this.defaultDocuments[group]) {
control.setValue([...this.defaultDocuments[group]]);
}
}
isSelected(id: string): boolean {
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).includes(id);
}
toggleDocument(id: string, checked: boolean): void {
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
const current: string[] = control?.value ?? [];
control?.setValue(
checked
? [
...new Set([
...current,
id
])
]
: current.filter((d) => d !== id)
);
control?.markAsDirty();
}
get loanProductEligibilityDocs() {
const value = this.loanProductEligibilityDocsForm.getRawValue();
return { ...value, requiredDocuments: (value.requiredDocuments ?? []).join(',') };
}
}
@@ -44,18 +44,38 @@
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" min="1" required />
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
<input matInput type="number" formControlName="minTenure" [min]="band?.minTenure ?? 1" required />
@if (band; as b) {
<mat-hint>{{ b.label }}: {{ b.minTenure }}–{{ b.maxTenure }} months</mat-hint>
}
<mat-error>
@if (band; as b) {
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" min="1" />
<input matInput type="number" formControlName="defaultTenure" [min]="band?.minTenure ?? 1" />
@if (band; as b) {
<mat-error
>Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months</mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" min="1" required />
<input matInput type="number" formControlName="maxTenure" [min]="band?.minTenure ?? 1" required />
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
<mat-error>
@if (band; as b) {
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
</div>
</div>
@@ -90,6 +110,17 @@
<mat-label>Maximum Loans per Customer</mat-label>
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Income Multiple (× net monthly salary)</mat-label>
<input matInput type="number" formControlName="incomeMultiple" min="0" step="0.5" />
<mat-hint>Loan up to this many times the net salary. Policy: personal loan 10×</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="memberRequired">Applicant must be a member of the bank</mat-checkbox>
<mat-checkbox formControlName="guarantorMustBeMember">Guarantors must be members of the bank</mat-checkbox>
</div>
</div>
</div>
@@ -6,12 +6,70 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Tenure band (months) and borrower age limits for a Loans A/C sub type, taken from the bank's loan policy: short term
* up to 15 months, medium 15 to 84, long above 84, and never more than 120. Housing runs 7 to 10 years, education is
* repaid in 84 equal instalments, vehicle 3 to 7 years, personal 3 to 5 years.
*/
interface LoanBand {
label: string;
minTenure: number;
defaultTenure: number;
maxTenure: number;
minAge: number;
maxAgeAtMaturity?: number;
/** Loan = this many times the net monthly salary (policy: personal loan 10x). */
incomeMultiple?: number;
/** The guarantors must themselves be members of the bank (policy: personal loan). */
guarantorMustBeMember?: boolean;
}
const LOAN_BANDS: Record<string, LoanBand> = {
personal: {
label: 'Personal Loan',
minTenure: 6,
defaultTenure: 36,
maxTenure: 60,
minAge: 21,
maxAgeAtMaturity: 60,
incomeMultiple: 10,
guarantorMustBeMember: true
},
housing: {
label: 'Housing Loan',
minTenure: 12,
defaultTenure: 84,
maxTenure: 120,
minAge: 21,
maxAgeAtMaturity: 70
},
vehicle: { label: 'Vehicle Loan', minTenure: 12, defaultTenure: 60, maxTenure: 84, minAge: 21, maxAgeAtMaturity: 65 },
education: { label: 'Education Loan', minTenure: 12, defaultTenure: 84, maxTenure: 84, minAge: 18 },
business: {
label: 'Business / MSME Loan',
minTenure: 12,
defaultTenure: 60,
maxTenure: 120,
minAge: 21,
maxAgeAtMaturity: 65
},
gold_emi: {
label: 'Gold EMI Loan',
minTenure: 6,
defaultTenure: 12,
maxTenure: 36,
minAge: 21,
maxAgeAtMaturity: 70
},
term: { label: 'Term Loan', minTenure: 12, defaultTenure: 60, maxTenure: 120, minAge: 21, maxAgeAtMaturity: 65 }
};
/**
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
@@ -33,6 +91,27 @@ export class LoanProductLoanAmountTenureStepComponent {
loanProductLoanAmountTenureForm: UntypedFormGroup;
private _productSubType = '';
/** The sub type picked on the Details step: it sets the tenure band the three tenure fields must stay inside. */
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyBand();
}
get productSubType(): string {
return this._productSubType;
}
/** The band in force for the chosen sub type, or null for a sub type without one. */
get band(): LoanBand | null {
return LOAN_BANDS[this._productSubType] ?? null;
}
readonly disbursementModeOptions = [
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
{ id: 'transfer', label: 'Transfer to A/C only' },
@@ -85,6 +164,13 @@ export class LoanProductLoanAmountTenureStepComponent {
minMonthlyIncome: [''],
maxFoir: [50],
maxLoansPerCustomer: [1],
// Policy: loans go to members, so the applicant must be a member; a personal loan also needs member guarantors.
memberRequired: [true],
guarantorMustBeMember: [false],
incomeMultiple: [
'',
Validators.min(0)
],
disbursementType: ['single'],
maxTranches: [
2,
@@ -96,6 +182,68 @@ export class LoanProductLoanAmountTenureStepComponent {
});
}
/** Sets the tenure limits and the age defaults for the sub type; a value the officer has typed is left alone. */
private applyBand(): void {
const form = this.loanProductLoanAmountTenureForm;
const band = this.band;
if (!form || !band) {
return;
}
const tenureControls: [string, number][] = [
[
'minTenure',
band.minTenure
],
[
'defaultTenure',
band.defaultTenure
],
[
'maxTenure',
band.maxTenure
]
];
const bounds = [
Validators.min(band.minTenure),
Validators.max(band.maxTenure)
];
tenureControls.forEach(
([
name,
value
]) => {
const control = form.get(name);
control.setValidators(
name === 'defaultTenure' ? bounds : [
Validators.required,
...bounds
]
);
if (!control.dirty) {
control.setValue(value, { emitEvent: false });
}
control.updateValueAndValidity({ emitEvent: false });
}
);
const incomeMultiple = form.get('incomeMultiple');
if (!incomeMultiple.dirty) {
incomeMultiple.setValue(band.incomeMultiple ?? '', { emitEvent: false });
}
const guarantorMember = form.get('guarantorMustBeMember');
if (!guarantorMember.dirty) {
guarantorMember.setValue(band.guarantorMustBeMember ?? false, { emitEvent: false });
}
const minAge = form.get('minAge');
if (!minAge.dirty) {
minAge.setValue(band.minAge, { emitEvent: false });
}
const maxAge = form.get('maxAgeAtMaturity');
// A sub type without its own limit goes back to the general 70, not the previous sub type's value.
if (!maxAge.dirty) {
maxAge.setValue(band.maxAgeAtMaturity ?? 70, { emitEvent: false });
}
}
get isMultiTranche(): boolean {
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
}
@@ -63,8 +63,16 @@
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · floating rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
<mat-hint>Nil as per RBI</mat-hint>
<input
matInput
type="number"
formControlName="foreclosureFloatingIndividual"
min="0"
max="0"
step="0.01"
readonly
/>
<mat-hint>Nil as per RBI. Cannot be changed</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · fixed rate (%)</mat-label>
@@ -132,7 +140,6 @@
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
</div>
@@ -77,9 +77,13 @@ export class LoanProductLoanPrepaymentStepComponent {
allowedOn: ['emi_date'],
partPaymentsPerYear: ['unlimited'],
partPrepaymentMode: ['reduce_tenure'],
// RBI: no foreclosure / pre-payment charge on a floating-rate loan to an individual, so this stays at nil.
foreclosureFloatingIndividual: [
0,
percent
[
Validators.min(0),
Validators.max(0)
]
],
foreclosureFixedIndividual: [
2,
@@ -105,7 +109,6 @@ export class LoanProductLoanPrepaymentStepComponent {
autoCloseOnZeroBalance: ['YES'],
nocLetter: ['auto'],
restructuring: ['approval'],
releaseCollateralOnClosure: [true],
topUpAllowed: [false],
balanceTransferOutAllowed: [true]
});
@@ -49,6 +49,7 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Days to First Repayment</mat-label>
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
<mat-hint>Policy: instalments start from the 2nd month after the loan month</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>EMI Due Day</mat-label>
@@ -69,6 +70,7 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Moratorium / Grace (months)</mat-label>
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
<mat-hint>Interest and EMI fall due only after the moratorium ends (RBI)</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest during Moratorium</mat-label>
@@ -98,14 +100,16 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (isAutoDebit) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PDC Collection</mat-label>
<mat-select formControlName="pdcCollection">
@@ -115,11 +119,13 @@
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
@if (isAutoDebit) {
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
}
<div style="padding: 0 24px 20px">
<div class="adv-info">
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
@@ -28,17 +28,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent {
export class LoanProductLoanRepaymentStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
// Bullet / Step-up / Balloon live on the Advances A/C "Limit & Tenure" step instead — a Loans A/C
// (EMI) product always repays on a fixed instalment schedule, so only these three belong here.
readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }
];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
@@ -50,7 +50,7 @@ export class LoanProductLoanRepaymentStepComponent {
readonly emiDueDayOptions = [
{ id: 'disbursement_day', label: 'Same day as disbursement' },
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
{ id: 'fixed_day', label: 'Fixed day of the month (day set on each account)' }
];
readonly holidayRuleOptions = [
@@ -114,16 +114,52 @@ export class LoanProductLoanRepaymentStepComponent {
],
moratoriumInterest: ['pre_emi'],
variableInstallments: [false],
repaymentMode: ['nach'],
// Loan policy: cheques for the instalments are taken together in advance (quarterly), so cheques are the default.
repaymentMode: ['pdc'],
linkedAccountType: ['savings_current'],
pdcCollection: ['mandatory'],
mandateRequired: [true],
mandateRequired: [false],
appropriationOrder: ['charges_penal_interest_principal'],
advanceEmi: ['adjust_next']
});
}
ngOnInit(): void {
// The mode decides how the EMI is collected, so it sets the cheque and mandate defaults that go with it.
this.loanProductLoanRepaymentForm
.get('repaymentMode')
.valueChanges.subscribe((mode: string) => this.applyMode(mode));
}
private applyMode(mode: string): void {
const defaults: Record<string, { pdcCollection: string; mandateRequired: boolean }> = {
nach: { pdcCollection: 'optional', mandateRequired: true },
pdc: { pdcCollection: 'mandatory', mandateRequired: false },
cash: { pdcCollection: 'none', mandateRequired: false }
};
const fit = defaults[mode];
if (!fit) {
return;
}
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
const control = this.loanProductLoanRepaymentForm.get(name);
if (control && !control.dirty) {
control.setValue(fit[name], { emitEvent: false });
}
});
}
/** Auto-debit needs a linked account and a registered mandate; the other modes do not. */
get isAutoDebit(): boolean {
return this.loanProductLoanRepaymentForm.get('repaymentMode').value === 'nach';
}
get loanProductLoanRepayment() {
return this.loanProductLoanRepaymentForm.getRawValue();
const value = this.loanProductLoanRepaymentForm.getRawValue();
// A mandate only exists for auto-debit; never save it as required for cheques or cash.
if (!this.isAutoDebit) {
value.mandateRequired = false;
}
return value;
}
}
@@ -28,6 +28,9 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
@if (marginHint) {
<mat-hint>{{ marginHint }}</mat-hint>
}
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
@@ -38,40 +41,6 @@
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table"></i>
<h3>Collateral Rules by Security Type</h3>
<span class="adv-section__hint">Which security this product may accept</span>
</div>
<table class="adv-table">
<thead>
<tr>
<th>Security</th>
<th>Allowed</th>
<th>Max LTV %</th>
<th>Valuation</th>
<th>Insurance</th>
<th>Charge / lien</th>
</tr>
</thead>
<tbody>
@for (rule of collateralRules; track rule.security) {
<tr>
<td>{{ rule.security }}</td>
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
{{ rule.allowed ? 'Yes' : 'No' }}
</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-plus"></i>
@@ -90,14 +59,16 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (isSecured) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
@@ -107,18 +78,15 @@
<h3>Conditions</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="legalOpinionRequired">
Legal opinion / title search mandatory before sanction
</mat-checkbox>
<mat-checkbox formControlName="propertyInsuranceRequired">
Property insurance mandatory for the whole tenure
</mat-checkbox>
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
</div>
@if (shownConditions.length > 0) {
<div class="adv-checks">
@for (rule of shownConditions; track rule.control) {
<mat-checkbox [formControlName]="rule.control">{{ rule.label }}</mat-checkbox>
}
</div>
} @else {
<div class="adv-info">This loan is unsecured, so it carries no security conditions.</div>
}
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
@@ -6,16 +6,157 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface SecurityProfile {
securityType: string;
securityMandatory: 'yes' | 'no';
margin: number;
guarantorsRequired: number;
coApplicant: string;
thirdPartySecurity: string;
}
/** One condition a sub type's security carries: the checkbox it controls, its wording, and whether it starts ticked. */
interface ConditionRule {
control: string;
label: string;
defaultOn: boolean;
}
/** Every condition checkbox the form holds. One not listed for a sub type does not apply to it and is saved as off. */
const CONDITION_CONTROLS = [
'legalOpinionRequired',
'propertyInsuranceRequired',
'registerCharge',
'autoReleaseOnClosure',
'allowMultipleSecurities',
'allowThirdPartyDeposit'
];
/** Immovable property (Housing, Business, Term): title check, property bima, CERSAI charge. */
const PROPERTY_CONDITIONS: ConditionRule[] = [
{ control: 'legalOpinionRequired', label: 'Legal opinion / title search mandatory before sanction', defaultOn: true },
{ control: 'propertyInsuranceRequired', label: 'Property insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'registerCharge', label: 'Register charge (CERSAI) before disbursement', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Release the mortgage automatically on closure', defaultOn: true },
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false }
];
/** Vehicle: the vehicle is hypothecated, insured, and the lien is noted on the RC book. */
const VEHICLE_CONDITIONS: ConditionRule[] = [
{ control: 'propertyInsuranceRequired', label: 'Vehicle insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'registerCharge', label: 'Note the hypothecation on the RC book before disbursement', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Release the hypothecation automatically on closure', defaultOn: true }
];
/** Gold EMI: the gold is pledged and insured, and handed back on closure. No registered charge. */
const GOLD_CONDITIONS: ConditionRule[] = [
{ control: 'propertyInsuranceRequired', label: 'Gold insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Return the gold automatically on closure', defaultOn: true }
];
/** Wording used when a sub type has no list of its own but the officer made security mandatory. */
const GENERIC_CONDITIONS: ConditionRule[] = [
{
control: 'legalOpinionRequired',
label: 'Legal opinion / title search mandatory before sanction',
defaultOn: false
},
{
control: 'propertyInsuranceRequired',
label: 'Security insurance mandatory for the whole tenure',
defaultOn: false
},
{ control: 'registerCharge', label: 'Register the charge on the security before disbursement', defaultOn: false },
{ control: 'autoReleaseOnClosure', label: 'Release the security automatically on closure', defaultOn: false },
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false },
{ control: 'allowThirdPartyDeposit', label: 'Allow third-party deposit as security', defaultOn: false }
];
/** Personal and Education are unsecured, so they carry no security conditions unless security is made mandatory. */
const CONDITIONS_BY_SUB_TYPE: Record<string, ConditionRule[]> = {
housing: PROPERTY_CONDITIONS,
business: PROPERTY_CONDITIONS,
term: PROPERTY_CONDITIONS,
vehicle: VEHICLE_CONDITIONS,
gold_emi: GOLD_CONDITIONS,
personal: [],
education: PROPERTY_CONDITIONS
};
/** Sensible defaults by Loans A/C sub type — the officer can still override every field. */
const SECURITY_PROFILES: Record<string, SecurityProfile> = {
// Policy: a personal loan takes two guarantors who are bank members.
personal: {
securityType: 'None (unsecured)',
securityMandatory: 'no',
margin: 0,
guarantorsRequired: 2,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
// Policy: 90% of the expense is lent (the student bears 10%), against a house / plot / NSC worth double the loan,
// with the parent or guardian applying jointly.
education: {
securityType: 'Property / NSC (double the loan value)',
securityMandatory: 'yes',
margin: 10,
guarantorsRequired: 1,
coApplicant: 'always',
thirdPartySecurity: 'consent'
},
housing: {
securityType: 'Immovable property (mortgage)',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
vehicle: {
securityType: 'Vehicle (hypothecation)',
securityMandatory: 'yes',
margin: 20,
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
business: {
securityType: 'Immovable property / business assets',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
gold_emi: {
securityType: 'Gold ornaments (pledge)',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
term: {
securityType: 'Immovable property (mortgage)',
securityMandatory: 'yes',
margin: 20,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
}
};
/**
* Security & Collateral step for a Loans A/C (EMI) product (step 3).
*
* Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
* limit. UI-only for now: not sent in the create payload.
* limit. Defaults adapt to the chosen sub type (e.g. Personal/Education default to unsecured,
* Housing/Vehicle/Gold default to their usual collateral) but every field stays editable.
*/
@Component({
selector: 'mifosx-loan-product-loan-security-step',
@@ -26,11 +167,46 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox
]
})
export class LoanProductLoanSecurityStepComponent {
export class LoanProductLoanSecurityStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeSubGroup: UntypedFormControl;
loanProductLoanSecurityForm: UntypedFormGroup;
/** What the bank's loan policy says about the margin for a sub type, shown under the Margin field. */
private readonly marginHints: Record<string, string> = {
housing: 'Policy: house construction 25%, flat / house purchase 20%, repair or renovation 10%',
vehicle: 'Policy: new vehicle 20–25%, old vehicle 50%',
education: 'Policy: the student bears 10% of the expense',
business: 'Policy: machinery 25% (old machinery 40–50%)',
personal: 'Unsecured: no margin'
};
get marginHint(): string {
return this.marginHints[this.productTypeSubGroup?.value] ?? '';
}
/** The conditions that apply to the chosen sub type; the template draws one checkbox for each. */
applicableConditions: ConditionRule[] = [];
/**
* The conditions drawn on screen: nothing while the loan is unsecured; the sub type's own list when it has one; and
* every condition (all off) when the officer makes security mandatory on a sub type that has no list of its own,
* so the officer can always set them.
*/
get shownConditions(): ConditionRule[] {
if (!this.isSecured) {
return [];
}
return this.applicableConditions.length > 0 ? this.applicableConditions : GENERIC_CONDITIONS;
}
/** True when sanction needs security; an unsecured product shows no security conditions. */
get isSecured(): boolean {
return this.loanProductLoanSecurityForm.get('securityMandatory').value === 'yes';
}
readonly securityMandatoryOptions = [
{ id: 'yes', label: 'Yes — no sanction without security' },
{ id: 'no', label: 'No — unsecured' }
@@ -47,44 +223,6 @@ export class LoanProductLoanSecurityStepComponent {
{ id: 'no', label: 'Not allowed' }
];
/** Reference table: which security a loan on this product may accept. */
readonly collateralRules = [
{
security: 'Residential property',
allowed: true,
ltv: '80',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Commercial property',
allowed: true,
ltv: '70',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Fixed / Recurring Deposit',
allowed: true,
ltv: '90',
valuation: 'Not needed',
insurance: 'Not needed',
charge: 'Lien marked'
},
{
security: 'LIC policy',
allowed: true,
ltv: '90',
valuation: 'Surrender value',
insurance: 'Not needed',
charge: 'Assignment'
},
{ security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
{ security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
];
constructor() {
this.loanProductLoanSecurityForm = this.formBuilder.group({
securityType: ['Immovable property (mortgage)'],
@@ -112,6 +250,49 @@ export class LoanProductLoanSecurityStepComponent {
});
}
ngOnInit(): void {
this.applySubGroup(this.productTypeSubGroup?.value);
// Keep the security defaults in step with the sub type chosen on the Details step.
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
}
private applySubGroup(subGroup: string): void {
const profile = SECURITY_PROFILES[subGroup];
if (!profile) {
return;
}
this.loanProductLoanSecurityForm.get('securityType').setValue(profile.securityType, { emitEvent: false });
this.loanProductLoanSecurityForm.get('securityMandatory').setValue(profile.securityMandatory, { emitEvent: false });
if (!this.loanProductLoanSecurityForm.get('margin').dirty) {
this.loanProductLoanSecurityForm.get('margin').setValue(profile.margin, { emitEvent: false });
}
if (!this.loanProductLoanSecurityForm.get('guarantorsRequired').dirty) {
this.loanProductLoanSecurityForm
.get('guarantorsRequired')
.setValue(profile.guarantorsRequired, { emitEvent: false });
}
([
'coApplicant',
'thirdPartySecurity'
] as const).forEach((name) => {
const control = this.loanProductLoanSecurityForm.get(name);
if (!control.dirty) {
control.setValue(profile[name], { emitEvent: false });
}
});
// Only the conditions that fit this kind of security apply; the rest are off and not shown.
const rules = CONDITIONS_BY_SUB_TYPE[subGroup] ?? [];
this.applicableConditions = rules;
CONDITION_CONTROLS.forEach((name) => {
const control = this.loanProductLoanSecurityForm.get(name);
if (!control.dirty) {
const rule = rules.find((entry) => entry.control === name);
control.setValue(rule ? rule.defaultOn : false, { emitEvent: false });
}
});
}
/** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
get ltv(): number {
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
@@ -119,8 +300,16 @@ export class LoanProductLoanSecurityStepComponent {
}
get loanProductLoanSecurity() {
const value = this.loanProductLoanSecurityForm.getRawValue();
// A condition that is not shown (unsecured, or not part of this security) is never saved as on.
const shown = new Set(this.shownConditions.map((rule) => rule.control));
CONDITION_CONTROLS.forEach((name) => {
if (!shown.has(name)) {
value[name] = false;
}
});
return {
...this.loanProductLoanSecurityForm.getRawValue(),
...value,
ltv: this.ltv
};
}
@@ -7,13 +7,14 @@
-->
<form [formGroup]="loanProductPenalNpaForm">
<div class="adv-section">
<!-- 1. PENAL -->
<div class="adv-section pn-card pn-card--penal">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
@@ -21,11 +22,11 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
@@ -33,7 +34,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
@@ -45,13 +46,14 @@
</div>
</div>
<div class="adv-section">
<!-- 2. SMA -->
<div class="adv-section pn-card pn-card--sma">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
<i class="fas fa-bell"></i>
<h3>SMA (Special Mention Account)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@@ -61,11 +63,38 @@
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>SMA Counting Starts After (months)</mat-label>
<input matInput type="number" formControlName="smaStartAfterMonths" min="0" required />
<mat-hint>
{{ isAdvanceAccount ? 'Advances: from disbursement (default 9)' : 'Loans: first EMI due (default 1)' }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="pn-stages">
<span class="pn-stage pn-stage--0">SMA-0 · 1–30 days</span>
<span class="pn-stage pn-stage--1">SMA-1 · 31–60 days</span>
<span class="pn-stage pn-stage--2">SMA-2 · 61–90 days</span>
</div>
</div>
<!-- 3. NPA -->
<div class="adv-section pn-card pn-card--npa">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>NPA (Non-Performing Asset)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
@@ -73,7 +102,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
@@ -81,26 +110,45 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
@if (isRevolvingSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Out-of-Order Basis (CC/OD)</mat-label>
<mat-select formControlName="npaOutOfOrderBasis">
@for (opt of npaOutOfOrderBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Stock Statement Validity (days)</mat-label>
<input matInput type="number" formControlName="stockStatementValidityDays" min="0" />
<mat-hint>Older statement + drawings 90 days = NPA</mat-hint>
</mat-form-field>
}
@if (isKccSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Crop Duration (KCC)</mat-label>
<mat-select formControlName="cropDuration">
@for (opt of cropDurationOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="upgradeOnlyAllArrears">
Upgrade from NPA only when all arrears are cleared
</mat-checkbox>
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
@if (isDepositBacked) {
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
}
</div>
</div>
</div>
<!-- 4. ECL (SICR & LGD): hidden for now. The form controls stay in the .ts so the card can be restored. -->
</form>
@@ -6,14 +6,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* UI-only for now — not sent in the create payload.
* Penal charges & asset-classification (SMA / NPA / SICR / LGD) step for a loan product (Figma step 6).
* Shared by both Advances A/C and Loans A/C flows — fields are sent in the create/update payload via
* `loanProductPenalNpa`, mapped onto the core product fields and the LoanProductAdvanceExt satellite params
* in create-loan-product.component.ts.
*/
@Component({
selector: 'mifosx-loan-product-penal-npa-step',
@@ -24,13 +26,40 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox
]
})
export class LoanProductPenalNpaStepComponent {
export class LoanProductPenalNpaStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
@Input() productTypeGroup: UntypedFormControl;
@Input() productTypeSubGroup: UntypedFormControl;
loanProductPenalNpaForm: UntypedFormGroup;
/** True for Advances A/C (cc/overdraft/kcc style limit products); drives which SICR/LGD fields show. */
isAdvanceAccount = false;
/** The ECL card is optional and starts collapsed. */
eclOpen = false;
/** One line for the collapsed ECL card. */
get eclSummary(): string {
const v = this.loanProductPenalNpaForm.getRawValue();
const days = this.isRevolvingSubType ? v.sicrRevolvingDaysThreshold : v.sicrDaysThreshold;
return `Stage 2 after ${days} days late · loss ${v.lgdSecuredPct}% with security, ${v.lgdUnsecuredPct}% without`;
}
/** The chosen sub type (e.g. 'cc', 'overdraft', 'kcc', 'gold') within the current product type group. */
subType: string | null = null;
/** FD / RD / DDS / LIC are lent against a deposit or policy; Gold, CC and the rest are pledged or hypothecated. */
get isDepositBacked(): boolean {
return [
'fd',
'rd',
'dds',
'lic'
].includes(this.subType ?? '');
}
readonly yesNoOptions = [
'YES',
'NO'
@@ -43,7 +72,8 @@ export class LoanProductPenalNpaStepComponent {
readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' },
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' },
{ id: 'crop_season', label: 'Overdue for crop season(s) (KCC)' }
];
readonly interestAfterNpaOptions = [
@@ -51,10 +81,48 @@ export class LoanProductPenalNpaStepComponent {
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
];
readonly sicrAssessmentBasisOptions = [
{ id: 'DPD', label: 'Days-Past-Due threshold only' },
{ id: 'RATING_DOWNGRADE', label: 'Internal rating downgrade only' },
{ id: 'PRICING_INCREASE', label: 'Risk-based repricing only' },
{ id: 'DPD_AND_RATING', label: 'DPD + rating downgrade (either triggers)' }
];
/** RBI 2026: CC/OD "out of order" basis (para on revolving facilities). */
readonly npaOutOfOrderBasisOptions = [
{ id: 'LIMIT_EXCEEDED', label: 'Outstanding continuously exceeds limit/drawing power' },
{ id: 'NO_CREDITS_90_DAYS', label: 'No credits for 90 days, or credits < interest debited' },
{ id: 'DP_NOT_RENEWED', label: 'Drawing power not reassessed/renewed within 180 days' }
];
readonly cropDurationOptions = [
{ id: 'KHARIF', label: 'Kharif (short duration)' },
{ id: 'RABI', label: 'Rabi (short duration)' },
{ id: 'LONG_DURATION', label: 'Long duration crop' }
];
readonly eligibleCollateralTypeOptions = [
{ id: 'NONE', label: 'None' },
{ id: 'GOLD', label: 'Gold / gold jewellery' },
{ id: 'COMMODITY', label: 'Commodity (warehouse receipt)' },
{ id: 'GOVERNMENT_SECURITIES', label: 'Government securities' },
{ id: 'BANK_DEPOSIT', label: 'Bank term deposit (lien-marked)' },
{ id: 'LIFE_INSURANCE', label: 'Life insurance policy (surrender value)' }
];
get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
}
/** CC/OD are revolving facilities — RBI's "out of order" NPA basis and 60-day SICR window apply to them. */
get isRevolvingSubType(): boolean {
return this.subType === 'cc' || this.subType === 'overdraft';
}
get isKccSubType(): boolean {
return this.subType === 'kcc';
}
constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'],
@@ -78,13 +146,125 @@ export class LoanProductPenalNpaStepComponent {
npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0],
smaStartAfterMonths: [
1,
[
Validators.required,
Validators.min(0)
]
],
stockStatementValidityDays: [
90,
[Validators.min(0)]
],
inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true],
keepStandardIfDepositCovers: [true]
keepStandardIfDepositCovers: [true],
// ---- SICR (Significant Increase in Credit Risk) — RBI 2026 ECL framework ----
sicrDaysThreshold: [
30,
[Validators.min(0)]
],
sicrRevolvingDaysThreshold: [
60,
[Validators.min(0)]
],
sicrRatingDowngradeNotches: [
2,
[Validators.min(0)]
],
sicrPricingIncreaseTriggerPct: [
1.5,
[
Validators.min(0),
Validators.max(100)
]
],
sicrAssessmentBasis: ['DPD'],
sicrRebuttalAllowed: [false],
npaOutOfOrderBasis: ['LIMIT_EXCEEDED'],
cropDuration: [''],
// ---- LGD (Loss Given Default) — regulatory backstop vs bank's own model ----
useOwnLgdModel: [false],
lgdSecuredPct: [
65,
[
Validators.min(0),
Validators.max(100)
]
],
lgdUnsecuredPct: [
70,
[
Validators.min(0),
Validators.max(100)
]
],
eligibleCollateralType: ['NONE']
});
}
ngOnInit() {
if (this.productTypeGroup) {
this.applyProductTypeRules(this.productTypeGroup.value === 'advances', this.productTypeSubGroup?.value ?? null);
this.productTypeGroup.valueChanges.subscribe((group: string) => {
this.applyProductTypeRules(group === 'advances', this.productTypeSubGroup?.value ?? null);
});
}
if (this.productTypeSubGroup) {
this.productTypeSubGroup.valueChanges.subscribe((subGroup: string) => {
this.applyProductTypeRules(this.productTypeGroup?.value === 'advances', subGroup);
});
}
}
/**
* CC/OD (revolving) default to the 60-day SICR window and show the "out of order" NPA basis; KCC shows
* the crop-duration field instead. Term/EMI loans use the plain 30-day DPD-based SICR window and hide
* both revolving-only fields. Only defaults the value the first time a sub type becomes relevant, so it
* doesn't clobber a value the user already edited.
*/
private applyProductTypeRules(isAdvance: boolean, subType: string | null): void {
this.isAdvanceAccount = isAdvance;
this.subType = isAdvance ? subType : null;
if (this.isRevolvingSubType && !this.loanProductPenalNpaForm.get('sicrAssessmentBasis')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ sicrAssessmentBasis: 'DPD_AND_RATING' });
}
// The security that backs the product decides which collateral counts for ECL.
const collateralBySubType: Record<string, string> = {
fd: 'BANK_DEPOSIT',
rd: 'BANK_DEPOSIT',
dds: 'BANK_DEPOSIT',
lic: 'LIFE_INSURANCE',
gold: 'GOLD',
warehouse: 'COMMODITY',
gold_emi: 'GOLD'
};
const collateral = subType ? collateralBySubType[subType] : undefined;
if (!this.loanProductPenalNpaForm.get('eligibleCollateralType')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ eligibleCollateralType: collateral ?? 'NONE' });
}
// SMA counting starts 9 months after disbursement for Advances, 1 month for Loans (EMI).
if (!this.loanProductPenalNpaForm.get('smaStartAfterMonths')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ smaStartAfterMonths: isAdvance ? 9 : 1 });
}
// NPA trigger follows the facility: CC/OD out-of-order, KCC crop season, everything else overdue days.
if (!this.loanProductPenalNpaForm.get('npaTrigger')?.dirty) {
const trigger = this.isRevolvingSubType ? 'out_of_order' : this.isKccSubType ? 'crop_season' : 'maturity';
this.loanProductPenalNpaForm.patchValue({ npaTrigger: trigger });
}
}
get loanProductPenalNpa() {
return this.loanProductPenalNpaForm.getRawValue();
const value = this.loanProductPenalNpaForm.getRawValue();
// Advances have no instalments, so the 9-month start is carried as arrears-ageing grace (30-day months).
// Loans (EMI) need none: the first instalment falls due one month after disbursement already.
const months = Number(value.smaStartAfterMonths) || 0;
return { ...value, graceOnArrearsAgeing: this.isAdvanceAccount ? months * 30 : 0 };
}
}
@@ -6,12 +6,38 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<mifosx-loan-product-summary
[action]="'preview'"
[loanProduct]="loanProduct"
[loanProductsTemplate]="loanProductsTemplate"
[paymentAllocations]="loanProduct.paymentAllocation"
[creditAllocations]="loanProduct.creditAllocation"
[supportedInterestRefundTypes]="loanProduct.supportedInterestRefundTypes"
>
</mifosx-loan-product-summary>
<div class="rv">
@if (!readOnly) {
<p class="rv__intro">
Check the details below. Use <strong>Submit</strong> at the bottom to send the product for approval.
</p>
}
@for (section of sections; track section.title) {
<div class="rv__card">
<div class="rv__head"><i class="fas {{ section.icon }}"></i>{{ section.title }}</div>
<div class="rv__grid">
@for (item of section.rows; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</div>
}
@if (otherSettings.length > 0) {
<details class="rv__card rv__other">
<summary class="rv__head"><i class="fas fa-sliders"></i>Other settings ({{ otherSettings.length }})</summary>
<div class="rv__grid">
@for (item of otherSettings; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</details>
}
</div>
@@ -47,3 +47,74 @@ mat-divider {
.repayment-strategy {
color: $status-approved;
}
/* Review step: sections of label / value rows. */
.rv {
display: flex;
flex-direction: column;
gap: 14px;
&__intro {
margin: 0;
color: #475569;
font-size: 13px;
}
&__card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #f8fafc;
color: #1e293b;
font-size: 14px;
font-weight: 600;
cursor: default;
i {
color: #6366f1;
}
}
&__other > &__head {
cursor: pointer;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
padding: 6px 16px 10px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
&__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 13px;
}
&__label {
color: #64748b;
}
&__value {
color: #1e293b;
font-weight: 500;
text-align: right;
word-break: break-word;
}
}
@@ -6,44 +6,452 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
import { LoanProducts } from '../../loan-products';
import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { productCategoryLabel, productTypeLabel } from '../../loan-product-types';
interface ReviewRow {
label: string;
value: string;
}
interface ReviewSection {
title: string;
icon: string;
rows: ReviewRow[];
}
/**
* Review step for the Advances and Loans wizards: a plain read-out of what is about to be submitted, in sections, with
* the GL accounts shown by name. (The standard Fineract summary expects option objects this wizard does not build.)
*/
@Component({
selector: 'mifosx-loan-product-preview-step',
templateUrl: './loan-product-preview-step.component.html',
styleUrls: ['./loan-product-preview-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
LoanProductSummaryComponent,
FaIconComponent
]
imports: []
})
export class LoanProductPreviewStepComponent implements OnInit, OnChanges {
export class LoanProductPreviewStepComponent {
@Input() loanProductsTemplate: any;
@Input() accountingRuleData: any;
@Input() loanProduct: any;
@Input() isValid: boolean = true;
@Input() isValid = true;
/** True on the product View page: the reader without the "submit for approval" wording. */
@Input() readOnly = false;
@Output() submitEvent = new EventEmitter();
isAdvancedPaymentAllocation = false;
/** Keys shown in a section above; everything else non-empty goes to "Other settings". */
private readonly shown = new Set<string>();
private cachedFor: any = null;
private cachedSections: ReviewSection[] = [];
private cachedOther: ReviewRow[] = [];
constructor() {}
/** Keys that are technical or already covered, never shown. */
private readonly hidden = new Set<string>([
'locale',
'dateFormat',
'charges',
'accountingRule',
'inMultiplesOf',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'glAccount',
'interestRatePerPeriod'
]);
ngOnInit() {
this.advancedPaymentAllocation();
get sections(): ReviewSection[] {
this.build();
return this.cachedSections;
}
ngOnChanges(changes: SimpleChanges): void {
this.advancedPaymentAllocation();
get otherSettings(): ReviewRow[] {
this.build();
return this.cachedOther;
}
advancedPaymentAllocation() {
this.isAdvancedPaymentAllocation = LoanProducts.isAdvancedPaymentAllocationStrategy(
this.loanProduct.transactionProcessingStrategyCode
);
/** Rebuilt only when the product object changes (it is stable until a form value changes). */
private build(): void {
const p = this.loanProduct;
if (!p || p === this.cachedFor) {
return;
}
this.cachedFor = p;
this.shown.clear();
const row = (label: string, key: string, format?: (value: any) => string): ReviewRow | null => {
this.shown.add(key);
const value = p[key];
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
return null;
}
return { label, value: format ? format(value) : this.format(value) };
};
const gl = (label: string, key: string) => row(label, key, (id) => this.glName(id));
const pct = (value: any) => `${value}%`;
const months = (value: any) => `${value} months`;
const rupees = (value: any) => `₹ ${Number(value).toLocaleString('en-IN')}`;
// An option id such as "fixed_day" read as words ("Fixed day").
const words = (value: any) => {
const text = String(value).replace(/_/g, ' ');
return text.charAt(0).toUpperCase() + text.slice(1);
};
const sections: ReviewSection[] = [
{
title: 'Product',
icon: 'fa-hand-holding-usd',
rows: [
{ label: 'Type', value: productCategoryLabel(p.productTypeGroup) },
{ label: 'Sub type', value: productTypeLabel(p.productTypeGroup, p.productTypeSubGroup) },
row('Name', 'name'),
row('Short name', 'shortName'),
gl('Product GL (asset)', 'glAccount'),
row('Start date', 'startDate'),
row('Close date', 'closeDate'),
row('Currency', 'currencyCode'),
row('Decimal places', 'digitsAfterDecimal')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Amount and tenure',
icon: 'fa-coins',
rows: [
row('Facility type', 'facilityType'),
row('Minimum amount / limit', 'minPrincipal', rupees),
row('Default amount / limit', 'principal', rupees),
row('Maximum amount / limit', 'maxPrincipal', rupees),
row('Minimum tenure', 'minNumberOfRepayments', months),
row('Default tenure', 'numberOfRepayments', months),
row('Maximum tenure', 'maxNumberOfRepayments', months),
row('Repayment type', 'repaymentType', (v) =>
v === 'bullet'
? 'Bullet: principal + interest at maturity'
: v === 'running'
? 'Running: any time, clear by limit expiry'
: v
),
row('Appropriation order', 'appropriationOrder', (v) =>
v === 'charges_penal_interest_principal'
? 'Charges, then penal, then interest, then principal'
: v === 'principal_interest_penal_charges'
? 'Principal, then interest, then penal, then charges'
: v
)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Interest',
icon: 'fa-percent',
rows: [
row('Rate register row', 'registerParticular'),
p.interestRatePerPeriod !== undefined
? { label: 'Rate (default)', value: pct(p.interestRatePerPeriod) }
: null,
row('Spread', 'spread', pct),
row('Calculation method', 'interestCalcMethod'),
row('Interest charged on', 'interestChargedOn'),
row('Posting frequency', 'interestPostingFrequency'),
row('Posting day', 'interestPostingDay')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Security and lien',
icon: 'fa-shield-halved',
rows: [
row('Security type', 'securityType'),
row(
'Eligible amount basis',
'eligibleAmountBasis',
(v) =>
(
({
gold_value: 'Net weight × purity × bank gold rate',
balance_accrued: 'Deposit balance + accrued interest',
balance: 'Deposit balance only',
surrender: 'Surrender value (LIC)',
commodity_value: 'Quantity × market price of the commodity'
}) as Record<string, string>
)[v] ?? v
),
row('Margin', 'margin', pct),
row('LTV', 'ltv', pct),
row('Lien required', 'lienRequired'),
row('Lien marking stage', 'lienMarkingStage'),
row('Eligible deposit products', 'eligibleDepositProducts'),
row('Lien certificate prefix', 'lienCertificatePrefix'),
row('Tenure cannot exceed deposit maturity', 'lienTenureNotExceedMaturity'),
row('Auto-release lien on closure', 'lienAutoReleaseOnClosure'),
row('Multiple deposits for one advance', 'lienAllowMultipleDeposits'),
row('Adjust from deposit at maturity', 'lienAdjustFromMaturity'),
row('Third-party deposit allowed', 'lienAllowThirdParty'),
row('Deposit renewal under lien', 'lienAllowRenewalUnderLien')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Gold details',
icon: 'fa-ring',
rows: [
row('Maximum LTV cap', 'goldMaxLtv', pct),
row('Minimum purity', 'goldMinPurityCarat', (v) => `${v} carat`),
row('Valuation rate source', 'goldRateSource', (v) =>
v === 'ibja_30d_avg' ? 'IBJA 30-day average' : 'Bank daily gold rate'
),
row('Allowed items', 'goldAllowedItems', (v) =>
v === 'ornaments_coins_50g' ? 'Ornaments + coins (up to 50 g)' : 'Ornaments only'
),
row('Max loan per gram', 'goldMaxPerGram', rupees),
row('Minimum net weight', 'goldMinNetWeight', (v) => `${v} g`),
row('Max net weight per customer', 'goldMaxNetWeightPerCustomer', (v) => `${v} g`),
row('Appraiser required', 'goldAppraiserRequired'),
row('Valuation certificate required', 'goldValuationCertificateRequired'),
row('Max valuation per appraiser', 'goldMaxValuationPerAppraiser', rupees),
row('Packet number prefix', 'goldPacketPrefix'),
row('Revalue gold when loan turns NPA', 'goldRevalueOnNpa'),
row('Charge customer for revaluation', 'goldRevaluationChargeApplicable'),
row('Insurance required', 'insuranceRequired'),
row('Partial release of ornaments', 'goldPartialReleaseAllowed'),
row('Third-party gold allowed', 'goldThirdPartyAllowed'),
row('Disbursement mode', 'goldDisbursementMode', (v) =>
v === 'account_credit' ? 'Account credit only' : 'Account credit; cash up to the limit'
),
row('Cash disbursement limit', 'goldCashDisbursementLimit', rupees),
row('LTV breach alert', 'goldLtvBreachAlert', pct),
row('Auction notice', 'goldAuctionNoticeDays', (v) => `${v} days`),
row('Auction reserve price', 'goldAuctionReservePct', (v) => `${v}% of market value`),
row('Customer may redeem before auction', 'goldRedeemBeforeAuction')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Warehouse receipt details',
icon: 'fa-warehouse',
rows: [
row('Maximum LTV cap', 'wrMaxLtv', pct),
row('Receipt type allowed', 'wrReceiptType', (v) =>
v === 'e_nwr' ? 'e-NWR (electronic)' : v === 'paper_nwr' ? 'Paper NWR' : 'Both e-NWR and paper NWR'
),
row('WDRA-registered warehouse', 'wrWdraRegistered'),
row('Quality / assay certificate', 'wrAssayCertificateRequired'),
row('Tenure within receipt validity', 'wrTenureWithinReceiptValidity'),
row('Pledge marked in repository first', 'wrPledgeMarkedBeforeDisbursement'),
row('Eligible commodity groups', 'wrCommodityGroups', (v) =>
String(v)
.split('|')
.map((id) => id.charAt(0).toUpperCase() + id.slice(1))
.join(', ')
),
row(
'Valuation price source',
'wrValuationPriceSource',
(v) =>
(
({
mandi_apmc: 'Mandi / APMC price',
msp: 'MSP (government support price)',
exchange_ncdex: 'Exchange price (NCDEX)',
bank_assessed: 'Bank-assessed price'
}) as Record<string, string>
)[v] ?? v
),
row('Max loan per quintal', 'wrMaxLoanPerQuintal', rupees),
row('Insurance required', 'insuranceRequired'),
row('Stock inspection every', 'wrStockInspectionMonths', months),
row('LTV breach alert', 'wrLtvBreachAlert', pct),
row('Partial release of stock', 'wrPartialReleaseAllowed'),
row('Disposal notice', 'wrDisposalNoticeDays', (v) => `${v} days`),
row('Disposal reserve price', 'wrDisposalReservePct', (v) => `${v}% of market value`),
row('Customer may redeem before disposal', 'wrRedeemBeforeDisposal')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Borrower eligibility',
icon: 'fa-user',
rows: [
row('Applicant must be a bank member', 'memberRequired'),
row('Guarantors must be bank members', 'guarantorMustBeMember'),
row('Minimum age', 'minAge', (v) => `${v} years`),
row('Maximum age at maturity', 'maxAgeAtMaturity', (v) => `${v} years`),
row('Minimum CIBIL score', 'minCibil'),
row('Minimum monthly income', 'minMonthlyIncome', rupees),
row('Income multiple', 'incomeMultiple', (v) => `${v}× net monthly salary`),
row('Maximum FOIR / DBR', 'maxFoir', pct),
row('Maximum loans per customer', 'maxLoansPerCustomer')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Security and guarantors (EMI loan)',
icon: 'fa-shield-halved',
rows: [
row('Security mandatory', 'securityMandatory', words),
row('Guarantors required', 'guarantorsRequired'),
row('Co-applicant', 'coApplicant', words),
row('Third-party security', 'thirdPartySecurity', words),
row('Legal opinion / title search', 'legalOpinionRequired'),
row('Insurance of the security', 'propertyInsuranceRequired'),
row('Charge registered before disbursement', 'registerCharge'),
row('Release security on closure', 'releaseSecurityOnClosure'),
row('Multiple securities allowed', 'allowMultipleSecurities')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Disbursement',
icon: 'fa-arrow-right',
rows: [
row('Disbursement type', 'disbursementType', words),
row('Disbursement mode', 'disbursementMode', words),
row('Interest on undisbursed amount', 'undisbursedInterest', words),
row('Pre-EMI interest', 'preEmiInterest')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Repayment and EMI',
icon: 'fa-calendar-days',
rows: [
row('EMI due day', 'emiDueDay', words),
row('If the due date is a holiday', 'holidayRule', words),
row('Moratorium', 'moratoriumMonths', months),
row('Interest during moratorium', 'moratoriumInterest', words),
row('Variable instalments', 'variableInstallments'),
row('Repayment mode', 'repaymentMode', words),
row('Linked account type', 'linkedAccountType', words),
row('Cheque (PDC) collection', 'pdcCollection', words),
row('Mandate required', 'mandateRequired'),
row('Advance EMI', 'advanceEmi', words)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Interest rate settings',
icon: 'fa-percent',
rows: [
row('Rate type', 'rateType', words),
row('CIBIL adjustment applied', 'applyCibilAdjustment'),
row('Rate decided at', 'rateResolvedAt', words),
row('Calculation period', 'calcPeriod', words),
row('Interest starts from', 'interestStartsFrom', words),
row('Compounding', 'compounding', words),
row('Benchmark', 'benchmark', words),
row('Reset frequency', 'resetFrequency', words),
row('On reset, change', 'resetChange', words)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Prepayment and foreclosure',
icon: 'fa-flag-checkered',
rows: [
row('Part-prepayment allowed', 'partPrepaymentAllowed'),
row('Minimum part-payment', 'minPartPayment', rupees),
row('Part-payment allowed on', 'allowedOn', words),
row('Part-payments per year', 'partPaymentsPerYear', words),
row('Part-payment mode', 'partPrepaymentMode', words),
row('Foreclosure: individual, floating rate', 'foreclosureFloatingIndividual', pct),
row('Foreclosure: individual, fixed rate', 'foreclosureFixedIndividual', pct),
row('Foreclosure: non-individual', 'foreclosureNonIndividual', pct),
row('Foreclosure: takeover', 'foreclosureTakeover', pct),
row('Foreclosure quote valid for', 'foreclosureQuoteValidDays', (v) => `${v} days`),
row('Close automatically at zero balance', 'autoCloseOnZeroBalance'),
row('NOC letter', 'nocLetter', words),
row('Restructuring', 'restructuring', words),
row('Top-up allowed', 'topUpAllowed'),
row('Balance transfer out allowed', 'balanceTransferOutAllowed')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Penal, SMA and NPA',
icon: 'fa-triangle-exclamation',
rows: [
row('Penal charges', 'penalApplicable'),
row('Penal rate', 'penalRate', pct),
row('Penal applied on', 'penalAppliedOn'),
row('Penal grace days', 'penalGraceDays'),
row('SMA counting starts after', 'smaStartAfterMonths', months),
row('NPA after', 'overdueDaysForNPA', (v) => `${v} days overdue`),
row('NPA trigger', 'npaTrigger'),
row('Stock statement validity', 'stockStatementValidityDays', (v) => `${v} days`),
row('Interest after NPA', 'interestAfterNpa'),
row('Upgrade only when all arrears are cleared', 'npaUpgradeOnlyAllArrears'),
row('Keep standard while deposit covers dues', 'npaKeepStandardIfDepositCovers')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Renewal and closure',
icon: 'fa-rotate',
rows: [
row('Renewal allowed', 'renewalAllowed'),
row('Renewal period', 'renewalPeriodMonths', months),
row('Renewal grace', 'renewalGraceDays', (v) => `${v} days`),
row('Pre-closure', 'preclosureAllowed'),
row('Lock-in', 'lockInMonths', months),
row('Interest on pre-closure', 'interestOnPreclosure')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Charges',
icon: 'fa-receipt',
rows: [row('Attached charges', 'chargesSummary')].filter((r): r is ReviewRow => !!r)
},
{
title: 'Accounting (GL accounts)',
icon: 'fa-book',
rows: [
gl('Loan portfolio', 'loanPortfolioAccountId'),
gl('Fund source', 'fundSourceAccountId'),
gl('Interest income', 'interestOnLoanAccountId'),
gl('Fee income', 'incomeFromFeeAccountId'),
gl('Penal income', 'incomeFromPenaltyAccountId'),
gl('Write-off', 'writeOffAccountId'),
gl('Interest receivable', 'receivableInterestAccountId'),
gl('Overpayment', 'overpaymentLiabilityAccountId'),
gl('Interest suspense', 'undebitedInterestGlId')
].filter((r): r is ReviewRow => !!r)
}
].filter((section) => section.rows.length > 0);
// The group and sub type are shown through the label row above.
this.shown.add('productTypeGroup');
this.shown.add('productTypeSubGroup');
this.cachedSections = sections;
// On the View page the product also carries every raw core field; only the extension record is "other settings".
const candidates = this.readOnly ? Object.keys(p.advanceExtensions ?? {}) : Object.keys(p);
this.cachedOther = candidates
.filter((key) => key !== 'advanceExtensions' && !this.shown.has(key) && !this.hidden.has(key))
.filter(
(key) => p[key] !== undefined && p[key] !== null && p[key] !== '' && !(Array.isArray(p[key]) && !p[key].length)
)
.map((key) => ({ label: this.humanize(key), value: this.format(p[key]) }));
}
private glName(id: any): string {
const options = this.loanProductsTemplate?.accountingMappingOptions ?? {};
const all: any[] = [
...(options.assetAccountOptions ?? []),
...(options.incomeAccountOptions ?? []),
...(options.expenseAccountOptions ?? []),
...(options.liabilityAccountOptions ?? []),
...(options.equityAccountOptions ?? [])
];
const account = all.find((entry) => String(entry.id) === String(id));
return account ? `${account.glCode} ${account.name}` : String(id);
}
private humanize(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
private format(value: any): string {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
return value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ');
}
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
}
@@ -27,15 +27,29 @@
<mat-hint>From Sub Type — not chosen again</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
@if (isGold || isWarehouse) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<input matInput [value]="isGold ? goldBasisLabel : warehouseBasisLabel" disabled />
<mat-hint>
@if (isGold) {
Fixed for Gold: valued on weight, purity and the gold rate
} @else {
Fixed for Warehouse Receipt: valued on quantity and the commodity's market price
}
</mat-hint>
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Margin (%)</mat-label>
@@ -53,9 +67,424 @@
<input matInput [value]="ltv" disabled />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
@if (isGold) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Maximum LTV Cap (%)</mat-label>
<input matInput type="number" formControlName="goldMaxLtv" min="1" max="100" step="0.01" required />
<mat-hint>No account may be sanctioned above this LTV (RBI ceiling 75%)</mat-hint>
@if (loanProductSecurityLienForm.get('goldMaxLtv').hasError('required')) {
<mat-error>Maximum LTV Cap is <strong>required</strong></mat-error>
} @else {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
}
@if (isWarehouse) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Maximum LTV Cap (%)</mat-label>
<input matInput type="number" formControlName="wrMaxLtv" min="1" max="100" step="0.01" required />
<mat-hint>No account may be sanctioned above this LTV</mat-hint>
@if (loanProductSecurityLienForm.get('wrMaxLtv').hasError('required')) {
<mat-error>Maximum LTV Cap is <strong>required</strong></mat-error>
} @else {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
}
</div>
@if ((isGold || isWarehouse) && loanProductSecurityLienForm.hasError('ltvAboveCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV (100 − Margin) is above the Maximum LTV Cap. Raise the margin or the cap.
</div>
}
</div>
@if (isGold) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-ring"></i>
<h3>Gold Details</h3>
<span class="lsl-section__hint">Pledged ornaments — no lien marking</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Valuation basis</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Minimum Purity (carat)</mat-label>
<mat-select formControlName="goldMinPurityCarat" required>
@for (carat of goldPurityOptions; track carat) {
<mat-option [value]="carat">{{ carat }} carat</mat-option>
}
</mat-select>
<mat-hint>Ornaments below this purity are not accepted</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Rate Source</mat-label>
<mat-select formControlName="goldRateSource" required>
@for (opt of goldRateSourceOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Allowed Items</mat-label>
<mat-select formControlName="goldAllowedItems" required>
@for (opt of goldAllowedItemsOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Loan per Gram (₹)</mat-label>
<input matInput type="number" formControlName="goldMaxPerGram" min="1" />
<mat-hint>Optional bank cap on the gold rate</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Min Net Weight (g)</mat-label>
<input matInput type="number" formControlName="goldMinNetWeight" min="0" step="0.01" />
<mat-hint>Optional</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Net Weight per Customer (g)</mat-label>
<input matInput type="number" formControlName="goldMaxNetWeightPerCustomer" min="0" step="0.01" />
<mat-hint>Optional</mat-hint>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('netWeightRange')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
Minimum net weight cannot exceed the maximum per customer.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Valuation by appraiser</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Appraiser Required</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Certificate Required</mat-label>
<mat-select formControlName="goldValuationCertificateRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Valuation per Appraiser (₹)</mat-label>
<input matInput type="number" formControlName="goldMaxValuationPerAppraiser" min="1" />
<mat-hint>Optional default limit for one appraiser</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Safe custody</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Packet No. Prefix</mat-label>
<input matInput formControlName="goldPacketPrefix" maxlength="20" />
<mat-hint>The packet number itself is set per account</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Insurance Required</mat-label>
<mat-select formControlName="insuranceRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Revaluation</p>
<div class="lsl-checks">
<mat-checkbox formControlName="goldRevalueOnNpa">
Revalue the gold when the loan turns NPA (short-term loans are not revalued on a schedule)
</mat-checkbox>
<mat-checkbox formControlName="goldRevaluationChargeApplicable">
Charge the customer for revaluation (set the fee on the Charges step)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Loan servicing rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="goldPartialReleaseAllowed">
Partial release of ornaments allowed on part payment (LTV must stay within the cap)
</mat-checkbox>
<mat-checkbox formControlName="goldThirdPartyAllowed">
Third-party gold allowed (relative's gold, consent letter required)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Disbursement and monitoring</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disbursement Mode</mat-label>
<mat-select formControlName="goldDisbursementMode" required>
@for (opt of goldDisbursementModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductSecurityLienForm.get('goldDisbursementMode').value === 'account_credit_cash_limit') {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Cash Disbursement Limit (₹)</mat-label>
<input
matInput
type="number"
formControlName="goldCashDisbursementLimit"
min="0"
[max]="goldCashLimitMax"
/>
<mat-hint>Cash above this is not allowed (Income Tax Sec 269SS)</mat-hint>
<mat-error>Must be between 0 and {{ goldCashLimitMax }}</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV Breach Alert (%)</mat-label>
<input
matInput
type="number"
formControlName="goldLtvBreachAlert"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Flag the account when the live LTV (after a gold rate fall) crosses this level</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('alertBelowCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV Breach Alert cannot be below the Maximum LTV Cap.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Auction and recovery</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Auction Notice Period (days)</mat-label>
<input matInput type="number" formControlName="goldAuctionNoticeDays" min="0" required />
<mat-hint>Notice given before an auction after NPA</mat-hint>
<mat-error>Auction Notice Period is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Auction Reserve Price (% of market value)</mat-label>
<input
matInput
type="number"
formControlName="goldAuctionReservePct"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Gold is not sold below this share of the market value</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox [checked]="true" disabled>
Customer may redeem (clear dues and take the gold) any time before the auction. Always on
</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Packet number, item list, weights, carat, appraiser and valuation date are captured per account at
<strong>Loan Account Creation</strong>, not on the product.
</div>
}
@if (isWarehouse) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-warehouse"></i>
<h3>Warehouse Receipt Details</h3>
<span class="lsl-section__hint">Pledged commodity — no lien marking</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Receipt</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Receipt Type Allowed</mat-label>
<mat-select formControlName="wrReceiptType" required>
@for (opt of wrReceiptTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-hint>e-NWR is pledge-marked online in the repository</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Warehouse WDRA-registered</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Quality / Assay Certificate</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox formControlName="wrTenureWithinReceiptValidity">
Tenure cannot exceed the receipt's validity date
</mat-checkbox>
<mat-checkbox formControlName="wrPledgeMarkedBeforeDisbursement">
Receipt must be pledge-marked in the repository before disbursement
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Commodity and valuation</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field lsl-field--full">
<mat-label>Eligible Commodity Groups</mat-label>
<mat-select formControlName="wrCommodityGroups" multiple required>
@for (opt of wrCommodityGroupOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-hint>Only receipts for these commodities can be pledged</mat-hint>
<mat-error>Pick at least one commodity group</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Price Source</mat-label>
<mat-select formControlName="wrValuationPriceSource" required>
@for (opt of wrValuationSourceOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Loan per Quintal (₹)</mat-label>
<input matInput type="number" formControlName="wrMaxLoanPerQuintal" min="1" />
<mat-hint>Optional bank cap on the price</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Insurance and inspection</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Insurance Required</mat-label>
<mat-select formControlName="insuranceRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Stock Inspection Every (months)</mat-label>
<input matInput type="number" formControlName="wrStockInspectionMonths" min="1" required />
<mat-hint>Physical check that the stock is in the warehouse</mat-hint>
<mat-error>Required, at least 1</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV Breach Alert (%)</mat-label>
<input matInput type="number" formControlName="wrLtvBreachAlert" min="1" max="100" step="0.01" required />
<mat-hint>Flag the account when a price fall pushes the live LTV above this</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('alertBelowCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV Breach Alert cannot be below the Maximum LTV Cap.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Loan servicing rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="wrPartialReleaseAllowed">
Partial release of stock allowed on part payment (LTV must stay within the cap)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Disposal and recovery</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disposal Notice Period (days)</mat-label>
<input matInput type="number" formControlName="wrDisposalNoticeDays" min="0" required />
<mat-hint>Notice given before the stock is sold after NPA</mat-hint>
<mat-error>Disposal Notice Period is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disposal Reserve Price (% of market value)</mat-label>
<input
matInput
type="number"
formControlName="wrDisposalReservePct"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Stock is not sold below this share of the market value</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox [checked]="true" disabled>
Customer may redeem (clear dues and take the stock) any time before the disposal. Always on
</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Commodity, quantity, grade, warehouse, receipt number and validity, repository pledge reference and the market
price on the sanction date are captured per account at <strong>Loan Account Creation</strong>, not on the
product.
</div>
}
@if (isLienSubType) {
<div class="lsl-section">
<div class="lsl-section__header">
@@ -115,3 +115,20 @@ $gray-900: #202124;
color: $accent;
}
}
.lsl-error {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 24px 16px;
padding: 10px 14px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
font-size: 13px;
color: #b91c1c;
}
.lsl-checks--spaced {
margin-top: 14px;
}
@@ -67,9 +67,65 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
fd: 10,
rd: 10,
dds: 10,
lic: 40
lic: 40,
gold: 25,
warehouse: 25
};
/** RBI ceiling a gold loan may be sanctioned at; the product's own LTV cap defaults to it. */
private readonly goldDefaultLtvCap = 75;
readonly goldPurityOptions = [
18,
20,
22,
24
];
readonly goldRateSourceOptions = [
{ id: 'bank_daily', label: 'Bank daily gold rate' },
{ id: 'ibja_30d_avg', label: 'IBJA 30-day average' }
];
readonly goldAllowedItemsOptions = [
{ id: 'ornaments_only', label: 'Ornaments only' },
{ id: 'ornaments_coins_50g', label: 'Ornaments + coins (up to 50 g)' }
];
readonly goldBasisLabel = 'Net weight × purity × bank gold rate';
// ---- Loan against Warehouse Receipt ----
readonly warehouseBasisLabel = 'Quantity × market price of the commodity';
readonly wrReceiptTypeOptions = [
{ id: 'e_nwr', label: 'e-NWR (electronic)' },
{ id: 'paper_nwr', label: 'Paper NWR' },
{ id: 'both', label: 'Both' }
];
readonly wrCommodityGroupOptions = [
{ id: 'cereals', label: 'Cereals' },
{ id: 'pulses', label: 'Pulses' },
{ id: 'oilseeds', label: 'Oilseeds' },
{ id: 'spices', label: 'Spices' },
{ id: 'others', label: 'Others' }
];
readonly wrValuationSourceOptions = [
{ id: 'mandi_apmc', label: 'Mandi / APMC price' },
{ id: 'msp', label: 'MSP (government support price)' },
{ id: 'exchange_ncdex', label: 'Exchange price (NCDEX)' },
{ id: 'bank_assessed', label: 'Bank-assessed price' }
];
readonly goldDisbursementModeOptions = [
{ id: 'account_credit', label: 'Account credit only' },
{ id: 'account_credit_cash_limit', label: 'Account credit; cash up to the limit' }
];
/** The most that may be paid out in cash against one gold loan (Income Tax Sec 269SS). */
readonly goldCashLimitMax = 20000;
readonly eligibleAmountBasisOptions = [
{ id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
{ id: 'balance', label: 'Deposit balance only' },
@@ -85,9 +141,18 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular',
'Daily Deposit Scheme'
'Daily Deposit Scheme',
'LIC Policy'
];
/** The deposit or policy a sub type is lent against; this is the default "Eligible Deposit Products". */
private readonly defaultDepositBySubGroup: Record<string, string> = {
fd: 'Fixed Deposit – Regular',
rd: 'Recurring Deposit – Regular',
dds: 'Daily Deposit Scheme',
lic: 'LIC Policy'
};
readonly yesNoOptions = [
'YES',
'NO'
@@ -111,6 +176,23 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
}
// Gold is valued on weight, purity and the gold rate; the deposit bases apply to every other type.
const basis = this.loanProductSecurityLienForm.get('eligibleAmountBasis');
if (subGroup === 'gold') {
basis.setValue('gold_value', { emitEvent: false });
} else if (subGroup === 'warehouse') {
basis.setValue('commodity_value', { emitEvent: false });
} else if (basis.value === 'gold_value' || basis.value === 'commodity_value') {
basis.setValue('balance_accrued', { emitEvent: false });
}
this.loanProductSecurityLienForm.updateValueAndValidity({ emitEvent: false });
// The deposit list follows the sub type until the officer has chosen their own.
// A sub type with no deposit of its own (gold, CC ...) keeps the plain FD default; the list is hidden for it.
const deposit = this.defaultDepositBySubGroup[subGroup] ?? 'Fixed Deposit – Regular';
const deposits = this.loanProductSecurityLienForm.get('eligibleDepositProducts');
if (deposit && deposits && !deposits.dirty) {
deposits.setValue([deposit], { emitEvent: false });
}
}
/** This whole step is only relevant to an Advances A/C product. */
@@ -118,6 +200,16 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
return this.productTypeGroup?.value === 'advances';
}
/** Loan against Gold: pledged ornaments, valued by an appraiser; no lien or deposit list. */
get isGold(): boolean {
return this.productTypeSubGroup?.value === 'gold';
}
/** Loan against Warehouse Receipt: a pledged commodity, valued on quantity and market price; no lien or deposit list. */
get isWarehouse(): boolean {
return this.productTypeSubGroup?.value === 'warehouse';
}
/** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */
get isLienSubType(): boolean {
return this.lienSubGroups.includes(this.productTypeSubGroup?.value);
@@ -133,41 +225,216 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
}
createForm() {
this.loanProductSecurityLienForm = this.formBuilder.group({
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
this.loanProductSecurityLienForm = this.formBuilder.group(
{
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES'],
// ---- Loan against Gold (hidden and not sent for any other type) ----
goldMaxLtv: [
this.goldDefaultLtvCap,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
goldMinPurityCarat: [
18,
Validators.required
],
goldRateSource: [
'bank_daily',
Validators.required
],
goldMaxPerGram: [
'',
Validators.min(1)
],
goldMinNetWeight: [
'',
Validators.min(0)
],
goldMaxNetWeightPerCustomer: [
'',
Validators.min(0)
],
goldAllowedItems: [
'ornaments_only',
Validators.required
],
goldValuationCertificateRequired: ['YES'],
goldMaxValuationPerAppraiser: [
'',
Validators.min(1)
],
goldPacketPrefix: [
'GL/',
Validators.maxLength(20)
],
goldRevalueOnNpa: [true],
goldRevaluationChargeApplicable: [true],
insuranceRequired: ['YES'],
goldAuctionNoticeDays: [
15,
[
Validators.required,
Validators.min(0)
]
],
goldPartialReleaseAllowed: [true],
goldThirdPartyAllowed: [false],
goldDisbursementMode: [
'account_credit_cash_limit',
Validators.required
],
goldCashDisbursementLimit: [
this.goldCashLimitMax,
[
Validators.min(0),
Validators.max(this.goldCashLimitMax)
]
],
goldLtvBreachAlert: [
80,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
goldAuctionReservePct: [
90,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
// ---- Loan against Warehouse Receipt (hidden and not sent for any other type) ----
wrMaxLtv: [
75,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
wrReceiptType: [
'e_nwr',
Validators.required
],
wrTenureWithinReceiptValidity: [true],
wrPledgeMarkedBeforeDisbursement: [true],
wrCommodityGroups: [
[
'cereals',
'pulses',
'oilseeds'
],
Validators.required
],
wrValuationPriceSource: [
'mandi_apmc',
Validators.required
],
wrMaxLoanPerQuintal: [
'',
Validators.min(1)
],
wrStockInspectionMonths: [
3,
[
Validators.required,
Validators.min(1)
]
],
wrLtvBreachAlert: [
80,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
wrPartialReleaseAllowed: [true],
wrDisposalNoticeDays: [
15,
[
Validators.required,
Validators.min(0)
]
],
wrDisposalReservePct: [
90,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES']
});
},
{ validators: (group: AbstractControl) => this.pledgeRules(group) }
);
}
/**
* Pledged-security rules (Gold, Warehouse): the LTV the margin gives must fit under the product's LTV cap, the breach
* alert must not sit below the cap, and a gold net-weight range must run low to high.
*/
private pledgeRules(group: AbstractControl): Record<string, boolean> | null {
const gold = this.isGold;
if (!gold && !this.isWarehouse) {
return null;
}
const errors: Record<string, boolean> = {};
const margin = Number(group.get('margin')?.value);
const cap = Number(group.get(gold ? 'goldMaxLtv' : 'wrMaxLtv')?.value);
if (!isNaN(margin) && !isNaN(cap) && 100 - margin > cap) {
errors['ltvAboveCap'] = true;
}
const alert = Number(group.get(gold ? 'goldLtvBreachAlert' : 'wrLtvBreachAlert')?.value);
if (!isNaN(alert) && !isNaN(cap) && alert < cap) {
errors['alertBelowCap'] = true;
}
if (gold) {
const min = group.get('goldMinNetWeight')?.value;
const max = group.get('goldMaxNetWeightPerCustomer')?.value;
if (min !== '' && max !== '' && min !== null && max !== null && Number(min) > Number(max)) {
errors['netWeightRange'] = true;
}
}
return Object.keys(errors).length ? errors : null;
}
/** Flat values for the payload. Not wired into submit yet — see the class comment. */
@@ -190,7 +457,45 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
},
lienCertificateRequired: value.lienCertificateRequired,
lienCertificatePrefix: value.lienCertificatePrefix,
printOnDisbursement: value.printOnDisbursement
printOnDisbursement: value.printOnDisbursement,
gold: {
maxLtv: value.goldMaxLtv,
minPurityCarat: value.goldMinPurityCarat,
rateSource: value.goldRateSource,
maxPerGram: value.goldMaxPerGram,
minNetWeight: value.goldMinNetWeight,
maxNetWeightPerCustomer: value.goldMaxNetWeightPerCustomer,
allowedItems: value.goldAllowedItems,
valuationCertificateRequired: value.goldValuationCertificateRequired,
maxValuationPerAppraiser: value.goldMaxValuationPerAppraiser,
packetPrefix: value.goldPacketPrefix,
revalueOnNpa: value.goldRevalueOnNpa,
revaluationChargeApplicable: value.goldRevaluationChargeApplicable,
insuranceRequired: value.insuranceRequired,
auctionNoticeDays: value.goldAuctionNoticeDays,
partialReleaseAllowed: value.goldPartialReleaseAllowed,
thirdPartyAllowed: value.goldThirdPartyAllowed,
disbursementMode: value.goldDisbursementMode,
cashDisbursementLimit: value.goldCashDisbursementLimit,
ltvBreachAlert: value.goldLtvBreachAlert,
auctionReservePct: value.goldAuctionReservePct,
redeemBeforeAuction: true
},
warehouse: {
maxLtv: value.wrMaxLtv,
receiptType: value.wrReceiptType,
tenureWithinReceiptValidity: value.wrTenureWithinReceiptValidity,
pledgeMarkedBeforeDisbursement: value.wrPledgeMarkedBeforeDisbursement,
commodityGroups: value.wrCommodityGroups,
valuationPriceSource: value.wrValuationPriceSource,
maxLoanPerQuintal: value.wrMaxLoanPerQuintal,
stockInspectionMonths: value.wrStockInspectionMonths,
insuranceRequired: value.insuranceRequired,
ltvBreachAlert: value.wrLtvBreachAlert,
partialReleaseAllowed: value.wrPartialReleaseAllowed,
disposalNoticeDays: value.wrDisposalNoticeDays,
disposalReservePct: value.wrDisposalReservePct
}
};
}
}
@@ -45,6 +45,11 @@ export interface RegisterRateSelection {
const PARTICULAR_BY_SUB_TYPE: Record<string, string> = {
fd: 'against fdr',
rd: 'against rd',
dds: 'against dds',
lic: 'against lic',
warehouse: 'warehouse',
bills: 'bills purchased',
education: 'education loan',
gold: 'gold loan',
cc: 'cash credit',
overdraft: 'overdraft',
@@ -81,9 +81,33 @@
placeholder="Search by name or short name…"
/>
</div>
<div class="pl-filters">
<label class="pl-filter">
<span>Category</span>
<select [(ngModel)]="categoryFilter" (ngModelChange)="onCategoryChange()">
<option value="">All categories</option>
@for (cat of categories; track cat.id) {
<option [value]="cat.id">{{ cat.label }}</option>
}
</select>
</label>
<label class="pl-filter">
<span>Product type</span>
<select [(ngModel)]="typeFilter" (ngModelChange)="refreshFilter()">
<option value="">All types</option>
@for (type of typeOptions; track type.id) {
<option [value]="type.id">{{ type.label }}</option>
}
</select>
</label>
<label class="pl-filter pl-filter--check">
<input type="checkbox" [(ngModel)]="activeOnly" (ngModelChange)="refreshFilter()" />
<span>Active only</span>
</label>
</div>
</div>
@if (totalCount === 0) {
@if (totalCount === 0 && pendingRows.length === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<h3>No loan products yet</h3>
@@ -123,6 +147,16 @@
</td>
</ng-container>
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>Category</th>
<td mat-cell *matCellDef="let p">{{ categoryOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="productType">
<th mat-header-cell *matHeaderCellDef>Product Type</th>
<td mat-cell *matCellDef="let p">{{ typeOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="closeDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Expiry Date' | translate }}</th>
<td mat-cell *matCellDef="let p">
@@ -137,48 +171,100 @@
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (p.pending) {
<span
class="pl-status-pill pl-status-pill--pending"
matTooltip="Requested by {{ p.madeBy }}; visible as a product once a checker approves"
>
<fa-icon icon="hourglass-half"></fa-icon> Pending approval
</span>
} @else {
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (pendingUpdates.get(p.id); as update) {
<button
type="button"
class="pl-status-pill pl-status-pill--pending"
style="margin-left: 6px; cursor: pointer"
matTooltip="An edit by {{ update.maker }} is waiting for a checker. Click to see what changes."
(click)="$event.stopPropagation(); viewUpdate(p)"
>
<fa-icon icon="hourglass-half"></fa-icon> Update pending: view changes
</button>
}
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
@if (p.pending) {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
(click)="$event.stopPropagation(); viewPending(p)"
matTooltip="View the submitted details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
disabled
(click)="$event.stopPropagation()"
matTooltip="Locked until the request is approved or rejected"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
} @else {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[disabled]="pendingUpdates.has(p.id)"
(click)="$event.stopPropagation()"
[matTooltip]="
pendingUpdates.has(p.id)
? 'Locked until the pending edit is approved or rejected'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="row.pending ? null : [row.id]"
(click)="row.pending && viewPending(row)"
[class.pl-row--pending]="row.pending"
class="pl-row"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
@@ -61,3 +61,18 @@
background: #ede9fe;
color: #5b21b6;
}
.pl-status-pill--pending {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.pl-row--pending {
background: #fffbeb;
}
.pl-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@@ -8,6 +8,7 @@
/** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatDialog } from '@angular/material/dialog';
@@ -27,8 +28,8 @@ import {
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** rxjs Imports */
import { switchMap, catchError, finalize } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { switchMap, catchError, finalize, map, tap } from 'rxjs/operators';
import { EMPTY, forkJoin, of } from 'rxjs';
/* Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
@@ -42,6 +43,9 @@ import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductEditDiffDialogComponent } from 'app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component';
import { withExtensions } from './loan-product-original.util';
import { PendingProductViewDialogComponent } from './pending-product-view-dialog/pending-product-view-dialog.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types';
@Component({
@@ -50,6 +54,7 @@ import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTy
styleUrls: ['./loan-products.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTable,
MatSort,
@@ -80,6 +85,10 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
private errorHandler = inject(ErrorHandlerService);
loanProductsData: any[] = [];
/** Create requests a checker has not approved yet; shown in the table with a Pending Approval status. */
pendingRows: any[] = [];
/** Saved products that have an edit waiting for a checker, by product id (value: the pending request). */
pendingUpdates = new Map<number, any>();
displayedColumns: string[] = [
'name',
'shortName',
@@ -130,8 +139,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
return this.totalCount - this.activeCount;
}
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
/* Reference of create loan product button */
@ViewChild('buttonCreateLoanProduct') buttonCreateLoanProduct: ElementRef<any>;
@@ -160,7 +169,75 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
this.dataSource.sort = this.sort;
this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product);
this.filteredCount = this.totalCount;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
this.dataSource.connect().subscribe(() => (this.filteredCount = this.dataSource.filteredData.length));
this.loadPendingRequests();
}
/** Pending loan product requests become table rows, so a submitted product is visible straight away. */
private loadPendingRequests(): void {
this.productsService
.getPendingMakerCheckerByEntity('LOANPRODUCT')
.pipe(
catchError(() => of([])),
tap((entries: any[]) => {
this.pendingUpdates = new Map(
(entries ?? [])
.filter((entry) => (entry.actionName ?? '').toUpperCase() === 'UPDATE' && entry.resourceId != null)
.map(
(entry) => [
Number(entry.resourceId),
entry
] as [number, any]
)
);
}),
switchMap((entries: any[]) =>
(entries ?? []).filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE').length
? forkJoin(
entries
.filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE')
.map((entry) =>
this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(
catchError(() => of(null)),
map((audit) => this.pendingRow(entry, audit))
)
)
)
: of([])
)
)
.subscribe((rows: any[]) => {
this.pendingRows = rows;
this.dataSource.data = [
...rows,
...this.loanProductsData
];
});
}
private pendingRow(entry: any, audit: any): any {
let payload: any = {};
try {
payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {};
} catch {
payload = {};
}
return {
id: null,
pending: true,
pendingRequestId: entry.id,
name: payload.name ?? 'New loan product',
shortName: payload.shortName ?? '—',
currency: payload.currencyCode ? { code: payload.currencyCode } : null,
closeDate: null,
status: 'pending',
madeBy: entry.maker,
payload,
advanceExtensions: {
productTypeGroup: payload.productTypeGroup,
productTypeSubGroup: payload.productTypeSubGroup
}
};
}
applyFilter(filterValue: string) {
@@ -210,6 +287,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
* To show popover.
*/
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
if (this.configurationWizardService.showLoanProductsPage) {
setTimeout(() => {
this.showPopover(
@@ -267,6 +346,34 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
/**
* Opens the import loan product dialog.
*/
/** What a pending edit would change: the saved product and the submitted edit, side by side. */
viewUpdate(row: any): void {
const request = this.pendingUpdates.get(Number(row.id));
if (!request) {
return;
}
forkJoin({
audit: this.productsService.getLoanRateRegisterAuditDetail(request.id),
product: this.productsService.getLoanProduct(row.id, true)
}).subscribe(({ audit, product }: any) => {
let proposed: any = {};
try {
proposed = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {};
} catch {
proposed = {};
}
this.dialog.open(ProductEditDiffDialogComponent, {
width: '820px',
data: { productName: row.name, original: withExtensions(product), proposed }
});
});
}
/** A pending product has no page yet, so its submitted values open in a read-only dialog. */
viewPending(row: any): void {
this.dialog.open(PendingProductViewDialogComponent, { width: '40rem', data: row });
}
openImportDialog(): void {
const importDialogRef = this.dialog.open(ImportLoanProductDialogComponent, {
width: '50rem'
@@ -318,7 +425,10 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
.subscribe({
next: (data: any) => {
this.loanProductsData = data;
this.dataSource.data = this.loanProductsData;
this.dataSource.data = [
...this.pendingRows,
...this.loanProductsData
];
this.errorHandler.showSuccess('Loan product imported successfully!');
},
error: () => {
@@ -0,0 +1,39 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ data.name }}</h1>
<mat-dialog-content>
<div class="ppv__banner">
<i class="fas fa-hourglass-half"></i>
<span>
Awaiting checker approval. Requested by <strong>{{ data.madeBy }}</strong
>. This product is not active yet and cannot be edited until the request is approved or rejected.
</span>
</div>
<div class="ppv__chips">
<span class="ppv__chip">{{ category }}</span>
<span class="ppv__chip">{{ type }}</span>
<span class="ppv__chip">Short name: {{ data.shortName }}</span>
</div>
<table class="ppv__table">
<tbody>
@for (field of fields; track field.label) {
<tr>
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
}
</tbody>
</table>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-dialog-close class="ppv__close">Close</button>
</mat-dialog-actions>
@@ -0,0 +1,74 @@
/**
* 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/.
*/
.ppv__banner {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px;
margin-bottom: 12px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
i {
margin-top: 2px;
color: #d97706;
}
}
.ppv__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.ppv__chip {
padding: 3px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 12px;
font-weight: 600;
}
.ppv__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 6px 8px;
border-bottom: 1px solid #f1f5f9;
text-align: left;
vertical-align: top;
}
th {
width: 42%;
font-weight: 500;
color: #64748b;
}
td {
color: #1e293b;
word-break: break-word;
}
}
.ppv__close {
padding: 6px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
@@ -0,0 +1,78 @@
/**
* 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 { Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { productCategoryLabel, productTypeLabel } from '../loan-product-types';
/** Submitted values that carry no information for a reader. */
const HIDDEN_KEYS = new Set([
'locale',
'dateFormat',
'charges'
]);
/**
* Read-only view of a loan product request that is still awaiting a checker. The product does not exist yet, so the
* page is built from what the maker submitted (the request payload).
*/
@Component({
selector: 'mifosx-pending-product-view-dialog',
templateUrl: './pending-product-view-dialog.component.html',
styleUrls: ['./pending-product-view-dialog.component.scss'],
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class PendingProductViewDialogComponent {
/** The pending table row: name, shortName, madeBy and the submitted payload. */
data = inject<any>(MAT_DIALOG_DATA);
get category(): string {
return productCategoryLabel(this.data?.payload?.productTypeGroup);
}
get type(): string {
return productTypeLabel(this.data?.payload?.productTypeGroup, this.data?.payload?.productTypeSubGroup);
}
/** Every submitted field as label / value, hiding empty values and technical keys. */
get fields(): { label: string; value: string }[] {
const payload = this.data?.payload ?? {};
return Object.keys(payload)
.filter((key) => !HIDDEN_KEYS.has(key))
.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== '')
.map((key) => ({ label: this.label(key), value: this.format(payload[key]) }));
}
private label(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
private format(value: any): string {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
return value.length
? value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ')
: '—';
}
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
}
@@ -48,6 +48,14 @@
</div>
</div>
@if (advanceReview) {
<mifosx-loan-product-preview-step
[loanProduct]="advanceReview.product"
[loanProductsTemplate]="advanceReview.template"
[readOnly]="true"
></mifosx-loan-product-preview-step>
}
<!-- Summary content (rendered by shared component) -->
<div class="pv-legacy">
<mifosx-loan-product-summary
@@ -8,6 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { LoanProduct } from '../../models/loan-product.model';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
@@ -19,6 +20,8 @@ import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductPreviewStepComponent } from '../../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
import { reviewProductOf } from '../../loan-product-original.util';
@Component({
selector: 'mifosx-general-tab',
@@ -27,7 +30,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
LoanProductSummaryComponent
LoanProductSummaryComponent,
LoanProductPreviewStepComponent
]
})
export class GeneralTabComponent implements OnInit {
@@ -37,8 +41,11 @@ export class GeneralTabComponent implements OnInit {
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private translateService = inject(TranslateService);
private http = inject(HttpClient);
loanProduct: LoanProduct;
/** An Advances product: its own settings live in the extension record the standard summary does not read. */
advanceReview: { product: any; template: any } | null = null;
constructor() {
this.route.data.subscribe((data: { loanProduct: any }) => {
@@ -47,11 +54,45 @@ export class GeneralTabComponent implements OnInit {
}
ngOnInit() {
const savedGroup = (this.loanProduct as any)?.advanceExtensions?.productTypeGroup;
if (savedGroup === 'advances' || savedGroup === 'loans') {
this.advanceReview = reviewProductOf(this.loanProduct);
this.nameSuspenseGl();
}
this.loanProduct.allowAttributeConfiguration = Object.values(this.loanProduct.allowAttributeOverrides).some(
(attribute: boolean) => attribute
);
}
/** The interest suspense GL lives on the extension record, not in the accounting mappings: look its name up. */
private nameSuspenseGl(): void {
const id = this.advanceReview?.product?.undebitedInterestGlId;
if (id === undefined || id === null) {
return;
}
this.http.get(`/glaccounts/${id}`).subscribe({
next: (account: any) => {
const current = this.advanceReview;
const options = current.template.accountingMappingOptions;
// A new object, so the Review reader rebuilds with the name.
this.advanceReview = {
product: { ...current.product },
template: {
accountingMappingOptions: {
...options,
assetAccountOptions: [
...options.assetAccountOptions,
{ id: account.id, glCode: account.glCode, name: account.name }]
}
}
};
},
error: () => {
/* the id stays as the fallback text */
}
});
}
exportDefinition(): void {
const product = this.loanProduct;
delete product['id'];
@@ -48,6 +48,17 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" required formControlName="maximumAgeDays" min="1" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -61,15 +61,18 @@ export class CreateRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*)$'),
Validators.max(10000)
]
]
],
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: ['']
});
}
submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code;
const data = {
const data: any = {
...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale
};
this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
@@ -47,6 +47,17 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" formControlName="maximumAgeDays" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -68,15 +68,18 @@ export class EditRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*?)$'),
Validators.max(10000)
]
]
],
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: [this.delinquencyRangeData.stage ?? '']
});
}
submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code;
const data = {
const data: any = {
...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale
};
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
@@ -52,6 +52,25 @@
<span class="vr-label">{{ 'labels.inputs.Days Till' | translate }}</span>
<span class="vr-value">{{ delinquencyRangeData.maximumAgeDays }}</span>
</div>
<div class="vr-row">
<span class="vr-label">Stage (RBI 2026 ECL)</span>
<span class="vr-value">
@switch (delinquencyRangeData.stage) {
@case (1) {
Stage 1 — Standard / performing
}
@case (2) {
Stage 2 — SICR / underperforming
}
@case (3) {
Stage 3 — NPA / credit-impaired
}
@default {
— None (informational only) —
}
}
</span>
</div>
</div>
</div>
</div>
@@ -34,5 +34,16 @@
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
<div class="mdb-card" [routerLink]="['floor-master']" *mifosxHasPermission="'READ_PRUDENTIAL_FLOOR'">
<div class="mdb-card__icon-wrap">
<i class="fas fa-chart-line"></i>
</div>
<div class="mdb-card__content">
<h3>Prudential Floor Master</h3>
<p>RBI 2026 ECL stage-wise provisioning floor, by product category</p>
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
</div>
</div>
@@ -0,0 +1,64 @@
<!--
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="container">
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
<i class="fas fa-chart-line title-icon"></i>
Prudential Floor Master
</h2>
<p class="pf-subtitle">
RBI 2026 ECL framework (para 82) — regulatory minimum provisioning floor by product category and stage. Seeded
from the RBI Directions; read-only.
</p>
</div>
<div class="table-actions">
<div class="table-search">
<mat-form-field appearance="outline">
<i matPrefix class="fas fa-search"></i>
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
</div>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<ng-container matColumnDef="productCategory">
<th mat-header-cell *matHeaderCellDef mat-sort-header="productCategory">Product Category</th>
<td mat-cell *matCellDef="let row">{{ row.productCategory }}</td>
</ng-container>
<ng-container matColumnDef="stage">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stage">Stage</th>
<td mat-cell *matCellDef="let row">{{ stageLabels[row.stage] ?? row.stage }}</td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef>Duration in Stage 3</th>
<td mat-cell *matCellDef="let row">{{ durationLabel(row) }}</td>
</ng-container>
<ng-container matColumnDef="securedFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="securedFloorPct">Secured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.securedFloorPct }}</td>
</ng-container>
<ng-container matColumnDef="unsecuredFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="unsecuredFloorPct">Unsecured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.unsecuredFloorPct }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,32 @@
/**
* 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/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { ProductsService } from '../../products.service';
/**
* Prudential Floor Master data resolver (RBI 2026 ECL framework, para 82). Read-only.
*/
@Injectable()
export class PrudentialFloorComponentsResolver {
private productsService = inject(ProductsService);
/**
* Returns the prudential floor master rows.
* @returns {Observable<any>}
*/
resolve(): Observable<any> {
return this.productsService.getPrudentialFloors();
}
}
@@ -0,0 +1,19 @@
/**
* 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/.
*/
@use '../../../shared/styles/list-layout' as *;
table {
width: 100%;
}
.pf-subtitle {
margin: 4px 0 0;
color: rgb(0 0 0 / 60%);
font-size: 0.875rem;
}
@@ -0,0 +1,118 @@
/**
* 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 { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* RBI 2026 ECL framework: stage-wise prudential floor master (para 82), by loan product category.
* Read-only — the master is seeded from the RBI Directions, not created per bank, so there is no
* create/edit screen here (a Board-approved "tighten only" override is a follow-up, see backend
* PrudentialFloorsApiResource).
*/
@Component({
selector: 'mifosx-prudential-floor',
templateUrl: './prudential-floor.component.html',
styleUrls: ['./prudential-floor.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator
]
})
export class PrudentialFloorComponent implements OnInit {
private route = inject(ActivatedRoute);
prudentialFloorData: any[];
/** Columns to be displayed in the prudential floor table. */
displayedColumns: string[] = [
'productCategory',
'stage',
'duration',
'securedFloorPct',
'unsecuredFloorPct'
];
/** Data source for the prudential floor table. */
dataSource: MatTableDataSource<any>;
/** Paginator for the prudential floor table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for the prudential floor table. */
@ViewChild(MatSort, { static: true }) sort: MatSort;
readonly stageLabels: Record<number, string> = {
1: 'Stage 1',
2: 'Stage 2',
3: 'Stage 3'
};
constructor() {
this.route.data.subscribe((data: { prudentialFloors: any[] }) => {
this.prudentialFloorData = data.prudentialFloors;
});
}
ngOnInit(): void {
this.setDatasource();
}
/**
* Filters data in the prudential floor table based on passed value.
* @param {string} filterValue Value to filter data.
*/
applyFilter(filterValue: string) {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** "0–365 days", "1461+ days" (open-ended) or "—" (Stage 1/2: not duration-banded). */
durationLabel(row: any): string {
if (row.durationFromDays == null) {
return '—';
}
if (row.durationToDays == null) {
return `${row.durationFromDays}+ days`;
}
return `${row.durationFromDays}–${row.durationToDays} days`;
}
/**
* Initializes the data source, paginator and sorter for the prudential floor table.
*/
setDatasource() {
this.dataSource = new MatTableDataSource(this.prudentialFloorData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
}
@@ -120,6 +120,8 @@ import { DelinquencyBucketComponent } from './manage-delinquency-buckets/delinqu
import { DelinquencyRangeComponent } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component';
import { DelinquencyBucketComponentsResolver } from './manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.resolver';
import { DelinquencyRangeComponentsResolver } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component.resolver';
import { PrudentialFloorComponent } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component';
import { PrudentialFloorComponentsResolver } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver';
import { ViewRangeComponent } from './manage-delinquency-buckets/delinquency-range/view-range/view-range.component';
import { EditRangeComponent } from './manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component';
import { CreateRangeComponent } from './manage-delinquency-buckets/delinquency-range/create-range/create-range.component';
@@ -224,6 +226,15 @@ const routes: Routes = [
},
{
path: 'edit',
component: CreateLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
loanProductAndTemplate: LoanProductAndTemplateResolver,
configurations: GlobalConfigurationsResolver
}
},
{
path: 'edit-legacy',
component: EditLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
@@ -663,6 +674,14 @@ const routes: Routes = [
]
}
]
},
{
path: 'floor-master',
component: PrudentialFloorComponent,
data: { title: 'Prudential Floor Master', breadcrumb: 'Prudential Floor Master' },
resolve: {
prudentialFloors: PrudentialFloorComponentsResolver
}
}
]
},
@@ -1104,6 +1123,7 @@ const routes: Routes = [
CollateralTemplateResolver,
DelinquencyRangeComponentsResolver,
DelinquencyBucketComponentsResolver,
PrudentialFloorComponentsResolver,
CurrentProductsResolver,
CurrentProductsTemplateResolver,
CurrentProductResolver,
+41 -2
View File
@@ -12,7 +12,8 @@ import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable } from 'rxjs';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
@@ -583,6 +584,17 @@ export class ProductsService {
return this.http.put(`/taxes/group/${taxGroupId}`, taxGroup);
}
/**
* RBI 2026 ECL framework: stage-wise prudential floor master, by product category. Read-only — seeded
* from the RBI Directions (para 82) rather than created per bank.
* @param {string} [productCategory] Optional filter.
* @returns {Observable<any>} Prudential Floor rows
*/
getPrudentialFloors(productCategory?: string): Observable<any> {
const params = productCategory ? { productCategory } : {};
return this.http.get('/prudentialfloors', { params });
}
/**
* @returns {Observable<any>} Delinquency Range data
*/
@@ -867,9 +879,36 @@ export class ProductsService {
}
/** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */
/**
* Pending requests for an entity: everything the user may approve (checker inbox) plus the requests the user
* submitted. The inbox alone hides a maker's own requests unless the maker also holds checker permission.
* Each entry carries canApprove, true only for those the user can actually approve.
*/
getPendingMakerCheckerByEntity(entityName: string): Observable<any> {
const params = new HttpParams().set('entityName', entityName);
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
const context = new HttpContext().set(SILENT_HTTP_ERROR, true);
const toApprove$ = this.http
.get<any[]>('/makercheckers', { params, context })
.pipe(catchError(() => of([] as any[])));
const mine$ = this.http
.get<any[]>('/makercheckers/mine', { params, context })
.pipe(catchError(() => of([] as any[])));
return forkJoin([
toApprove$,
mine$
]).pipe(
map(
([
toApprove,
mine
]) => {
const byId = new Map<number, any>();
(mine ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: false }));
(toApprove ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: true }));
return [...byId.values()];
}
)
);
}
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
@@ -63,9 +63,90 @@ export class ProductEditDiffDialogComponent implements OnInit {
'id',
'productId',
'savingsProductId',
'otherGlMappings'
'otherGlMappings',
// Loan / advance engine defaults and the locked type: not settings a reviewer decides on.
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'accountingRule',
'productTypeGroup',
'productTypeSubGroup'
]);
/** Plain names for the loan / advance fields that otherwise read as raw parameter names. */
private static readonly FRIENDLY_LABELS: Record<string, string> = {
minPrincipal: 'Minimum amount / limit',
principal: 'Default amount / limit',
maxPrincipal: 'Maximum amount / limit',
minNumberOfRepayments: 'Minimum tenure (months)',
numberOfRepayments: 'Default tenure (months)',
maxNumberOfRepayments: 'Maximum tenure (months)',
interestRatePerPeriod: 'Default interest rate (%)',
minInterestRatePerPeriod: 'Minimum interest rate (%)',
maxInterestRatePerPeriod: 'Maximum interest rate (%)',
overdueDaysForNPA: 'NPA after (days overdue)',
graceOnArrearsAgeing: 'Arrears grace (days)',
smaStartAfterMonths: 'SMA counting starts after (months)',
penalRate: 'Penal rate (%)',
margin: 'Margin (%)',
ltv: 'LTV (%)',
glAccount: 'Product GL (asset)',
registerParticular: 'Rate register row',
loanPortfolioAccountId: 'Loan portfolio GL',
fundSourceAccountId: 'Fund source GL',
interestOnLoanAccountId: 'Interest income GL',
incomeFromFeeAccountId: 'Fee income GL',
incomeFromPenaltyAccountId: 'Penal income GL',
writeOffAccountId: 'Write-off GL',
overpaymentLiabilityAccountId: 'Overpayment GL',
receivableInterestAccountId: 'Interest receivable GL',
undebitedInterestGlId: 'Interest suspense GL',
goldMaxLtv: 'Gold: maximum LTV cap (%)',
goldMinPurityCarat: 'Gold: minimum purity (carat)',
goldRateSource: 'Gold: valuation rate source',
goldMaxPerGram: 'Gold: max loan per gram (₹)',
goldMinNetWeight: 'Gold: minimum net weight (g)',
goldMaxNetWeightPerCustomer: 'Gold: max net weight per customer (g)',
goldAllowedItems: 'Gold: allowed items',
goldAppraiserRequired: 'Gold: appraiser required',
goldValuationCertificateRequired: 'Gold: valuation certificate required',
goldMaxValuationPerAppraiser: 'Gold: max valuation per appraiser (₹)',
goldPacketPrefix: 'Gold: packet number prefix',
goldRevalueOnNpa: 'Gold: revalue when loan turns NPA',
goldRevaluationChargeApplicable: 'Gold: charge customer for revaluation',
insuranceRequired: 'Insurance required',
goldAuctionNoticeDays: 'Gold: auction notice (days)',
goldPartialReleaseAllowed: 'Gold: partial release of ornaments',
goldThirdPartyAllowed: 'Gold: third-party gold allowed',
goldDisbursementMode: 'Gold: disbursement mode',
goldCashDisbursementLimit: 'Gold: cash disbursement limit (₹)',
goldLtvBreachAlert: 'Gold: LTV breach alert (%)',
goldAuctionReservePct: 'Gold: auction reserve price (%)',
goldRedeemBeforeAuction: 'Gold: customer may redeem before auction',
wrMaxLtv: 'Warehouse: maximum LTV cap (%)',
wrReceiptType: 'Warehouse: receipt type allowed',
wrWdraRegistered: 'Warehouse: WDRA-registered warehouse',
wrAssayCertificateRequired: 'Warehouse: assay certificate required',
wrTenureWithinReceiptValidity: 'Warehouse: tenure within receipt validity',
wrPledgeMarkedBeforeDisbursement: 'Warehouse: pledge marked before disbursement',
wrCommodityGroups: 'Warehouse: eligible commodity groups',
wrValuationPriceSource: 'Warehouse: valuation price source',
wrMaxLoanPerQuintal: 'Warehouse: max loan per quintal (₹)',
wrStockInspectionMonths: 'Warehouse: stock inspection (months)',
wrLtvBreachAlert: 'Warehouse: LTV breach alert (%)',
wrPartialReleaseAllowed: 'Warehouse: partial release of stock',
wrDisposalNoticeDays: 'Warehouse: disposal notice (days)',
wrDisposalReservePct: 'Warehouse: disposal reserve price (%)',
wrRedeemBeforeDisposal: 'Warehouse: customer may redeem before disposal'
};
/** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any
* other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
@@ -84,6 +165,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id',
delinquencyBucketId: 'delinquencyBucket.id',
// Enum fields the stored product exposes as "<name>Type": {id, value}", not "<name>TypeId" --
// without these the "current" side always read as blank, so these showed as changed on every
// edit even when the maker never touched them.
@@ -124,7 +206,13 @@ export class ProductEditDiffDialogComponent implements OnInit {
penaltiesReceivableAccountId: 'penaltyReceivableAccount',
interestPayableAccountId: 'interestPayableAccount',
interestReceivableAccountId: 'interestReceivableAccount',
escheatLiabilityId: 'escheatLiabilityAccount'
escheatLiabilityId: 'escheatLiabilityAccount',
// loan / advance products
fundSourceAccountId: 'fundSourceAccount',
loanPortfolioAccountId: 'loanPortfolioAccount',
interestOnLoanAccountId: 'interestOnLoanAccount',
overpaymentLiabilityAccountId: 'overpaymentLiabilityAccount',
receivableInterestAccountId: 'receivableInterestAccount'
};
private glAccountNamesById = new Map<string, string>();
@@ -143,7 +231,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: humanizeKey(key),
label: ProductEditDiffDialogComponent.FRIENDLY_LABELS[key] ?? humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
+4
View File
@@ -127,6 +127,10 @@ export function normalizeValue(value: any): any {
if (typeof value === 'boolean') {
return value;
}
// A YES / NO select is saved as true / false: the same answer, so compare them as one.
if (typeof value === 'string' && /^(yes|no)$/i.test(value.trim())) {
return value.trim().toLowerCase() === 'yes';
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
+3 -1
View File
@@ -7,6 +7,8 @@
*/
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { withExtensions } from 'app/products/loan-products/loan-product-original.util';
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
@@ -59,7 +61,7 @@ const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Ob
SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true),
CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true),
SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true).pipe(map((product: any) => withExtensions(product))),
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
+41 -43
View File
@@ -45,17 +45,15 @@
"hasMask": false,
"ao": 0,
"ks": { "a": { "a": 0, "k": [
0,
0,
0
-17.052704811096184,
-15.0467209815979
], "ix": 1 }, "s": { "a": 0, "k": [
394,
394,
362.228,
362.228,
100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
250,
250,
0
188.2303284168625,
195.49656352277756
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [
{
@@ -343,16 +341,16 @@
"g": { "p": 3, "k": { "a": 0, "k": [
0,
1,
0.6,
0.2,
0.4470588235294118,
0,
0.485,
0.9725490196078431,
0.2235294117647059,
0.5019607843137255,
1,
0.6,
0.2,
1,
1,
0.6,
0.2
0.9411764705882353,
0,
1
], "ix": 8 } },
"t": 1,
"a": { "a": 0, "k": 0 },
@@ -365,7 +363,7 @@
"lj": 1,
"ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2.5, "ix": 10 }
"w": { "a": 0, "k": 2, "ix": 10 }
},
{
"ty": "fl",
@@ -404,8 +402,8 @@
"o": {
"a": 1,
"k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 },
{ "s": [388], "t": 90.0000036657751 }
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
{ "s": [360], "t": 90.0000036657751 }
],
"ix": 3
},
@@ -439,7 +437,7 @@
250,
250,
0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [
{
"ty": "gr",
@@ -725,16 +723,16 @@
], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [
0,
1,
1,
0,
0.7764705882352941,
1,
0.485,
0.24705882352941178,
0.38823529411764707,
1,
1,
1,
1,
1,
1,
0.49411764705882355,
0,
1
], "ix": 8 } },
"t": 1,
@@ -748,7 +746,7 @@
"lj": 1,
"ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2.5, "ix": 10 }
"w": { "a": 0, "k": 0.5, "ix": 10 }
},
{
"ty": "fl",
@@ -815,14 +813,14 @@
0,
0
], "ix": 1 }, "s": { "a": 0, "k": [
330,
330,
362.228,
362.228,
100
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
250,
250,
0
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
"shapes": [
{
"ty": "gr",
@@ -1108,17 +1106,17 @@
], "ix": 5 },
"g": { "p": 3, "k": { "a": 0, "k": [
0,
0.0784,
0.7059,
0.2118,
0.485,
0.0784,
0.7059,
0.2118,
1,
0.0784,
0.7059,
0.2118
0.8235294117647058,
0,
0.485,
0.5019607843137255,
0.8117647058823529,
0.5019607843137255,
1,
0,
0.8,
1
], "ix": 8 } },
"t": 1,
"a": { "a": 0, "k": 0 },
@@ -1131,7 +1129,7 @@
"lj": 1,
"ml": 4,
"o": { "a": 0, "k": 100, "ix": 9 },
"w": { "a": 0, "k": 2.5, "ix": 10 }
"w": { "a": 0, "k": 0.5, "ix": 10 }
},
{
"ty": "fl",
@@ -1170,8 +1168,8 @@
"o": {
"a": 1,
"k": [
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 },
{ "s": [332], "t": 90.0000036657751 }
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
{ "s": [360], "t": 90.0000036657751 }
],
"ix": 3
},
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '261012',
'hash': '6b0f218b'
'version': '261013',
'hash': 'd88f2a6eb'
},
'allow_switching_backend_instance': true
};
+6 -6
View File
@@ -325,10 +325,10 @@
};
document.head.appendChild(script);
// Progress simulation stages (Guaranteed ~4 seconds duration on refresh: 2% every 90ms reaches 90% in about 4s)
// Progress simulation stages (Guaranteed ~2 seconds duration on refresh)
var interval = setInterval(function () {
if (progress < 90) {
progress += 2;
progress += 5;
} else if (angularReady && progress < 100) {
progress += 5;
}
@@ -351,7 +351,7 @@
}
}, 90);
// Enforce minimum hold time (4000ms = 4 seconds) so refresh always shows the animation
// Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation
setTimeout(function () {
minTimePassed = true;
if (angularReady) {
@@ -359,7 +359,7 @@
if (bar) bar.style.width = '100%';
dismissSplash();
}
}, 4000);
}, 2000);
function dismissSplash() {
if (dismissed) return;
@@ -387,12 +387,12 @@
}
};
// Safety fallback (Max 9s in case of backend network failure)
// Safety fallback (Max 7s in case of backend network failure)
setTimeout(function () {
angularReady = true;
minTimePassed = true;
dismissSplash();
}, 9000);
}, 7000);
})();
</script>