feat(loan-origination): RBI compliance checklist, real client integration, and CIC consent/charge flows
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

- Add standalone RBI Eligibility Checklist and RBI Rules Reference pages (11 real RBI-circular
  based rules: age-at-maturity, area of operation, PSL, director/related-party, wilful defaulter,
  exposure norms, LTV/margin, fair practices code, prepayment charges, own-shares security,
  insurance bundling), wired into the Loan Origination menu.
- Bridge the mock Loan Origination Pipeline to real Fineract data wherever an application is
  raised against a real Existing Customer: client search in the pipeline intake form, real Share
  Member accounts (with a details popup), and real KYC (Aadhaar/PAN verified status + masked
  numbers + document image view, generic document lookup for Income Proof/Bank Statement/
  Electricity Bill) on the Application Detail page.
- Add local-only capture UI for New Customer / no-account applications: front/back document
  images with crop + simulated Aadhaar OTP / PAN verification, employment-type-aware income proof,
  and an expiry-validated electricity bill upload.
- Add an EMI Calculation Basis breakdown (principal, rate, tenure, total interest/payable, and the
  substituted reducing-balance formula) to the KYC stage's income assessment.
- Add a CIC/Credit Bureau Check consent flow: a generated declaration letter (print for Branch,
  share for BC/Self-Apply), gated on the signed copy being uploaded before the credit score can be
  checked, plus a CIC check charge collected either by debiting a real linked account or via a
  mock payment-link flow for customers with none.
- Let the stage stepper jump directly to any already-reached stage (forward or backward) via a new
  furthestStageIndex, without losing any data already recorded on the application.
