From 75df8865ddab067125485d1d26214e8724811227 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 13 Aug 2026 10:15:28 +0530 Subject: [PATCH] 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. --- src/app/core/shell/shell.component.html | 6 + .../application-detail.component.html | 782 +++++++++++++++++- .../application-detail.component.ts | 773 ++++++++++++++++- ...hare-account-details-dialog.component.html | 62 ++ ...hare-account-details-dialog.component.scss | 37 + .../share-account-details-dialog.component.ts | 61 ++ .../loan-eligibility-checklist.component.html | 61 ++ .../loan-eligibility-checklist.component.scss | 104 +++ .../loan-eligibility-checklist.component.ts | 76 ++ .../loan-origination/loan-origination-data.ts | 93 ++- .../loan-origination-routing.module.ts | 12 + .../loan-origination-shared.scss | 348 ++++++++ .../loan-origination.module.ts | 6 +- .../loan-pipeline.component.html | 51 +- .../loan-pipeline/loan-pipeline.component.ts | 61 ++ .../rbi-compliance-rules.data.ts | 184 +++++ .../rbi-compliance-step.component.html | 309 +++++++ .../rbi-compliance-step.component.scss | 128 +++ .../rbi-compliance-step.component.ts | 314 +++++++ .../rbi-rules-reference.component.html | 87 ++ .../rbi-rules-reference.component.scss | 145 ++++ .../rbi-rules-reference.component.ts | 36 + 22 files changed, 3688 insertions(+), 48 deletions(-) create mode 100644 src/app/loan-origination/application-detail/share-account-details-dialog/share-account-details-dialog.component.html create mode 100644 src/app/loan-origination/application-detail/share-account-details-dialog/share-account-details-dialog.component.scss create mode 100644 src/app/loan-origination/application-detail/share-account-details-dialog/share-account-details-dialog.component.ts create mode 100644 src/app/loan-origination/loan-eligibility-checklist/loan-eligibility-checklist.component.html create mode 100644 src/app/loan-origination/loan-eligibility-checklist/loan-eligibility-checklist.component.scss create mode 100644 src/app/loan-origination/loan-eligibility-checklist/loan-eligibility-checklist.component.ts create mode 100644 src/app/loan-origination/rbi-compliance-step/rbi-compliance-rules.data.ts create mode 100644 src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.html create mode 100644 src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.scss create mode 100644 src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.ts create mode 100644 src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.html create mode 100644 src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.scss create mode 100644 src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.ts diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 5b9a7603f..d52c27b31 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1376,6 +1376,12 @@ + +
diff --git a/src/app/loan-origination/application-detail/application-detail.component.html b/src/app/loan-origination/application-detail/application-detail.component.html index 6eae936f0..d78d970ab 100644 --- a/src/app/loan-origination/application-detail/application-detail.component.html +++ b/src/app/loan-origination/application-detail/application-detail.component.html @@ -56,7 +56,24 @@
Share Member
- @if (a.isShareMember) { + @if (a.clientId) { + + @if (loadingShareAccounts) { + Checking membership… + } @else if (realShareAccounts.length > 0) { + + } @else { + Not a Member + + } + } @else if (a.isShareMember) { Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }}) } @else { Not a Member @@ -83,11 +100,24 @@
@for (s of stageOrder; track s) { -
- - {{ stepMarker(a, s) }} - {{ stageLabels[s] }} -
+ @if (canJumpToStage(a, s)) { + + } @else { +
+ + {{ stepMarker(a, s) }} + {{ stageLabels[s] }} +
+ } }
@@ -225,36 +255,521 @@
-
- - - - - @if (a.secured) { - - + @if (a.clientId) { + + @if (loadingKyc) { +

Loading KYC record…

+ } @else { +
+
+ Aadhaar +
+ + {{ a.aadhaarVerified ? 'Verified' : 'Not Verified' }} + + @if (realKyc?.aadhaarNumberMasked) { + {{ realKyc.aadhaarNumberMasked }} + } +
+ +
+
+ PAN +
+ + {{ a.panVerified ? 'Verified' : 'Not Verified' }} + + @if (realKyc?.panNumber) { + {{ maskPan(realKyc.panNumber) }} + } +
+ +
+
+ Income Proof + @if (findDocument('income'); as incomeDoc) { + Submitted + {{ incomeDoc.name || incomeDoc.fileName }} + + } @else { + + +
+ + @if (newKycCapture.incomeProof.file) { + + } +
+ @if (a.incomeProofSubmitted) { + Submitted + } @else { + Missing + } + } +
+
+ Bank Statement + @if (findDocument('bank statement'); as bankDoc) { + Submitted + {{ bankDoc.name || bankDoc.fileName }} + + } @else { +
+ + @if (newKycCapture.bankStatement.file) { + + } +
+ @if (a.bankStatementSubmitted) { + Submitted + } @else { + Missing + } + } +
+
+ Electricity Bill (Address Proof) + @if (findDocument('electricity') || findDocument('address proof'); as billDoc) { + Submitted + {{ billDoc.name || billDoc.fileName }} + + } @else { + + @if (electricityBillTooOld) { +

Must not be older than 3 months.

+ } +
+ + @if (newKycCapture.electricityBill.file) { + + } +
+ @if (a.electricityBillSubmitted) { + Submitted + } @else { + Missing + } + } +
+ @if (a.secured) { +
+ +
+
+ +
+ } +
+ +
+
} - - -
+ } @else { + +

+ Captured for this application — final Aadhaar/PAN verification happens when the customer's CBS client + profile is created. +

+
+
+ Aadhaar +
+ + +
+ @if (newKycCapture.aadhaar.front || newKycCapture.aadhaar.back) { +
+ @if (newKycCapture.aadhaar.front) { + + } + @if (newKycCapture.aadhaar.back) { + + } +
+ } + @if (newKycCapture.aadhaar.verified) { + Aadhaar Verified + } @else { +
+ + + @if (newKycCapture.aadhaar.otpSent) { +
+ @for (d of newKycCapture.aadhaar.otpDigits; track $index) { + + } +
+ + } +
+ } +
+
+ PAN +
+ + +
+ @if (newKycCapture.pan.front || newKycCapture.pan.back) { +
+ @if (newKycCapture.pan.front) { + + } + @if (newKycCapture.pan.back) { + + } +
+ } + @if (newKycCapture.pan.verified) { + PAN Verified + } @else { +
+ + +
+ } +
+
+ Income Proof + +
+ + @if (newKycCapture.incomeProof.file) { + + } +
+ @if (a.incomeProofSubmitted) { + Submitted + } @else { + Missing + } +
+
+ Bank Statement +
+ + @if (newKycCapture.bankStatement.file) { + + } +
+ @if (a.bankStatementSubmitted) { + Submitted + } @else { + Missing + } +
+
+ Electricity Bill (Address Proof) + + @if (electricityBillTooOld) { +

Must not be older than 3 months.

+ } +
+ + @if (newKycCapture.electricityBill.file) { + + } +
+ @if (a.electricityBillSubmitted) { + Submitted + } @else { + Missing + } +
+ @if (a.secured) { +
+ +
+
+ +
+ } +
+ +
+
+ } @if (a.monthlyIncome === 0) {
+ + } + } @else { + + }
+ + @if (newApp.customerType === 'EXISTING' && !newApp.clientId) { + @if (searchingExistingClient) { +

Searching…

+ } @else if (existingClientSearchText.length >= 2 && existingClientSearchResults.length === 0) { +

No clients found.

+ } + @if (existingClientSearchResults.length > 0) { +
+ @for (client of existingClientSearchResults; track client.id) { + + } +
+ } + } +
diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts index a4f0be20d..9973815ca 100644 --- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts +++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts @@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { ClientsService } from '../../clients/clients.service'; import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export'; import { BRANCHES, @@ -134,12 +135,19 @@ export class LoanPipelineComponent implements OnInit { bcAgentName: '', customerType: 'NEW' as CustomerType, applicantName: '', + clientId: null as number | null, branchCode: this.branches[0].code, productCode: this.products[0].code, requestedAmount: null as number | null, tenureMonths: null as number | null }; + /** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */ + existingClientSearchText = ''; + existingClientSearchResults: any[] = []; + searchingExistingClient = false; + private existingClientSearchTimer: any; + toastMsg = ''; toastVisible = false; private toastTimer: ReturnType | undefined; @@ -147,6 +155,7 @@ export class LoanPipelineComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly auth = inject(AuthenticationService); + private readonly clientsService = inject(ClientsService); ngOnInit(): void { const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null; @@ -428,11 +437,59 @@ export class LoanPipelineComponent implements OnInit { return this.products.find((p) => p.code === code) || this.products[0]; } + /** Switching Customer Type away from "Existing" drops any client picked under the old mode -- + * avoids storing a stale clientId against a now-"New Customer" application. */ + onCustomerTypeChange(): void { + if (this.newApp.customerType === 'NEW') { + this.clearExistingClient(); + } + } + + onExistingClientSearch(text: string): void { + this.existingClientSearchText = text; + clearTimeout(this.existingClientSearchTimer); + if (!text || text.length < 2) { + this.existingClientSearchResults = []; + return; + } + this.searchingExistingClient = true; + this.existingClientSearchTimer = setTimeout(() => { + this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({ + next: (data: any) => { + this.existingClientSearchResults = data?.content || []; + this.searchingExistingClient = false; + }, + error: () => { + this.existingClientSearchResults = []; + this.searchingExistingClient = false; + } + }); + }, 400); + } + + selectExistingClient(client: any): void { + this.newApp.clientId = client.id; + this.newApp.applicantName = client.displayName; + this.existingClientSearchResults = []; + this.existingClientSearchText = ''; + } + + clearExistingClient(): void { + this.newApp.clientId = null; + this.newApp.applicantName = ''; + this.existingClientSearchText = ''; + this.existingClientSearchResults = []; + } + saveNewApplication(): void { if (!this.newApp.applicantName.trim() || !this.newApp.requestedAmount || this.newApp.requestedAmount <= 0) { this.toast('Enter the applicant name and a valid requested amount.'); return; } + if (this.newApp.customerType === 'EXISTING' && !this.newApp.clientId) { + this.toast('Search and select the existing client for this application.'); + return; + } if (this.newApp.channel === 'BC' && !this.newApp.bcAgentName.trim()) { this.toast('Enter the BC agent/point name for a Business Correspondent application.'); return; @@ -451,6 +508,7 @@ export class LoanPipelineComponent implements OnInit { bcAgentName: this.newApp.bcAgentName || '—', customerType: this.newApp.customerType, applicantName: this.newApp.applicantName.trim(), + clientId: this.newApp.clientId, branchCode: branch.code, branchName: branch.name, productCode: product.code, @@ -469,11 +527,14 @@ export class LoanPipelineComponent implements OnInit { bcAgentName: '', customerType: 'NEW', applicantName: '', + clientId: null, branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code, productCode: this.products[0].code, requestedAmount: null, tenureMonths: null }; + this.existingClientSearchText = ''; + this.existingClientSearchResults = []; this.router.navigate([ '/loan-origination/application', app.applicationNo diff --git a/src/app/loan-origination/rbi-compliance-step/rbi-compliance-rules.data.ts b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-rules.data.ts new file mode 100644 index 000000000..298153c65 --- /dev/null +++ b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-rules.data.ts @@ -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; +} diff --git a/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.html b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.html new file mode 100644 index 000000000..319d0ac7a --- /dev/null +++ b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.html @@ -0,0 +1,309 @@ + + +@if (rbiComplianceForm) { +
+ @if (isBlocked) { + Blocked -- resolve the legal-prohibition item(s) below to continue. + } @else if (overrideCount > 0) { + Cleared -- {{ overrideCount }} override reason(s) recorded below. + } @else { + Cleared -- all RBI checks satisfied. + } +
+ +
+ + + +
+ {{ ruleFor('AGE_AT_MATURITY').title }} + @if (ageAtMaturity != null) { + + {{ ageCheckFailed ? 'Exceeds Policy' : 'Within Policy' }} + + } +
+

{{ ruleFor('AGE_AT_MATURITY').rbiReference }}

+

{{ ruleFor('AGE_AT_MATURITY').statement }}

+ +
+ + Loan Tenure (months) + + + + Max Age at Maturity Policy + + +
+ Client's Current Age + {{ ageLoadError ? '—' : (clientAgeYears ?? '…') }} +
+
+ Age at Maturity + {{ ageAtMaturity ?? '—' }} +
+
+ + @if (ageCheckFailed) { + + Override Reason (required) + + + } +
+
+ + + + +
+ {{ ruleFor('AREA_OF_OPERATION').title }} + + {{ rbiComplianceForm.get('areaOfOperationConfirmed').value ? 'Confirmed' : 'Not Confirmed' }} + +
+

{{ ruleFor('AREA_OF_OPERATION').rbiReference }}

+ {{ + ruleFor('AREA_OF_OPERATION').statement + }} + + @if (!rbiComplianceForm.get('areaOfOperationConfirmed').value) { + + Override Reason (required) + + + } +
+
+ + + + +
+ {{ ruleFor('PSL_CLASSIFICATION').title }} +
+

{{ ruleFor('PSL_CLASSIFICATION').rbiReference }}

+

{{ ruleFor('PSL_CLASSIFICATION').statement }}

+ + PSL Category + + @for (option of pslCategoryOptions; track option.code) { + {{ option.name }} + } + + +
+
+ + + + +
+ {{ ruleFor('DIRECTOR_RELATED_PARTY').title }} + Legal Prohibition +
+

{{ ruleFor('DIRECTOR_RELATED_PARTY').rbiReference }}

+ {{ + ruleFor('DIRECTOR_RELATED_PARTY').statement + }} + @if (!rbiComplianceForm.get('directorRelatedPartyConfirmed').value) { +

Cannot proceed until confirmed -- no override is available for this rule.

+ } +
+
+ + + + +
+ {{ ruleFor('WILFUL_DEFAULTER').title }} + Legal Prohibition +
+

{{ ruleFor('WILFUL_DEFAULTER').rbiReference }}

+ {{ + ruleFor('WILFUL_DEFAULTER').statement + }} + @if (!rbiComplianceForm.get('wilfulDefaulterConfirmed').value) { +

Cannot proceed until confirmed -- no override is available for this rule.

+ } +
+
+ + + + +
+ {{ ruleFor('EXPOSURE_NORMS').title }} + @if (exposurePct != null) { + + {{ exposurePct | number: '1.1-1' }}% + + } +
+

{{ ruleFor('EXPOSURE_NORMS').rbiReference }}

+

{{ ruleFor('EXPOSURE_NORMS').statement }}

+ +
+ + Existing Exposure (₹) + + + + Bank's Tier-I Capital (₹) + + +
+ + @if (exposureCheckFailed) { + + Override Reason (required) + + + } +
+
+ + + + +
+ {{ ruleFor('LTV_MARGIN').title }} + @if (ltvPct != null) { + + {{ ltvPct | number: '1.1-1' }}% (cap {{ ltvCapPct | number: '1.0-0' }}%) + + } +
+

{{ ruleFor('LTV_MARGIN').rbiReference }}

+

{{ ruleFor('LTV_MARGIN').statement }}

+ +
+ + Collateral Type + + @for (option of collateralTypeOptions; track option.code) { + {{ option.name }} + } + + + @if (ltvApplicable) { + + Collateral Value (₹) + + + } +
+ + @if (ltvCheckFailed) { + + Override Reason (required) + + + } +
+
+ + + + +
+ {{ ruleFor('FAIR_PRACTICES_CODE').title }} + + {{ rbiComplianceForm.get('fairPracticesConfirmed').value ? 'Confirmed' : 'Not Confirmed' }} + +
+

{{ ruleFor('FAIR_PRACTICES_CODE').rbiReference }}

+ {{ + ruleFor('FAIR_PRACTICES_CODE').statement + }} + + @if (!rbiComplianceForm.get('fairPracticesConfirmed').value) { + + Override Reason (required) + + + } +
+
+ + + + +
+ {{ ruleFor('PREPAYMENT_CHARGES').title }} + Legal Prohibition +
+

{{ ruleFor('PREPAYMENT_CHARGES').rbiReference }}

+ {{ + ruleFor('PREPAYMENT_CHARGES').statement + }} + @if (!rbiComplianceForm.get('prepaymentChargesConfirmed').value) { +

Cannot proceed until confirmed -- no override is available for this rule.

+ } +
+
+ + + + +
+ {{ ruleFor('OWN_SHARES_SECURITY').title }} + Legal Prohibition +
+

{{ ruleFor('OWN_SHARES_SECURITY').rbiReference }}

+ {{ + ruleFor('OWN_SHARES_SECURITY').statement + }} + @if (!rbiComplianceForm.get('ownSharesSecurityConfirmed').value) { +

Cannot proceed until confirmed -- no override is available for this rule.

+ } +
+
+ + + + +
+ {{ ruleFor('INSURANCE_BUNDLING').title }} + + {{ rbiComplianceForm.get('insuranceBundlingConfirmed').value ? 'Confirmed' : 'Not Confirmed' }} + +
+

{{ ruleFor('INSURANCE_BUNDLING').rbiReference }}

+ {{ + ruleFor('INSURANCE_BUNDLING').statement + }} + + @if (!rbiComplianceForm.get('insuranceBundlingConfirmed').value) { + + Override Reason (required) + + + } +
+
+
+} diff --git a/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.scss b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.scss new file mode 100644 index 000000000..3cb2bec40 --- /dev/null +++ b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.scss @@ -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; +} diff --git a/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.ts b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.ts new file mode 100644 index 000000000..71ad8d694 --- /dev/null +++ b/src/app/loan-origination/rbi-compliance-step/rbi-compliance-step.component.ts @@ -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; + } +} diff --git a/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.html b/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.html new file mode 100644 index 000000000..c4a9856bf --- /dev/null +++ b/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.html @@ -0,0 +1,87 @@ + + +
+ + +

