feat(loan-origination): RBI compliance checklist, real client integration, and CIC consent/charge flows
- 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user