This commit is contained in:
2026-08-13 10:15:28 +05:30
parent 5a6fb1f9b5
commit 75df8865dd
22 changed files with 3688 additions and 48 deletions
@@ -0,0 +1,184 @@
/**
* 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 rule computed automatically from data already entered in the wizard (age, LTV). */
export type RbiRuleCheckType = 'AUTO' | 'MANUAL';
/** HARD rules are legal prohibitions with no override path; SOFT rules require a written
* override reason to proceed past a failure. */
export type RbiRuleBlockLevel = 'HARD' | 'SOFT';
export interface RbiComplianceRule {
code: string;
title: string;
rbiReference: string;
statement: string;
checkType: RbiRuleCheckType;
blockLevel: RbiRuleBlockLevel;
}
/**
* RBI-circular-based loan origination checklist. Age is the only rule computed from data this
* app already tracks (client date of birth) -- area of operation, PSL classification,
* director/related-party status, and wilful-defaulter/credit-bureau status have no backing data
* anywhere in this system yet, so those are officer manual attestations rather than automated
* checks. See CBS_UPDATE plan notes for the underlying RBI source for each rule.
*/
export const RBI_COMPLIANCE_RULES: RbiComplianceRule[] = [
{
code: 'AGE_AT_MATURITY',
title: 'Age at Loan Maturity',
rbiReference:
'Bank credit policy (standard practice, formalised for pension loans under RBI Master Circular on Pension Loans)',
statement: "Borrower's age at loan maturity must not exceed the bank's policy threshold.",
checkType: 'AUTO',
blockLevel: 'SOFT'
},
{
code: 'AREA_OF_OPERATION',
title: 'Area of Operation / Local Membership',
rbiReference:
"RBI Licensing Framework for UCBs -- a bank's area of operation is confined to its registered district(s)",
statement:
"I confirm the borrower's registered/communication address falls within this bank's declared area of operation.",
checkType: 'MANUAL',
blockLevel: 'SOFT'
},
{
code: 'PSL_CLASSIFICATION',
title: 'Priority Sector Lending (PSL) Classification',
rbiReference: 'RBI Master Directions -- Priority Sector Lending (Targets and Classification), 2025',
statement:
"Select this loan's PSL category, if any. Portfolio-level RBI targets: Agriculture 18% of ANBC (10% sub-target for small/marginal farmers within it), Weaker Sections 12%, and a UCB-specific Small Value Loan target of 50% of aggregate loans by 31 Mar 2026 (40% interim by 31 Mar 2025).",
checkType: 'MANUAL',
blockLevel: 'SOFT'
},
{
code: 'DIRECTOR_RELATED_PARTY',
title: 'Lending to Directors / Related Parties',
rbiReference: 'Section 20, Banking Regulation Act 1949 + RBI (UCBs -- Lending to Related Parties) Directions',
statement:
'I confirm the borrower and any guarantor are NOT a director, senior officer, or relative thereof of this bank -- or, if they are, that this loan falls within a board-approved permitted exception.',
checkType: 'MANUAL',
blockLevel: 'HARD'
},
{
code: 'WILFUL_DEFAULTER',
title: 'Wilful Defaulter / Credit Information Check',
rbiReference: 'RBI Master Direction on Treatment of Wilful Defaulters and Large Defaulters',
statement:
'I confirm a credit bureau (CIBIL) check has been run and the applicant is NOT listed as a wilful defaulter.',
checkType: 'MANUAL',
blockLevel: 'HARD'
},
{
code: 'EXPOSURE_NORMS',
title: 'Single / Group Borrower Exposure Limit',
rbiReference: 'RBI Master Circular -- Exposure Norms and Statutory/Other Restrictions for UCBs',
statement:
"This borrower's total exposure (existing + this loan) must not exceed 15% of the bank's Tier-I capital (25% for a borrower group).",
checkType: 'AUTO',
blockLevel: 'SOFT'
},
{
code: 'LTV_MARGIN',
title: 'Loan-to-Value / Margin Requirement',
rbiReference:
'RBI LTV norms -- Gold Loans (75-85% tiered by loan size) and Housing Loans (80-90% tiered by property value)',
statement:
'For a gold or housing loan, the loan amount must stay within the RBI-prescribed LTV cap for the collateral value entered.',
checkType: 'AUTO',
blockLevel: 'SOFT'
},
{
code: 'FAIR_PRACTICES_CODE',
title: 'Fair Practices Code / Key Facts Statement',
rbiReference: 'RBI Fair Practices Code for Lenders + Key Facts Statement (KFS) circular, 2024',
statement:
'I confirm the borrower has been, or will be, given the Key Facts Statement disclosing interest rate, fees and charges before sanction.',
checkType: 'MANUAL',
blockLevel: 'SOFT'
},
{
code: 'PREPAYMENT_CHARGES',
title: 'No Prepayment / Foreclosure Charges (Floating Rate)',
rbiReference:
'RBI (Pre-payment Charges on Loans) Directions, 2025 -- applies to loans sanctioned/renewed on or after 1 Jan 2026',
statement:
'I confirm that if this is a floating-rate loan to an individual or MSE, no prepayment or foreclosure charge is included in its terms for full or part prepayment, regardless of source of funds or co-obligants -- no exception is permitted.',
checkType: 'MANUAL',
blockLevel: 'HARD'
},
{
code: 'OWN_SHARES_SECURITY',
title: "Loan Against the Bank's Own Shares",
rbiReference: 'Section 20(1), Banking Regulation Act 1949',
statement:
"I confirm this loan's collateral does NOT include shares of this bank itself, or of its subsidiary -- absolutely prohibited, no exception is permitted.",
checkType: 'MANUAL',
blockLevel: 'HARD'
},
{
code: 'INSURANCE_BUNDLING',
title: 'No Compulsory Insurance / Third-Party Product Bundling',
rbiReference: 'RBI (Commercial Banks -- Responsible Business Conduct) Second Amendment Directions, 2026',
statement:
'I confirm the borrower has not been required to buy insurance or any third-party financial product from a specific tied provider as a condition for this loan -- any credit-linked cover the bank requires must let the borrower choose their own provider.',
checkType: 'MANUAL',
blockLevel: 'SOFT'
}
];
/** Reuses the same PSL category taxonomy already shown in the bank's PSL achievement dashboard
* (see psl-report-dialog.component.ts) so the two screens speak the same vocabulary. */
export const PSL_CATEGORY_OPTIONS: { code: string; name: string }[] = [
{ code: 'NOT_APPLICABLE', name: 'Not Applicable' },
{ code: 'AGRI', name: 'Agriculture' },
{ code: 'AGRI-SMF', name: 'Agriculture -- Small & Marginal Farmers' },
{ code: 'MSME-MICRO', name: 'MSME -- Micro Enterprises' },
{ code: 'MSME-SMALL', name: 'MSME -- Small Enterprises' },
{ code: 'MSME-MED', name: 'MSME -- Medium Enterprises' },
{ code: 'EDU', name: 'Education' },
{ code: 'HOUSE', name: 'Housing' },
{ code: 'SOC-INFRA', name: 'Social Infrastructure' },
{ code: 'REN-ENERGY', name: 'Renewable Energy' },
{ code: 'EXPORT', name: 'Export Credit' },
{ code: 'WEAKER', name: 'Weaker Sections' }
];
/** Collateral type drives which RBI LTV/margin cap table applies. */
export const COLLATERAL_TYPE_OPTIONS: { code: string; name: string }[] = [
{ code: 'NONE', name: 'Not Applicable / Unsecured' },
{ code: 'GOLD', name: 'Gold Loan' },
{ code: 'HOUSING', name: 'Housing Loan' },
{ code: 'SHARES_PHYSICAL', name: 'Shares / Debentures (Physical Form)' },
{ code: 'SHARES_DEMAT', name: 'Shares / Debentures (Demat Form)' }
];
/** Gold Loan LTV cap, tiered by loan amount (RBI circular, effective April 2026). */
export function goldLoanLtvCapPct(loanAmount: number): number {
if (loanAmount <= 250000) {
return 85;
}
if (loanAmount <= 500000) {
return 80;
}
return 75;
}
/** Housing Loan LTV cap, tiered by property (collateral) value. */
export function housingLoanLtvCapPct(collateralValue: number): number {
return collateralValue < 3000000 ? 90 : 80;
}
/** Max LTV against shares/debentures is the inverse of RBI's minimum margin -- 50% margin on
* physical-form securities caps the loan at 50% of market value; 25% margin on demat-form
* securities caps it at 75%. */
export function sharesLoanLtvCapPct(isDemat: boolean): number {
return isDemat ? 75 : 50;
}
@@ -0,0 +1,309 @@
<!--
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 (rbiComplianceForm) {
<div
class="rbi-summary"
[ngClass]="
isBlocked ? 'rbi-summary--blocked' : overrideCount > 0 ? 'rbi-summary--override' : 'rbi-summary--cleared'
"
>
@if (isBlocked) {
<span>Blocked -- resolve the legal-prohibition item(s) below to continue.</span>
} @else if (overrideCount > 0) {
<span>Cleared -- {{ overrideCount }} override reason(s) recorded below.</span>
} @else {
<span>Cleared -- all RBI checks satisfied.</span>
}
</div>
<form [formGroup]="rbiComplianceForm" class="rbi-form">
<!-- Age at Loan Maturity -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('AGE_AT_MATURITY').title }}</span>
@if (ageAtMaturity != null) {
<span class="rbi-badge" [ngClass]="ageCheckFailed ? 'rbi-badge--fail' : 'rbi-badge--pass'">
{{ ageCheckFailed ? 'Exceeds Policy' : 'Within Policy' }}
</span>
}
</div>
<p class="rbi-rule-reference">{{ ruleFor('AGE_AT_MATURITY').rbiReference }}</p>
<p class="rbi-rule-statement">{{ ruleFor('AGE_AT_MATURITY').statement }}</p>
<div class="rbi-inline-fields">
<mat-form-field>
<mat-label>Loan Tenure (months)</mat-label>
<input matInput type="number" formControlName="tenureMonths" min="1" />
</mat-form-field>
<mat-form-field>
<mat-label>Max Age at Maturity Policy</mat-label>
<input matInput type="number" formControlName="ageThresholdYears" min="18" />
</mat-form-field>
<div class="rbi-computed">
<span>Client's Current Age</span>
<strong>{{ ageLoadError ? '—' : (clientAgeYears ?? '…') }}</strong>
</div>
<div class="rbi-computed">
<span>Age at Maturity</span>
<strong>{{ ageAtMaturity ?? '—' }}</strong>
</div>
</div>
@if (ageCheckFailed) {
<mat-form-field class="rbi-override-field">
<mat-label>Override Reason (required)</mat-label>
<textarea matInput formControlName="ageAtMaturityOverrideReason" rows="2"></textarea>
</mat-form-field>
}
</mat-card-content>
</mat-card>
<!-- Area of Operation -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('AREA_OF_OPERATION').title }}</span>
<span
class="rbi-badge"
[ngClass]="rbiComplianceForm.get('areaOfOperationConfirmed').value ? 'rbi-badge--pass' : 'rbi-badge--fail'"
>
{{ rbiComplianceForm.get('areaOfOperationConfirmed').value ? 'Confirmed' : 'Not Confirmed' }}
</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('AREA_OF_OPERATION').rbiReference }}</p>
<mat-checkbox formControlName="areaOfOperationConfirmed">{{
ruleFor('AREA_OF_OPERATION').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('areaOfOperationConfirmed').value) {
<mat-form-field class="rbi-override-field">
<mat-label>Override Reason (required)</mat-label>
<textarea matInput formControlName="areaOfOperationOverrideReason" rows="2"></textarea>
</mat-form-field>
}
</mat-card-content>
</mat-card>
<!-- PSL Classification -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('PSL_CLASSIFICATION').title }}</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('PSL_CLASSIFICATION').rbiReference }}</p>
<p class="rbi-rule-statement">{{ ruleFor('PSL_CLASSIFICATION').statement }}</p>
<mat-form-field>
<mat-label>PSL Category</mat-label>
<mat-select formControlName="pslCategory">
@for (option of pslCategoryOptions; track option.code) {
<mat-option [value]="option.code">{{ option.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</mat-card-content>
</mat-card>
<!-- Director / Related Party -->
<mat-card class="rbi-rule-card rbi-rule-card--hard">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('DIRECTOR_RELATED_PARTY').title }}</span>
<span class="rbi-badge rbi-badge--hard">Legal Prohibition</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('DIRECTOR_RELATED_PARTY').rbiReference }}</p>
<mat-checkbox formControlName="directorRelatedPartyConfirmed">{{
ruleFor('DIRECTOR_RELATED_PARTY').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('directorRelatedPartyConfirmed').value) {
<p class="rbi-blocked-note">Cannot proceed until confirmed -- no override is available for this rule.</p>
}
</mat-card-content>
</mat-card>
<!-- Wilful Defaulter -->
<mat-card class="rbi-rule-card rbi-rule-card--hard">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('WILFUL_DEFAULTER').title }}</span>
<span class="rbi-badge rbi-badge--hard">Legal Prohibition</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('WILFUL_DEFAULTER').rbiReference }}</p>
<mat-checkbox formControlName="wilfulDefaulterConfirmed">{{
ruleFor('WILFUL_DEFAULTER').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('wilfulDefaulterConfirmed').value) {
<p class="rbi-blocked-note">Cannot proceed until confirmed -- no override is available for this rule.</p>
}
</mat-card-content>
</mat-card>
<!-- Exposure Norms -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('EXPOSURE_NORMS').title }}</span>
@if (exposurePct != null) {
<span class="rbi-badge" [ngClass]="exposureCheckFailed ? 'rbi-badge--fail' : 'rbi-badge--pass'">
{{ exposurePct | number: '1.1-1' }}%
</span>
}
</div>
<p class="rbi-rule-reference">{{ ruleFor('EXPOSURE_NORMS').rbiReference }}</p>
<p class="rbi-rule-statement">{{ ruleFor('EXPOSURE_NORMS').statement }}</p>
<div class="rbi-inline-fields">
<mat-form-field>
<mat-label>Existing Exposure (₹)</mat-label>
<input matInput type="number" formControlName="existingExposureAmount" min="0" />
</mat-form-field>
<mat-form-field>
<mat-label>Bank's Tier-I Capital (₹)</mat-label>
<input matInput type="number" formControlName="tierOneCapital" min="0" />
</mat-form-field>
</div>
@if (exposureCheckFailed) {
<mat-form-field class="rbi-override-field">
<mat-label>Override Reason (required)</mat-label>
<textarea matInput formControlName="exposureNormsOverrideReason" rows="2"></textarea>
</mat-form-field>
}
</mat-card-content>
</mat-card>
<!-- LTV / Margin -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('LTV_MARGIN').title }}</span>
@if (ltvPct != null) {
<span class="rbi-badge" [ngClass]="ltvCheckFailed ? 'rbi-badge--fail' : 'rbi-badge--pass'">
{{ ltvPct | number: '1.1-1' }}% (cap {{ ltvCapPct | number: '1.0-0' }}%)
</span>
}
</div>
<p class="rbi-rule-reference">{{ ruleFor('LTV_MARGIN').rbiReference }}</p>
<p class="rbi-rule-statement">{{ ruleFor('LTV_MARGIN').statement }}</p>
<div class="rbi-inline-fields">
<mat-form-field>
<mat-label>Collateral Type</mat-label>
<mat-select formControlName="collateralType">
@for (option of collateralTypeOptions; track option.code) {
<mat-option [value]="option.code">{{ option.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (ltvApplicable) {
<mat-form-field>
<mat-label>Collateral Value (₹)</mat-label>
<input matInput type="number" formControlName="collateralValue" min="0" />
</mat-form-field>
}
</div>
@if (ltvCheckFailed) {
<mat-form-field class="rbi-override-field">
<mat-label>Override Reason (required)</mat-label>
<textarea matInput formControlName="ltvOverrideReason" rows="2"></textarea>
</mat-form-field>
}
</mat-card-content>
</mat-card>
<!-- Fair Practices Code -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('FAIR_PRACTICES_CODE').title }}</span>
<span
class="rbi-badge"
[ngClass]="rbiComplianceForm.get('fairPracticesConfirmed').value ? 'rbi-badge--pass' : 'rbi-badge--fail'"
>
{{ rbiComplianceForm.get('fairPracticesConfirmed').value ? 'Confirmed' : 'Not Confirmed' }}
</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('FAIR_PRACTICES_CODE').rbiReference }}</p>
<mat-checkbox formControlName="fairPracticesConfirmed">{{
ruleFor('FAIR_PRACTICES_CODE').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('fairPracticesConfirmed').value) {
<mat-form-field class="rbi-override-field">
<mat-label>Override Reason (required)</mat-label>
<textarea matInput formControlName="fairPracticesOverrideReason" rows="2"></textarea>
</mat-form-field>
}
</mat-card-content>
</mat-card>
<!-- No Prepayment / Foreclosure Charges -->
<mat-card class="rbi-rule-card rbi-rule-card--hard">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('PREPAYMENT_CHARGES').title }}</span>
<span class="rbi-badge rbi-badge--hard">Legal Prohibition</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('PREPAYMENT_CHARGES').rbiReference }}</p>
<mat-checkbox formControlName="prepaymentChargesConfirmed">{{
ruleFor('PREPAYMENT_CHARGES').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('prepaymentChargesConfirmed').value) {
<p class="rbi-blocked-note">Cannot proceed until confirmed -- no override is available for this rule.</p>
}
</mat-card-content>
</mat-card>
<!-- Loan Against Bank's Own Shares -->
<mat-card class="rbi-rule-card rbi-rule-card--hard">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('OWN_SHARES_SECURITY').title }}</span>
<span class="rbi-badge rbi-badge--hard">Legal Prohibition</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('OWN_SHARES_SECURITY').rbiReference }}</p>
<mat-checkbox formControlName="ownSharesSecurityConfirmed">{{
ruleFor('OWN_SHARES_SECURITY').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('ownSharesSecurityConfirmed').value) {
<p class="rbi-blocked-note">Cannot proceed until confirmed -- no override is available for this rule.</p>
}
</mat-card-content>
</mat-card>
<!-- No Compulsory Insurance / Third-Party Bundling -->
<mat-card class="rbi-rule-card">
<mat-card-content>
<div class="rbi-rule-header">
<span class="rbi-rule-title">{{ ruleFor('INSURANCE_BUNDLING').title }}</span>
<span
class="rbi-badge"
[ngClass]="
rbiComplianceForm.get('insuranceBundlingConfirmed').value ? 'rbi-badge--pass' : 'rbi-badge--fail'
"
>
{{ rbiComplianceForm.get('insuranceBundlingConfirmed').value ? 'Confirmed' : 'Not Confirmed' }}
</span>
</div>
<p class="rbi-rule-reference">{{ ruleFor('INSURANCE_BUNDLING').rbiReference }}</p>
<mat-checkbox formControlName="insuranceBundlingConfirmed">{{
ruleFor('INSURANCE_BUNDLING').statement
}}</mat-checkbox>
@if (!rbiComplianceForm.get('insuranceBundlingConfirmed').value) {
<mat-form-field class="rbi-override-field">
<mat-label>Override Reason (required)</mat-label>
<textarea matInput formControlName="insuranceBundlingOverrideReason" rows="2"></textarea>
</mat-form-field>
}
</mat-card-content>
</mat-card>
</form>
}
@@ -0,0 +1,128 @@
/**
* 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/.
*/
.rbi-summary {
padding: 10px 16px;
border-radius: 8px;
font-weight: 600;
font-size: 13.5px;
margin-bottom: 16px;
}
.rbi-summary--cleared {
background: #dcfce7;
color: #166534;
}
.rbi-summary--override {
background: #fef3c7;
color: #92400e;
}
.rbi-summary--blocked {
background: #fee2e2;
color: #b42318;
}
.rbi-form {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 820px;
}
.rbi-rule-card {
border-left: 3px solid #94a3b8;
}
.rbi-rule-card--hard {
border-left-color: #b42318;
}
.rbi-rule-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 4px;
}
.rbi-rule-title {
font-size: 14.5px;
font-weight: 700;
color: #0f172a;
}
.rbi-rule-reference {
font-size: 12px;
font-style: italic;
color: #64748b;
margin: 0 0 6px;
}
.rbi-rule-statement {
font-size: 13px;
color: #334155;
margin: 0 0 10px;
}
.rbi-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.rbi-badge--pass {
background: #dcfce7;
color: #166534;
}
.rbi-badge--fail {
background: #fef3c7;
color: #92400e;
}
.rbi-badge--hard {
background: #fee2e2;
color: #b42318;
}
.rbi-inline-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
}
.rbi-computed {
display: flex;
flex-direction: column;
font-size: 12px;
color: #64748b;
strong {
font-size: 15px;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
}
.rbi-override-field {
width: 100%;
margin-top: 4px;
}
.rbi-blocked-note {
font-size: 12.5px;
color: #b42318;
font-weight: 600;
margin: 4px 0 0;
}
@@ -0,0 +1,314 @@
/**
* 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, OnChanges, OnInit, SimpleChanges, inject } from '@angular/core';
import { DecimalPipe, NgClass } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from 'app/clients/clients.service';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import {
COLLATERAL_TYPE_OPTIONS,
PSL_CATEGORY_OPTIONS,
RBI_COMPLIANCE_RULES,
RbiComplianceRule,
goldLoanLtvCapPct,
housingLoanLtvCapPct,
sharesLoanLtvCapPct
} from './rbi-compliance-rules.data';
/**
* RBI-circular-based loan origination checklist. Age and LTV/margin are computed from data
* already entered on this screen; everything else (area of operation, PSL classification,
* director/related-party status, wilful-defaulter/credit-bureau status, exposure norms,
* prepayment charges, own-shares security, insurance bundling) is an officer attestation, since
* this system has no backing data for those yet. Four rules are legal prohibitions with no
* override path (director/related-party lending, wilful defaulter, prepayment/foreclosure
* charges on floating-rate individual/MSE loans, and loans against the bank's own shares); every
* other failed rule needs a written override reason to proceed.
*/
@Component({
selector: 'mifosx-rbi-compliance-step',
templateUrl: './rbi-compliance-step.component.html',
styleUrls: ['./rbi-compliance-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
NgClass,
DecimalPipe
]
})
export class RbiComplianceStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private clientsService = inject(ClientsService);
@Input() clientId: number | null = null;
@Input() loanAmount: number | null = null;
rules: RbiComplianceRule[] = RBI_COMPLIANCE_RULES;
pslCategoryOptions = PSL_CATEGORY_OPTIONS;
collateralTypeOptions = COLLATERAL_TYPE_OPTIONS;
rbiComplianceForm: UntypedFormGroup;
/** Client's current age in whole years, from Fineract's dateOfBirth; null until resolved. */
clientAgeYears: number | null = null;
ageLoadError = false;
ngOnInit(): void {
this.rbiComplianceForm = this.formBuilder.group({
ageThresholdYears: [
60,
[
Validators.required,
Validators.min(18)
]
],
tenureMonths: [
null,
[
Validators.required,
Validators.min(1)
]
],
ageAtMaturityOverrideReason: [''],
areaOfOperationConfirmed: [false],
areaOfOperationOverrideReason: [''],
pslCategory: ['NOT_APPLICABLE'],
directorRelatedPartyConfirmed: [
false,
Validators.requiredTrue
],
wilfulDefaulterConfirmed: [
false,
Validators.requiredTrue
],
existingExposureAmount: [null],
tierOneCapital: [null],
exposureNormsOverrideReason: [''],
collateralType: ['NONE'],
collateralValue: [null],
ltvOverrideReason: [''],
fairPracticesConfirmed: [false],
fairPracticesOverrideReason: [''],
prepaymentChargesConfirmed: [
false,
Validators.requiredTrue
],
ownSharesSecurityConfirmed: [
false,
Validators.requiredTrue
],
insuranceBundlingConfirmed: [false],
insuranceBundlingOverrideReason: ['']
});
this.rbiComplianceForm.valueChanges.subscribe(() => this.recomputeOverrideRequirements());
this.loadClientAge();
this.recomputeOverrideRequirements();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.clientId && !changes.clientId.firstChange) {
this.loadClientAge();
}
if (this.rbiComplianceForm) {
this.recomputeOverrideRequirements();
}
}
private loadClientAge(): void {
this.clientAgeYears = null;
this.ageLoadError = false;
if (!this.clientId) {
return;
}
this.clientsService
.getClientData(String(this.clientId), true)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
const dob = data?.dateOfBirth;
if (Array.isArray(dob) && dob.length >= 3) {
const birth = new Date(dob[0], dob[1] - 1, dob[2]);
const today = new Date();
let years = today.getFullYear() - birth.getFullYear();
const beforeBirthdayThisYear =
today.getMonth() < birth.getMonth() ||
(today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate());
if (beforeBirthdayThisYear) {
years--;
}
this.clientAgeYears = years;
} else {
this.ageLoadError = true;
}
this.recomputeOverrideRequirements();
});
}
/** Age this borrower will be when the loan's final instalment falls due. */
get ageAtMaturity(): number | null {
const tenureMonths = Number(this.rbiComplianceForm?.get('tenureMonths')?.value);
if (this.clientAgeYears == null || !tenureMonths || tenureMonths <= 0) {
return null;
}
return this.clientAgeYears + Math.ceil(tenureMonths / 12);
}
get ageCheckFailed(): boolean {
const threshold = Number(this.rbiComplianceForm?.get('ageThresholdYears')?.value);
const maturity = this.ageAtMaturity;
return maturity != null && !!threshold && maturity > threshold;
}
get exposurePct(): number | null {
const existing = Number(this.rbiComplianceForm?.get('existingExposureAmount')?.value) || 0;
const tierOne = Number(this.rbiComplianceForm?.get('tierOneCapital')?.value);
const loanAmount = Number(this.loanAmount) || 0;
if (!tierOne || tierOne <= 0) {
return null;
}
return ((existing + loanAmount) / tierOne) * 100;
}
get exposureCheckFailed(): boolean {
const pct = this.exposurePct;
return pct != null && pct > 15;
}
get collateralType(): string {
return this.rbiComplianceForm?.get('collateralType')?.value ?? 'NONE';
}
get ltvApplicable(): boolean {
return this.collateralType !== 'NONE';
}
get ltvCapPct(): number | null {
const loanAmount = Number(this.loanAmount) || 0;
const collateralValue = Number(this.rbiComplianceForm?.get('collateralValue')?.value) || 0;
if (!this.ltvApplicable || collateralValue <= 0) {
return null;
}
switch (this.collateralType) {
case 'GOLD':
return goldLoanLtvCapPct(loanAmount);
case 'SHARES_PHYSICAL':
return sharesLoanLtvCapPct(false);
case 'SHARES_DEMAT':
return sharesLoanLtvCapPct(true);
default:
return housingLoanLtvCapPct(collateralValue);
}
}
get ltvPct(): number | null {
const loanAmount = Number(this.loanAmount) || 0;
const collateralValue = Number(this.rbiComplianceForm?.get('collateralValue')?.value) || 0;
if (!this.ltvApplicable || collateralValue <= 0) {
return null;
}
return (loanAmount / collateralValue) * 100;
}
get ltvCheckFailed(): boolean {
const pct = this.ltvPct;
const cap = this.ltvCapPct;
return pct != null && cap != null && pct > cap;
}
/** Adds/removes the `required` validator on each SOFT rule's override-reason field, live, as
* the underlying computed/attested result changes -- mirrors the dynamic-validator pattern
* used for the guardian fields in client-general-step.component.ts. */
private recomputeOverrideRequirements(): void {
if (!this.rbiComplianceForm) {
return;
}
this.setRequiredIf('ageAtMaturityOverrideReason', this.ageCheckFailed);
this.setRequiredIf('areaOfOperationOverrideReason', !this.rbiComplianceForm.get('areaOfOperationConfirmed')?.value);
this.setRequiredIf('exposureNormsOverrideReason', this.exposureCheckFailed);
this.setRequiredIf('ltvOverrideReason', this.ltvCheckFailed);
this.setRequiredIf('fairPracticesOverrideReason', !this.rbiComplianceForm.get('fairPracticesConfirmed')?.value);
this.setRequiredIf(
'insuranceBundlingOverrideReason',
!this.rbiComplianceForm.get('insuranceBundlingConfirmed')?.value
);
}
private setRequiredIf(controlName: string, required: boolean): void {
const control = this.rbiComplianceForm.get(controlName);
if (!control) {
return;
}
const hasRequired = control.hasValidator(Validators.required);
if (required && !hasRequired) {
control.addValidators(Validators.required);
control.updateValueAndValidity({ emitEvent: false });
} else if (!required && hasRequired) {
control.removeValidators(Validators.required);
control.updateValueAndValidity({ emitEvent: false });
}
}
/** Everything the parent wizard needs to decide whether this borrower/loan may proceed. */
get rbiCompliance(): any {
return { rbiCompliance: this.rbiComplianceForm.value };
}
ruleFor(code: string): RbiComplianceRule {
return this.rules.find((r) => r.code === code);
}
/** Legal-prohibition rules with no override path -- true until the officer ticks both. */
get isBlocked(): boolean {
if (!this.rbiComplianceForm) {
return true;
}
return (
!this.rbiComplianceForm.get('directorRelatedPartyConfirmed')?.value ||
!this.rbiComplianceForm.get('wilfulDefaulterConfirmed')?.value ||
!this.rbiComplianceForm.get('prepaymentChargesConfirmed')?.value ||
!this.rbiComplianceForm.get('ownSharesSecurityConfirmed')?.value
);
}
/** How many SOFT rules currently need (or have) a written override reason. */
get overrideCount(): number {
if (!this.rbiComplianceForm) {
return 0;
}
let count = 0;
if (this.ageCheckFailed) {
count++;
}
if (!this.rbiComplianceForm.get('areaOfOperationConfirmed')?.value) {
count++;
}
if (this.exposureCheckFailed) {
count++;
}
if (this.ltvCheckFailed) {
count++;
}
if (!this.rbiComplianceForm.get('fairPracticesConfirmed')?.value) {
count++;
}
if (!this.rbiComplianceForm.get('insuranceBundlingConfirmed')?.value) {
count++;
}
return count;
}
}