RBI Compliance Rules -- Reference

+

+ Every rule enforced on the RBI Eligibility Checklist, with its underlying regulation and exact wording. +

+ +
+ @for (rule of rules; track rule.code) { +
+
+ {{ rule.title }} +
+ {{ rule.checkType === 'AUTO' ? 'Automatic' : 'Manual' }} + + {{ rule.blockLevel === 'HARD' ? 'Legal Prohibition -- No Override' : 'Policy -- Override Allowed' }} + +
+
+

{{ rule.rbiReference }}

+

{{ rule.statement }}

+
+ } +
+ +

Priority Sector Lending -- Category Reference

+
+ @for (category of pslCategories; track category.code) { + {{ category.name }} + } +
+ +

Loan-to-Value Cap Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CollateralValue BandMax LTV
Gold LoanLoan amount up to ₹2.5 lakh85%
Loan amount ₹2.5 lakh -- ₹5 lakh80%
Loan amount above ₹5 lakh75%
Housing LoanProperty value below ₹30 lakh90%
Property value ₹30 lakh and above80%
Shares / DebenturesHeld in physical form (min. 50% margin)50%
Held in demat form (min. 25% margin)75%
+
+
+
diff --git a/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.scss b/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.scss new file mode 100644 index 000000000..1fe4319bf --- /dev/null +++ b/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.scss @@ -0,0 +1,145 @@ +/** + * 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/. + */ + +.rrr-container { + max-width: 900px; +} + +.rrr-title { + margin: 0 0 4px; +} + +.rrr-subtitle { + color: #64748b; + font-size: 13.5px; + margin: 0 0 20px; +} + +.rrr-rule-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 28px; +} + +.rrr-rule-row { + padding: 12px 16px; + border-left: 3px solid #94a3b8; + background: #f8fafc; + border-radius: 6px; +} + +.rrr-rule-row--hard { + border-left-color: #b42318; +} + +.rrr-rule-row-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.rrr-rule-title { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; +} + +.rrr-rule-tags { + display: flex; + gap: 6px; +} + +.rrr-tag { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.rrr-tag--type { + background: #e0e7ff; + color: #3730a3; +} + +.rrr-tag--soft { + background: #fef3c7; + color: #92400e; +} + +.rrr-tag--hard { + background: #fee2e2; + color: #b42318; +} + +.rrr-rule-reference { + font-size: 12px; + font-style: italic; + color: #64748b; + margin: 0 0 6px; +} + +.rrr-rule-statement { + font-size: 13px; + color: #334155; + margin: 0; +} + +.rrr-section-title { + font-size: 15px; + font-weight: 700; + color: #0f172a; + margin: 24px 0 10px; +} + +.rrr-psl-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.rrr-psl-chip { + display: inline-block; + padding: 4px 12px; + border-radius: 999px; + background: #dcfce7; + color: #166534; + font-size: 12.5px; + font-weight: 600; +} + +.rrr-ltv-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid #e2e8f0; + } + + th { + color: #64748b; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.4px; + } + + td:last-child { + font-variant-numeric: tabular-nums; + font-weight: 600; + } +} diff --git a/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.ts b/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.ts new file mode 100644 index 000000000..98972d37a --- /dev/null +++ b/src/app/loan-origination/rbi-rules-reference/rbi-rules-reference.component.ts @@ -0,0 +1,36 @@ +/** + * 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 } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + PSL_CATEGORY_OPTIONS, + RBI_COMPLIANCE_RULES, + RbiComplianceRule +} from '../rbi-compliance-step/rbi-compliance-rules.data'; + +/** + * Read-only reference of every RBI-circular-based rule enforced by the RBI Eligibility Checklist + * (see rbi-compliance-step.component.ts) -- lets an officer look up the underlying regulation and + * exact wording without having to pick a client first. + */ +@Component({ + selector: 'mifosx-rbi-rules-reference', + templateUrl: './rbi-rules-reference.component.html', + styleUrls: ['./rbi-rules-reference.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + NgClass + ] +}) +export class RbiRulesReferenceComponent { + rules: RbiComplianceRule[] = RBI_COMPLIANCE_RULES; + pslCategories = PSL_CATEGORY_OPTIONS.filter((c) => c.code !== 'NOT_APPLICABLE'); +}