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
+6
View File
@@ -1376,6 +1376,12 @@
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/eligibility-checklist">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> RBI Eligibility Checklist
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/rbi-rules-reference">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> RBI Rules Reference
</button>
</div>
<!-- Reports -->
<div class="deposit-menu-group">
@@ -56,7 +56,24 @@
<div class="loan-kv-field">
<dt>Share Member</dt>
<dd>
@if (a.isShareMember) {
@if (a.clientId) {
<!-- Real Existing Customer — checked against actual Fineract share accounts, not
the mock isShareMember fields every other (dummy-data) application uses. -->
@if (loadingShareAccounts) {
Checking membership…
} @else if (realShareAccounts.length > 0) {
<div class="loi-share-account-list">
@for (sa of realShareAccounts; track sa.id) {
<button type="button" class="loi-share-account-chip" (click)="viewShareAccountDetails(sa.id)">
{{ sa.accountNo }} — {{ sa.productName }}
</button>
}
</div>
} @else {
<span class="loan-badge loan-badge--hold">Not a Member</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="allotMembership(a)">Allot Shares</button>
}
} @else if (a.isShareMember) {
Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }})
} @else {
<span class="loan-badge loan-badge--hold">Not a Member</span>
@@ -83,11 +100,24 @@
<div class="pos-section-label">Sanctioning Workflow Progress</div>
<div class="loan-stepper">
@for (s of stageOrder; track s) {
<div [class]="stepStatusClass(a, s)">
<span class="loan-step__connector"></span>
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
<span class="loan-step__label">{{ stageLabels[s] }}</span>
</div>
@if (canJumpToStage(a, s)) {
<button
type="button"
[class]="stepStatusClass(a, s) + ' loan-step--clickable'"
title="Jump to {{ stageLabels[s] }} — nothing already entered is cleared"
(click)="goToStage(a, s)"
>
<span class="loan-step__connector"></span>
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
<span class="loan-step__label">{{ stageLabels[s] }}</span>
</button>
} @else {
<div [class]="stepStatusClass(a, s)">
<span class="loan-step__connector"></span>
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
<span class="loan-step__label">{{ stageLabels[s] }}</span>
</div>
}
}
</div>
</div>
@@ -225,36 +255,521 @@
<div class="pg-card">
<div class="pos-section-label">KYC &amp; Document Collection — OVD (RBI KYC Master Direction)</div>
<div class="loan-action-card">
<div class="loan-checklist">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarVerified" /> Aadhaar Verified (OVD)
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.panVerified" /> PAN Verified
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.incomeProofSubmitted" /> Income Proof Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.bankStatementSubmitted" /> Bank Statement Submitted
</label>
@if (a.secured) {
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
</label>
@if (a.clientId) {
<!-- Real Existing Customer -- Aadhaar/PAN read straight from Fineract's KYC record
(verified status + masked number + click-to-view image); the other three
proofs are checked against the client's real uploaded documents where
available, falling back to the same local-only capture UI as a New Customer
application when nothing is on file yet. -->
@if (loadingKyc) {
<p class="pg-sub npa-report-note">Loading KYC record…</p>
} @else {
<div class="loi-kyc-doc-grid">
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Aadhaar</span>
<div class="loi-kyc-row">
<span
[class]="'loan-badge ' + (a.aadhaarVerified ? 'loan-badge--verified' : 'loan-badge--missing')"
>
{{ a.aadhaarVerified ? 'Verified' : 'Not Verified' }}
</span>
@if (realKyc?.aadhaarNumberMasked) {
<span class="loi-kyc-masked">{{ realKyc.aadhaarNumberMasked }}</span>
}
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewKycIdentifierDoc('aadhaar')">
View Document
</button>
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">PAN</span>
<div class="loi-kyc-row">
<span [class]="'loan-badge ' + (a.panVerified ? 'loan-badge--verified' : 'loan-badge--missing')">
{{ a.panVerified ? 'Verified' : 'Not Verified' }}
</span>
@if (realKyc?.panNumber) {
<span class="loi-kyc-masked">{{ maskPan(realKyc.panNumber) }}</span>
}
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewKycIdentifierDoc('pan')">
View Document
</button>
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Income Proof</span>
@if (findDocument('income'); as incomeDoc) {
<span class="loan-badge loan-badge--verified">Submitted</span>
<span class="loi-kyc-masked">{{ incomeDoc.name || incomeDoc.fileName }}</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewGenericDocument(incomeDoc)">
View
</button>
} @else {
<!-- Nothing on file in Fineract for this client -- let the officer capture it
here instead of just ticking a box with nothing behind it. -->
<label class="inv-form-field">
<span>Employment Type</span>
<select
[(ngModel)]="newKycCapture.incomeProof.employmentType"
(ngModelChange)="onIncomeProofEmploymentTypeChange(a)"
>
<option value="SALARIED">Salaried</option>
<option value="BUSINESS">Business / Self-Employed</option>
</select>
</label>
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{
newKycCapture.incomeProof.file
? incomeProofDocLabel + ' ✓'
: 'Upload ' + incomeProofDocLabel
}}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewIncomeProofFile(a, $event)"
/>
</label>
@if (newKycCapture.incomeProof.file) {
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(newKycCapture.incomeProof.file)"
>
@if (newKycCapture.incomeProof.previewUrl) {
<img [src]="newKycCapture.incomeProof.previewUrl" alt="Income proof" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
}
</div>
@if (a.incomeProofSubmitted) {
<span class="loan-badge loan-badge--verified">Submitted</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
}
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Bank Statement</span>
@if (findDocument('bank statement'); as bankDoc) {
<span class="loan-badge loan-badge--verified">Submitted</span>
<span class="loi-kyc-masked">{{ bankDoc.name || bankDoc.fileName }}</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewGenericDocument(bankDoc)">
View
</button>
} @else {
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.bankStatement.file ? 'Statement ✓' : 'Upload Bank Statement' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewBankStatementFile(a, $event)"
/>
</label>
@if (newKycCapture.bankStatement.file) {
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(newKycCapture.bankStatement.file)"
>
@if (newKycCapture.bankStatement.previewUrl) {
<img [src]="newKycCapture.bankStatement.previewUrl" alt="Bank statement" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
}
</div>
@if (a.bankStatementSubmitted) {
<span class="loan-badge loan-badge--verified">Submitted</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
}
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Electricity Bill (Address Proof)</span>
@if (findDocument('electricity') || findDocument('address proof'); as billDoc) {
<span class="loan-badge loan-badge--verified">Submitted</span>
<span class="loi-kyc-masked">{{ billDoc.name || billDoc.fileName }}</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="viewGenericDocument(billDoc)">
View
</button>
} @else {
<label class="inv-form-field">
<span>Bill Date</span>
<input
type="date"
[(ngModel)]="newKycCapture.electricityBill.billDate"
(ngModelChange)="onElectricityBillDateChange(a)"
/>
</label>
@if (electricityBillTooOld) {
<p class="loi-kyc-error">Must not be older than 3 months.</p>
}
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.electricityBill.file ? 'Bill ✓' : 'Upload Bill' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewElectricityBillFile(a, $event)"
/>
</label>
@if (newKycCapture.electricityBill.file) {
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(newKycCapture.electricityBill.file)"
>
@if (newKycCapture.electricityBill.previewUrl) {
<img [src]="newKycCapture.electricityBill.previewUrl" alt="Electricity bill" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
}
</div>
@if (a.electricityBillSubmitted) {
<span class="loan-badge loan-badge--verified">Submitted</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
}
</div>
@if (a.secured) {
<div class="loi-kyc-card loi-kyc-card--checkbox">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
</label>
</div>
<div class="loi-kyc-card loi-kyc-card--checkbox">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
</label>
</div>
}
<div class="loi-kyc-card loi-kyc-card--checkbox">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
(e-KYC)
</label>
</div>
</div>
}
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.electricityBillSubmitted" /> Electricity Bill (Address Proof)
Submitted
</label>
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
(e-KYC)
</label>
</div>
} @else {
<!-- No real Fineract client linked to this application (whether it's a "New
Customer" intake or an "Existing Customer" mock application raised without a
real client pick) -- there's nowhere to read real KYC/documents from, so this
mirrors the client-creation KYC step's capture UX (front/back image + OTP) and
generic document upload, kept local-only/simulated. -->
<p class="loi-kyc-note">
Captured for this application — final Aadhaar/PAN verification happens when the customer's CBS client
profile is created.
</p>
<div class="loi-kyc-doc-grid">
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Aadhaar</span>
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.aadhaar.front ? 'Front ✓' : 'Upload Front' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewKycFile('aadhaar', 'front', $event)"
/>
</label>
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.aadhaar.back ? 'Back ✓' : 'Upload Back' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewKycFile('aadhaar', 'back', $event)"
/>
</label>
</div>
@if (newKycCapture.aadhaar.front || newKycCapture.aadhaar.back) {
<div class="loi-kyc-thumbs">
@if (newKycCapture.aadhaar.front) {
<button
type="button"
class="loi-kyc-thumb"
title="View front"
(click)="viewNewSingleDoc(newKycCapture.aadhaar.front)"
>
@if (newKycCapture.aadhaar.frontPreviewUrl) {
<img [src]="newKycCapture.aadhaar.frontPreviewUrl" alt="Aadhaar front" />
} @else {
<span class="loi-kyc-thumb-label">Front file</span>
}
</button>
}
@if (newKycCapture.aadhaar.back) {
<button
type="button"
class="loi-kyc-thumb"
title="View back"
(click)="viewNewSingleDoc(newKycCapture.aadhaar.back)"
>
@if (newKycCapture.aadhaar.backPreviewUrl) {
<img [src]="newKycCapture.aadhaar.backPreviewUrl" alt="Aadhaar back" />
} @else {
<span class="loi-kyc-thumb-label">Back file</span>
}
</button>
}
</div>
}
@if (newKycCapture.aadhaar.verified) {
<span class="loan-badge loan-badge--verified">Aadhaar Verified</span>
} @else {
<div class="loi-kyc-card-verify">
<input
type="text"
maxlength="12"
placeholder="Aadhaar Number"
[(ngModel)]="newKycCapture.aadhaar.number"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="sendNewAadhaarOtp()">Send OTP</button>
@if (newKycCapture.aadhaar.otpSent) {
<div class="loi-otp-digits">
@for (d of newKycCapture.aadhaar.otpDigits; track $index) {
<input
type="text"
maxlength="1"
[value]="d"
(input)="onNewAadhaarOtpDigit($index, $event)"
(keydown)="onNewAadhaarOtpKeydown($index, $event)"
/>
}
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="verifyNewAadhaarOtp(a)">
Verify
</button>
}
</div>
}
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">PAN</span>
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.pan.front ? 'Front ✓' : 'Upload Front' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewKycFile('pan', 'front', $event)"
/>
</label>
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.pan.back ? 'Back ✓' : 'Upload Back' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewKycFile('pan', 'back', $event)"
/>
</label>
</div>
@if (newKycCapture.pan.front || newKycCapture.pan.back) {
<div class="loi-kyc-thumbs">
@if (newKycCapture.pan.front) {
<button
type="button"
class="loi-kyc-thumb"
title="View front"
(click)="viewNewSingleDoc(newKycCapture.pan.front)"
>
@if (newKycCapture.pan.frontPreviewUrl) {
<img [src]="newKycCapture.pan.frontPreviewUrl" alt="PAN front" />
} @else {
<span class="loi-kyc-thumb-label">Front file</span>
}
</button>
}
@if (newKycCapture.pan.back) {
<button
type="button"
class="loi-kyc-thumb"
title="View back"
(click)="viewNewSingleDoc(newKycCapture.pan.back)"
>
@if (newKycCapture.pan.backPreviewUrl) {
<img [src]="newKycCapture.pan.backPreviewUrl" alt="PAN back" />
} @else {
<span class="loi-kyc-thumb-label">Back file</span>
}
</button>
}
</div>
}
@if (newKycCapture.pan.verified) {
<span class="loan-badge loan-badge--verified">PAN Verified</span>
} @else {
<div class="loi-kyc-card-verify">
<input
type="text"
maxlength="10"
placeholder="PAN Number"
[(ngModel)]="newKycCapture.pan.number"
/>
<button type="button" class="pg-btn pg-btn--primary" (click)="verifyNewPan(a)">Verify PAN</button>
</div>
}
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Income Proof</span>
<label class="inv-form-field">
<span>Employment Type</span>
<select
[(ngModel)]="newKycCapture.incomeProof.employmentType"
(ngModelChange)="onIncomeProofEmploymentTypeChange(a)"
>
<option value="SALARIED">Salaried</option>
<option value="BUSINESS">Business / Self-Employed</option>
</select>
</label>
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{
newKycCapture.incomeProof.file ? incomeProofDocLabel + ' ✓' : 'Upload ' + incomeProofDocLabel
}}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewIncomeProofFile(a, $event)"
/>
</label>
@if (newKycCapture.incomeProof.file) {
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(newKycCapture.incomeProof.file)"
>
@if (newKycCapture.incomeProof.previewUrl) {
<img [src]="newKycCapture.incomeProof.previewUrl" alt="Income proof" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
}
</div>
@if (a.incomeProofSubmitted) {
<span class="loan-badge loan-badge--verified">Submitted</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Bank Statement</span>
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.bankStatement.file ? 'Statement ✓' : 'Upload Bank Statement' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewBankStatementFile(a, $event)"
/>
</label>
@if (newKycCapture.bankStatement.file) {
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(newKycCapture.bankStatement.file)"
>
@if (newKycCapture.bankStatement.previewUrl) {
<img [src]="newKycCapture.bankStatement.previewUrl" alt="Bank statement" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
}
</div>
@if (a.bankStatementSubmitted) {
<span class="loan-badge loan-badge--verified">Submitted</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
</div>
<div class="loi-kyc-card">
<span class="loi-kyc-card-title">Electricity Bill (Address Proof)</span>
<label class="inv-form-field">
<span>Bill Date</span>
<input
type="date"
[(ngModel)]="newKycCapture.electricityBill.billDate"
(ngModelChange)="onElectricityBillDateChange(a)"
/>
</label>
@if (electricityBillTooOld) {
<p class="loi-kyc-error">Must not be older than 3 months.</p>
}
<div class="loi-kyc-card-files">
<label class="pg-btn pg-btn--ghost">
{{ newKycCapture.electricityBill.file ? 'Bill ✓' : 'Upload Bill' }}
<input
type="file"
accept="image/*,application/pdf"
hidden
(change)="onNewElectricityBillFile(a, $event)"
/>
</label>
@if (newKycCapture.electricityBill.file) {
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(newKycCapture.electricityBill.file)"
>
@if (newKycCapture.electricityBill.previewUrl) {
<img [src]="newKycCapture.electricityBill.previewUrl" alt="Electricity bill" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
}
</div>
@if (a.electricityBillSubmitted) {
<span class="loan-badge loan-badge--verified">Submitted</span>
} @else {
<span class="loan-badge loan-badge--missing">Missing</span>
}
</div>
@if (a.secured) {
<div class="loi-kyc-card loi-kyc-card--checkbox">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
</label>
</div>
<div class="loi-kyc-card loi-kyc-card--checkbox">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
</label>
</div>
}
<div class="loi-kyc-card loi-kyc-card--checkbox">
<label class="loan-checklist-item">
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
(e-KYC)
</label>
</div>
</div>
}
@if (a.monthlyIncome === 0) {
<label class="inv-form-field">
<span>Monthly Income (₹)</span>
@@ -263,6 +778,11 @@
<p class="pg-sub npa-report-note">
Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }}
</p>
@if (isRevolving(a)) {
<p class="pg-sub npa-report-note">
{{ a.productName }} is a revolving/renewable limit — repaid on demand, no fixed EMI.
</p>
}
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="setIncomeNow(a)">
Assess Income &amp; Calculate Eligible Amount
@@ -278,8 +798,54 @@
<dt>Eligible Loan Amount</dt>
<dd>₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Estimated EMI</dt>
<dd>
@if (isRevolving(a)) {
No fixed EMI (revolving limit)
} @else {
₹{{ emiAmountFor(a) | number: '1.0-0' }} / month
}
</dd>
</div>
</dl>
}
@if (!isRevolving(a)) {
<div class="loi-emi-breakdown">
<div class="loi-emi-breakdown-title">EMI Calculation Basis</div>
<dl class="loan-kv-grid">
<div class="loan-kv-field">
<dt>Principal (Loan Amount)</dt>
<dd>₹{{ a.requestedAmount | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Interest Rate</dt>
<dd>{{ a.interestRate }}% p.a. ({{ a.interestRate / 12 | number: '1.2-2' }}% / month)</dd>
</div>
<div class="loan-kv-field">
<dt>Tenure</dt>
<dd>{{ a.tenureMonths }} months</dd>
</div>
<div class="loan-kv-field">
<dt>Total Interest Payable</dt>
<dd>₹{{ totalInterestFor(a) | number: '1.0-0' }}</dd>
</div>
<div class="loan-kv-field">
<dt>Total Amount Payable</dt>
<dd>₹{{ totalPayableFor(a) | number: '1.0-0' }}</dd>
</div>
</dl>
<p class="pg-sub npa-report-note loi-emi-formula">
Reducing-balance EMI formula: EMI = [P × r × (1+r)<sup>n</sup>] / [(1+r)<sup>n</sup> − 1] — where P =
Principal, r = Monthly Interest Rate, n = Tenure in months. On P = ₹{{
a.requestedAmount | number: '1.0-0'
}}, r = {{ a.interestRate / 12 / 100 | number: '1.4-4' }}, n = {{ a.tenureMonths }} → EMI = ₹{{
emiAmountFor(a) | number: '1.0-0'
}}
/ month.
</p>
</div>
}
@if (a.coBorrowerRequired) {
<p class="pg-sub npa-report-note">Co-Borrower Required — {{ a.coBorrowerName }}</p>
}
@@ -303,6 +869,137 @@
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
<div class="loan-action-card">
@if (a.cibilScore === 0) {
<div class="loi-cic-subsection">
<div class="loi-cic-subsection-title">Customer Consent — Credit Bureau Check</div>
<p class="pg-sub npa-report-note">
RBI's Fair Practices Code requires the customer's signed consent before a credit bureau pull. Generate
the declaration letter below, get it signed by the customer, then upload the signed copy.
</p>
<div class="loan-action-row">
@if (a.channel === 'BRANCH') {
<button type="button" class="pg-btn pg-btn--ghost" (click)="printCicConsentLetter(a)">
Print Declaration Letter
</button>
} @else {
<button type="button" class="pg-btn pg-btn--ghost" (click)="downloadCicConsentLetter(a)">
Download Declaration Letter
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="shareCicConsentLetter(a)">
Share (Email / WhatsApp / Drive…)
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="emailCicConsentLetter(a)">Email</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="whatsappCicConsentLetter(a)">
WhatsApp
</button>
}
<label class="pg-btn pg-btn--ghost">
{{ cicConsentCapture.file ? 'Signed Declaration ✓' : 'Upload Signed Declaration' }}
<input type="file" accept="image/*,application/pdf" hidden (change)="onCicConsentFile(a, $event)" />
</label>
</div>
@if (cicConsentCapture.file) {
<div class="loi-kyc-thumbs">
<button
type="button"
class="loi-kyc-thumb"
title="View document"
(click)="viewNewSingleDoc(cicConsentCapture.file)"
>
@if (cicConsentCapture.previewUrl) {
<img [src]="cicConsentCapture.previewUrl" alt="Signed consent declaration" />
} @else {
<span class="loi-kyc-thumb-label">View file</span>
}
</button>
</div>
}
@if (a.cicConsentSigned) {
<span class="loan-badge loan-badge--verified">Signed Declaration Uploaded</span>
} @else {
<span class="loan-badge loan-badge--missing">Awaiting Signed Declaration</span>
}
</div>
<div class="loi-cic-subsection">
<div class="loi-cic-subsection-title">CIC Check Charge — ₹{{ cicCheckCharge }}</div>
@if (a.clientId && realDebitableAccounts.length > 0) {
<!-- Existing Customer with a real account -- pick which one to debit. This
pipeline never posts an actual Fineract transaction, so the "debit" is
recorded as a label for audit only. -->
<p class="pg-sub npa-report-note">
Select which account to debit the ₹{{ cicCheckCharge }} charge from:
</p>
<div class="loi-kyc-doc-grid">
@for (acc of realDebitableAccounts; track acc.id) {
<button
type="button"
class="loi-kyc-card loi-charge-account"
[class.loi-charge-account--selected]="a.cicChargeAccountLabel.startsWith(acc.accountNo)"
(click)="selectCicChargeAccount(a, acc)"
>
<span class="loi-kyc-card-title">{{ acc.kind }}</span>
<span class="loi-kyc-masked">{{ acc.accountNo }} — {{ acc.productName }}</span>
<span class="loi-kyc-masked">Balance: ₹{{ acc.accountBalance | number: '1.0-0' }}</span>
</button>
}
</div>
} @else {
<!-- New Customer, or an Existing Customer with no open account on file --
collect the charge the way a real-world merchant would: a payment link the
customer pays externally, with the reference they hand back recorded as
proof. -->
@if (a.clientId && loadingShareAccounts) {
<p class="pg-sub npa-report-note">Loading accounts…</p>
} @else {
@if (a.clientId) {
<p class="pg-sub npa-report-note">
No open savings/current account on file for this customer — collect the charge via payment link
instead.
</p>
}
@if (!a.cicPaymentLinkUrl || a.cicPaymentLinkUrl === '—') {
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="generateCicPaymentLink(a)">
Generate Payment Link
</button>
</div>
} @else {
<p class="loi-kyc-masked">{{ a.cicPaymentLinkUrl }}</p>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="copyCicPaymentLink(a)">
Copy Link
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="emailCicPaymentLink(a)">
Email
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="whatsappCicPaymentLink(a)">
WhatsApp
</button>
</div>
@if (!a.cicChargeCollected) {
<label class="inv-form-field">
<span>Payment Reference No. (from customer)</span>
<input type="text" [(ngModel)]="cicPaymentRefInput" placeholder="e.g. UTR / Txn ID" />
</label>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--ghost" (click)="confirmCicPaymentReceived(a)">
Confirm Payment Received
</button>
</div>
}
}
}
}
@if (a.cicChargeCollected) {
<span class="loan-badge loan-badge--verified">
Charge Collected —
{{ a.cicChargeMode === 'ACCOUNT_DEBIT' ? a.cicChargeAccountLabel : 'Ref ' + a.cicPaymentReference }}
</span>
} @else {
<span class="loan-badge loan-badge--missing">Charge Not Collected</span>
}
</div>
<label class="inv-form-field">
<span>Bureau</span>
<select [(ngModel)]="a.cicBureau">
@@ -312,10 +1009,21 @@
</select>
</label>
<div class="loan-action-row">
<button type="button" class="pg-btn pg-btn--primary" (click)="checkCreditScoreNow(a)">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!a.cicConsentSigned || !a.cicChargeCollected"
(click)="checkCreditScoreNow(a)"
>
Check Credit Score
</button>
</div>
@if (!a.cicConsentSigned || !a.cicChargeCollected) {
<p class="pg-sub npa-report-note">
Print/share the declaration and get it signed, and collect the ₹{{ cicCheckCharge }} check charge,
before checking the credit score.
</p>
}
} @else {
<dl class="loan-kv-grid" style="padding: 0 0 14px">
<div class="loan-kv-field">
@@ -876,3 +1584,5 @@
</div>
}
</div>
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
@@ -10,8 +10,17 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { Subscription, forkJoin } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { jsPDF } from 'jspdf';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClientsService } from '../../clients/clients.service';
import { SharesService } from '../../shares/shares.service';
import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service';
import { DocumentPreviewDialogComponent } from '../../clients/shared/document-preview-dialog/document-preview-dialog.component';
import { ShareAccountDetailsDialogComponent } from './share-account-details-dialog/share-account-details-dialog.component';
import {
ApprovalField,
BRANCHES,
@@ -32,6 +41,7 @@ import {
checkCreditScore,
cibilBand,
CIC_BUREAUS,
CIC_CHECK_CHARGE,
clearPersonalDiscussionAndProceed,
confirmChequeAch,
decideApproval,
@@ -39,6 +49,7 @@ import {
flagDiscrepancy,
isRevolvingProduct,
markCustomerSigned,
moveToStage,
progressPct,
pushFieldInquiry,
resolveDiscrepancyAndProceed,
@@ -53,6 +64,30 @@ import {
uploadCicReport
} from '../loan-origination-data';
interface NewKycDocSide {
front: File | null;
back: File | null;
frontPreviewUrl: string | null;
backPreviewUrl: string | null;
number: string;
verified: boolean;
}
interface NewKycSingleDoc {
file: File | null;
previewUrl: string | null;
}
type EmploymentType = 'SALARIED' | 'BUSINESS';
interface NewKycCaptureState {
aadhaar: NewKycDocSide & { otpSent: boolean; otpDigits: string[] };
pan: NewKycDocSide;
incomeProof: NewKycSingleDoc & { employmentType: EmploymentType };
bankStatement: NewKycSingleDoc;
electricityBill: NewKycSingleDoc & { billDate: string | null };
}
/**
* A single application's full sanctioning journey — the stage stepper, the current stage's
* action card (checklist / CIC upload / Field Investigation / Committee-Risk-Legal approval /
@@ -91,6 +126,10 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
monthlyIncomeInput: number | null = null;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
currentUserName = 'Branch User';
/** True for a Head Office login, which can open any branch's application — a branch-level
@@ -101,8 +140,85 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService);
private readonly sharesService = inject(SharesService);
private readonly kycUploadHelper = inject(KycUploadHelperService);
private readonly dialog = inject(MatDialog);
private sub?: Subscription;
/** Real Fineract share accounts for this application's linked client (only populated when
* LoanApplication.clientId is set -- i.e. raised against a real Existing Customer pick, not
* the mock isShareMember/shareCertNo/shareMemberSince fields every other application uses). */
realShareAccounts: any[] = [];
loadingShareAccounts = false;
/** The linked client's own open savings/current accounts -- offered as the CIC check charge's
* debit source. Populated by the same call as realShareAccounts above. */
realDebitableAccounts: any[] = [];
/** Real KYC record + generic uploaded documents for this application's linked client --
* only populated when LoanApplication.clientId is set, same real-vs-mock split as Share
* Member above. */
realKyc: any = null;
realDocuments: any[] = [];
loadingKyc = false;
/** New-Customer applications (no real client yet) get a client-creation-style capture UI
* instead -- front/back document images + an OTP box -- but since no real client/kycId
* exists yet, Aadhaar/PAN can't actually be verified against Fineract here (every one of
* those APIs requires a real clientId+kycId). This stays purely local/simulated, same as
* every other field on a New Customer application in this mock pipeline. */
newKycCapture: NewKycCaptureState = this.emptyNewKycCapture();
/** The customer's signed copy of the CIC consent declaration -- local-only, same as the KYC
* capture above. Uploading this is what actually flips `cicConsentSigned`, so "Check Credit
* Score" can't be reached on a self-ticked checkbox with nothing behind it. */
cicConsentCapture: NewKycSingleDoc = { file: null, previewUrl: null };
private emptyNewKycCapture(): NewKycCaptureState {
return {
aadhaar: {
front: null,
back: null,
frontPreviewUrl: null,
backPreviewUrl: null,
number: '',
otpSent: false,
otpDigits: [
'',
'',
'',
'',
'',
''
],
verified: false
},
pan: { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false },
incomeProof: { file: null, previewUrl: null, employmentType: 'SALARIED' },
bankStatement: { file: null, previewUrl: null },
electricityBill: { file: null, previewUrl: null, billDate: null }
};
}
/** Every uploaded-file preview in this local-only capture UI is an object URL -- release them
* before rebuilding the state (a new application selected) or the component is torn down, so
* a long session doesn't leak blob URLs. */
private revokeNewKycPreviews(state: NewKycCaptureState): void {
[
state.aadhaar.frontPreviewUrl,
state.aadhaar.backPreviewUrl,
state.pan.frontPreviewUrl,
state.pan.backPreviewUrl,
state.incomeProof.previewUrl,
state.bankStatement.previewUrl,
state.electricityBill.previewUrl,
this.cicConsentCapture.previewUrl
]
.filter((url): url is string => !!url)
.forEach((url) => URL.revokeObjectURL(url));
}
ngOnInit(): void {
const creds = this.auth.getCredentials();
this.currentUserName = creds?.username || 'Branch User';
@@ -122,11 +238,372 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
this.revokeNewKycPreviews(this.newKycCapture);
this.newKycCapture = this.emptyNewKycCapture();
this.cicConsentCapture = { file: null, previewUrl: null };
this.cicPaymentRefInput = '';
this.loadRealClientAccounts();
this.loadRealKyc();
});
}
/** Only applications raised against a real Existing Customer (clientId set) get a real
* membership check and a real account list to debit the CIC charge from -- every other
* application keeps using the mock isShareMember fields / the payment-link flow. One call
* to `/clients/{id}/accounts` covers both, since it returns shares, savings and current
* accounts together. */
private loadRealClientAccounts(): void {
this.realShareAccounts = [];
this.realDebitableAccounts = [];
const clientId = this.application?.clientId;
if (!clientId) {
return;
}
this.loadingShareAccounts = true;
this.clientsService
.getClientAccountData(String(clientId))
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.realShareAccounts = data?.shareAccounts ?? [];
const isClosed = (acc: any) => [
'savingsAccountStatusType.withdrawn.by.applicant',
'savingsAccountStatusType.closed',
'savingsAccountStatusType.pre.mature.closure',
'savingsAccountStatusType.rejected'
].includes(acc?.status?.code);
const savings = (data?.savingsAccounts ?? [])
.filter((acc: any) => acc?.depositType?.value === 'Savings' && !isClosed(acc))
.map((acc: any) => ({ ...acc, kind: 'Savings' }));
const current = (data?.currentAccounts ?? [])
.filter((acc: any) => !isClosed(acc))
.map((acc: any) => ({ ...acc, kind: 'Current' }));
this.realDebitableAccounts = [
...savings,
...current
];
this.loadingShareAccounts = false;
});
}
viewShareAccountDetails(shareAccountId: number): void {
this.sharesService
.getSharesAccountData(String(shareAccountId), true)
.pipe(catchError(() => of(null)))
.subscribe((account: any) => {
if (!account) {
return;
}
this.dialog.open(ShareAccountDetailsDialogComponent, {
data: { account },
width: '520px'
});
});
}
// -------------------- KYC & Documents — real client data --------------------
/** Pulls the linked client's real KYC record + generic uploaded documents, then syncs the
* mock checklist booleans that the rest of this screen's stage-gating logic (allKycDone,
* committeeReportsReady, etc.) already reads -- so a real, already-verified customer's
* application progresses exactly as if an officer had ticked the boxes, while the template
* shows the real verified/masked/viewable data instead of a plain checkbox. */
private loadRealKyc(): void {
this.realKyc = null;
this.realDocuments = [];
const a = this.application;
const clientId = a?.clientId;
if (!a || !clientId) {
return;
}
this.loadingKyc = true;
forkJoin({
kyc: this.clientsService.getClientKyc(String(clientId), true).pipe(catchError(() => of(null))),
documents: this.clientsService.getClientDocuments(String(clientId)).pipe(catchError(() => of([])))
}).subscribe(({ kyc, documents }: { kyc: any; documents: any[] }) => {
this.realKyc = kyc;
this.realDocuments = documents ?? [];
this.loadingKyc = false;
if (kyc) {
a.aadhaarVerified = !!kyc.aadhaarVerified;
a.panVerified = !!kyc.panVerified;
}
a.incomeProofSubmitted = !!this.findDocument('income') || a.incomeProofSubmitted;
a.bankStatementSubmitted = !!this.findDocument('bank statement') || a.bankStatementSubmitted;
a.electricityBillSubmitted =
!!this.findDocument('electricity') || !!this.findDocument('address proof') || a.electricityBillSubmitted;
});
}
/** The generic document (Income Proof / Bank Statement / Electricity Bill) whose name or
* description matches the given keyword, if the client has one on file in Fineract. */
findDocument(keyword: string): any {
const k = keyword.toLowerCase();
return this.realDocuments.find(
(d) => (d.name || '').toLowerCase().includes(k) || (d.description || '').toLowerCase().includes(k)
);
}
/** Masks a PAN (e.g. ABCDE1234F) down to just the last 4 characters, matching the masking
* convention Fineract's KYC API already applies to Aadhaar (aadhaarNumberMasked). */
maskPan(pan: string | undefined): string {
if (!pan || pan.length <= 4) {
return pan || '—';
}
return 'X'.repeat(pan.length - 4) + pan.slice(-4);
}
/** Resolves and shows the actual Aadhaar/PAN identification document image for the linked
* real client -- same client_identifiers lookup client-kyc-step.component.ts uses. */
viewKycIdentifierDoc(kind: 'aadhaar' | 'pan'): void {
const clientId = this.application?.clientId;
if (!clientId) {
return;
}
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
this.clientsService.getClientIdentifiers(String(clientId)).subscribe({
next: (identifiers: any[]) => {
if (!identifiers || identifiers.length === 0) {
return;
}
const sorted = [...identifiers].sort((x: any, y: any) => (y?.id ?? 0) - (x?.id ?? 0));
const match = sorted.find((i: any) => cleanStr(i?.documentType?.name).includes(kind));
if (!match?.id) {
return;
}
const identifierId = match.id.toString();
this.clientsService.getClientIdentificationDocuments(identifierId).subscribe({
next: (docs: any[]) => {
if (!docs || docs.length === 0) {
return;
}
const doc = [...docs].sort((x: any, y: any) => (y?.id ?? 0) - (x?.id ?? 0))[0];
this.clientsService.downloadClientIdentificationDocument(identifierId, doc.id.toString()).subscribe({
next: (blob: Blob) => {
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.name || doc.fileName, fileName: doc.fileName }
});
},
error: () => {}
});
},
error: () => {}
});
},
error: () => {}
});
}
/** Shows a generic uploaded document (Income Proof / Bank Statement / Electricity Bill). */
viewGenericDocument(doc: any): void {
const clientId = this.application?.clientId;
if (!clientId || !doc?.id) {
return;
}
this.clientsService.downloadClientDocument(String(clientId), doc.id.toString()).subscribe({
next: (blob: Blob) => {
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.name || doc.fileName, fileName: doc.fileName }
});
},
error: () => {}
});
}
// -------------------- KYC & Documents — new customer (local-only capture) --------------------
/** New-Customer applications have no real Fineract client yet, so this mirrors the
* client-creation KYC step's capture UX (front/back image + crop, OTP entry) without calling
* any of its real Aadhaar/PAN verification endpoints -- every one of those requires an
* already-created clientId + kycId. Purely local state, same as the rest of this mock
* pipeline's New Customer flow. */
async onNewKycFile(doc: 'aadhaar' | 'pan', side: 'front' | 'back', event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
const cropped = await this.kycUploadHelper.pickWithCrop(file, `${doc === 'aadhaar' ? 'Aadhaar' : 'PAN'} — ${side}`);
if (!cropped) {
return;
}
const target = this.newKycCapture[doc];
const previewKey = side === 'front' ? 'frontPreviewUrl' : 'backPreviewUrl';
if (target[previewKey]) {
URL.revokeObjectURL(target[previewKey]);
}
target[side] = cropped;
target[previewKey] = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
}
sendNewAadhaarOtp(): void {
if (this.newKycCapture.aadhaar.number.trim().length !== 12) {
return;
}
this.newKycCapture.aadhaar.otpSent = true;
this.newKycCapture.aadhaar.otpDigits = [
'',
'',
'',
'',
'',
''
];
}
onNewAadhaarOtpDigit(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '');
this.newKycCapture.aadhaar.otpDigits[index] = val.charAt(0) || '';
input.value = this.newKycCapture.aadhaar.otpDigits[index];
if (this.newKycCapture.aadhaar.otpDigits[index] && index < 5) {
const next = input.parentElement?.querySelectorAll('input')[index + 1];
(next as HTMLInputElement)?.focus();
}
}
onNewAadhaarOtpKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.newKycCapture.aadhaar.otpDigits[index] && index > 0) {
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
if (inputs) {
this.newKycCapture.aadhaar.otpDigits[index - 1] = '';
(inputs[index - 1] as HTMLInputElement).value = '';
(inputs[index - 1] as HTMLInputElement).focus();
}
}
}
/** Local-only "verification" -- flips the badge once every OTP box is filled, and also flips
* the application's real `aadhaarVerified` flag, since that's what actually gates "Proceed to
* CIC / Credit Bureau Check" (allKycDone) -- the capture-UI badge alone never did. No real
* Aadhaar API is reachable here (no clientId exists yet for this application). */
verifyNewAadhaarOtp(a: LoanApplication): void {
if (this.newKycCapture.aadhaar.otpDigits.every((d) => d !== '')) {
this.newKycCapture.aadhaar.verified = true;
a.aadhaarVerified = true;
}
}
/** Local-only "verification" -- flips the badge once the PAN looks well-formed, and also flips
* the application's real `panVerified` flag for the same reason as Aadhaar above. No real PAN
* API is reachable here (no clientId exists yet for this application). */
verifyNewPan(a: LoanApplication): void {
if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.newKycCapture.pan.number.trim().toUpperCase())) {
this.newKycCapture.pan.verified = true;
a.panVerified = true;
}
}
/** Salaried customers prove income with Form 16 / salary slips; business/self-employed
* customers prove it with their Income Tax Return -- so the required document changes with
* the employment type the officer selects, rather than asking for a generic "income proof". */
get incomeProofDocLabel(): string {
return this.newKycCapture.incomeProof.employmentType === 'BUSINESS'
? 'ITR (Income Tax Return)'
: 'Form 16 / Salary Slip';
}
onIncomeProofEmploymentTypeChange(a: LoanApplication): void {
// Switching employment type invalidates whatever was already picked under the other type's
// label -- clear it so the officer re-uploads the document that actually matches.
if (this.newKycCapture.incomeProof.previewUrl) {
URL.revokeObjectURL(this.newKycCapture.incomeProof.previewUrl);
}
this.newKycCapture.incomeProof.file = null;
this.newKycCapture.incomeProof.previewUrl = null;
a.incomeProofSubmitted = false;
}
async onNewIncomeProofFile(a: LoanApplication, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, this.incomeProofDocLabel);
if (!file) {
return;
}
this.setSingleDoc(this.newKycCapture.incomeProof, file);
a.incomeProofSubmitted = true;
}
async onNewBankStatementFile(a: LoanApplication, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, 'Bank Statement');
if (!file) {
return;
}
this.setSingleDoc(this.newKycCapture.bankStatement, file);
a.bankStatementSubmitted = true;
}
/** Address-proof bills go stale -- RBI KYC practice treats a utility bill older than 3
* months as no longer good evidence of the customer's current address, so this blocks the
* checklist item until a recent-enough date is entered. */
get electricityBillTooOld(): boolean {
const billDate = this.newKycCapture.electricityBill.billDate;
if (!billDate) {
return false;
}
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 3);
return new Date(billDate) < cutoff;
}
onElectricityBillDateChange(a: LoanApplication): void {
this.syncElectricityBillSubmitted(a);
}
async onNewElectricityBillFile(a: LoanApplication, event: Event): Promise<void> {
const file = await this.pickSingleNewKycFile(event, 'Electricity Bill');
if (!file) {
return;
}
this.setSingleDoc(this.newKycCapture.electricityBill, file);
this.syncElectricityBillSubmitted(a);
}
private syncElectricityBillSubmitted(a: LoanApplication): void {
a.electricityBillSubmitted = !!this.newKycCapture.electricityBill.file && !this.electricityBillTooOld;
}
private setSingleDoc(doc: NewKycSingleDoc, file: File): void {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
}
doc.file = file;
doc.previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
}
private async pickSingleNewKycFile(event: Event, title: string): Promise<File | null> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return null;
}
return this.kycUploadHelper.pickWithCrop(file, title);
}
/** Opens whichever local, not-yet-uploaded-anywhere file the officer picked for one of the
* New Customer capture blocks -- straight from the in-memory File, no backend round trip. */
viewNewSingleDoc(file: File | null): void {
if (!file) {
return;
}
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob: file, title: file.name, fileName: file.name }
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
this.revokeNewKycPreviews(this.newKycCapture);
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
goBack(): void {
@@ -134,6 +611,14 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
// -------------------- Stepper --------------------
/** A stage reads as "done" (green check) once the application has ever reached past it,
* regardless of whether `currentStage` is now sitting before or after it -- jumping back for
* a correction (see moveToStage) doesn't make an already-completed stage look unfinished. */
private isStageDone(a: LoanApplication, stage: OriginationStage): boolean {
const idx = stageIndex(stage);
return idx !== stageIndex(a.currentStage) && idx <= a.furthestStageIndex;
}
stepStatusClass(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
@@ -143,7 +628,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
return 'loan-step loan-step--hold';
}
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
if (this.isStageDone(a, stage) || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
return 'loan-step loan-step--done';
}
if (idx === curIdx) {
@@ -154,8 +639,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
stepMarker(a: LoanApplication, stage: OriginationStage): string {
const idx = stageIndex(stage);
const curIdx = stageIndex(a.currentStage);
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
if (this.isStageDone(a, stage) || (idx === stageIndex(a.currentStage) && a.overallStatus === 'DISBURSED')) {
return '✓';
}
return String(idx + 1);
@@ -186,9 +670,270 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
readonly cicBureaus = CIC_BUREAUS;
checkCreditScoreNow(a: LoanApplication): void {
// RBI's Fair Practices Code requires the customer's consent before a bureau pull, and the
// bureau's per-pull fee has to actually be collected first -- the button is disabled in the
// template for the same reasons, this is just the backstop.
if (!a.cicConsentSigned || !a.cicChargeCollected) {
return;
}
checkCreditScore(a, this.currentUserName);
}
/** Uploading the customer's signed copy of the consent declaration is what actually unlocks
* "Check Credit Score" -- a self-ticked checkbox proved nothing, this at least proves the
* officer has a scan of a signed paper in hand. */
async onCicConsentFile(a: LoanApplication, event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
const cropped = await this.kycUploadHelper.pickWithCrop(file, 'Signed Consent Declaration');
if (!cropped) {
return;
}
this.setSingleDoc(this.cicConsentCapture, cropped);
a.cicConsentSigned = true;
}
// -------------------- CIC check charge --------------------
readonly cicCheckCharge = CIC_CHECK_CHARGE;
/** Reference number keyed in against a payment-link collection -- cleared after each confirm
* so a stale reference can't accidentally get reused on a different application. */
cicPaymentRefInput = '';
/** Existing Customer -- the officer picks one of the client's own real accounts; this pipeline
* never posts an actual Fineract transaction, so the "debit" is recorded as a label only. */
selectCicChargeAccount(a: LoanApplication, account: any): void {
a.cicChargeMode = 'ACCOUNT_DEBIT';
a.cicChargeAccountLabel = `${account.accountNo} — ${account.kind} (${account.productName})`;
a.cicChargeCollected = true;
this.toast(`Rs. ${this.cicCheckCharge} CIC check charge marked for debit from ${account.accountNo}.`);
}
/** New Customer -- no account exists yet, so this mirrors a real payment-gateway collection
* link (illustrative only -- nothing is actually charged in this mock pipeline). */
generateCicPaymentLink(a: LoanApplication): void {
a.cicChargeMode = 'PAYMENT_LINK';
a.cicPaymentLinkUrl = `https://pay.cbsnexora.local/cic-charge/${a.applicationNo}?amount=${this.cicCheckCharge}`;
}
copyCicPaymentLink(a: LoanApplication): void {
const fallback = (): void => {
const ta = document.createElement('textarea');
ta.value = a.cicPaymentLinkUrl;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {
/* ignore */
}
document.body.removeChild(ta);
};
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(a.cicPaymentLinkUrl).catch(fallback);
} else {
fallback();
}
this.toast('Payment link copied to clipboard.');
}
emailCicPaymentLink(a: LoanApplication): void {
const subject = `Credit Bureau Check Charge — ${a.applicationNo}`;
const body = `Dear ${a.applicantName},\n\nPlease pay the credit bureau check charge of Rs. ${this.cicCheckCharge} for your loan application ${a.applicationNo} using the link below:\n${a.cicPaymentLinkUrl}\n\nRegards,\n${this.currentUserName}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
whatsappCicPaymentLink(a: LoanApplication): void {
const msg = `Dear ${a.applicantName}, please pay the credit bureau check charge of Rs. ${this.cicCheckCharge} for your loan application ${a.applicationNo} using this link: ${a.cicPaymentLinkUrl}`;
window.open(`https://wa.me/?text=${encodeURIComponent(msg)}`, '_blank', 'noopener');
}
/** The reference the customer hands back after paying externally is the only proof this
* pipeline has that the payment actually happened -- that's what flips `cicChargeCollected`. */
confirmCicPaymentReceived(a: LoanApplication): void {
if (!this.cicPaymentRefInput.trim()) {
return;
}
a.cicPaymentReference = this.cicPaymentRefInput.trim();
a.cicChargeCollected = true;
this.cicPaymentRefInput = '';
this.toast('Payment reference recorded — CIC check charge marked as collected.');
}
// -------------------- CIC consent declaration letter --------------------
private buildCicConsentPdf(a: LoanApplication): jsPDF {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const contentWidth = pageWidth - margin * 2;
let y = 20;
const addLine = (label: string, value: string, yPos: number): number => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.text(label, margin, yPos);
doc.setFont('helvetica', 'normal');
doc.text(value || '—', margin + 55, yPos);
return yPos + 7;
};
const drawHr = (yPos: number): number => {
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.3);
doc.line(margin, yPos, pageWidth - margin, yPos);
return yPos + 5;
};
doc.setFillColor(15, 23, 42);
doc.rect(0, 0, pageWidth, 30, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text('Credit Information Report — Consent Declaration', pageWidth / 2, 13, { align: 'center' });
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(
'As per the Credit Information Companies (Regulation) Act, 2005 and RBI Fair Practices Code',
pageWidth / 2,
21,
{
align: 'center'
}
);
doc.setTextColor(0, 0, 0);
y = 40;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Branch: ${a.branchName}`, margin, y);
doc.text(`Date: ${todayDDMMYYYY()}`, pageWidth - margin, y, { align: 'right' });
y += 8;
y = drawHr(y);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Application Details', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
y = addLine('Applicant Name:', a.applicantName, y);
y = addLine('Application No:', a.applicationNo, y);
y = addLine('Product:', a.productName, y);
y = addLine('Requested Amount:', `Rs. ${a.requestedAmount.toLocaleString('en-IN')}`, y);
y = addLine('Credit Bureau:', a.cicBureau, y);
y += 3;
y = drawHr(y);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(16, 116, 185);
doc.text('Declaration', margin, y);
doc.setTextColor(0, 0, 0);
y += 8;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9.5);
const declarationText = `I, ${a.applicantName}, having applied for ${a.productName} (Application No. ${a.applicationNo}) with this Bank, hereby give my free and informed consent to the Bank to access, obtain and verify my credit information from ${a.cicBureau} and/or any other Credit Information Company registered under the Credit Information Companies (Regulation) Act, 2005, for the purpose of assessing my creditworthiness in connection with this loan application.
I confirm that:
1. This consent is given voluntarily and without any coercion.
2. I understand my credit information will be used solely for evaluating this loan application.
3. I have been informed of my rights under the RBI Fair Practices Code, including the right to access my own credit information report.
4. This declaration does not obligate the Bank to sanction the loan applied for.`;
const lines = doc.splitTextToSize(declarationText, contentWidth);
doc.text(lines, margin, y);
y += lines.length * 4.5 + 12;
y = drawHr(y);
y += 5;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('________________________', margin, y + 15);
doc.text('Signature of Customer', margin, y + 21);
doc.text(`Name: ${a.applicantName}`, margin, y + 27);
doc.text(`Date: _______________`, margin, y + 33);
doc.text(`Place: _______________`, margin, y + 39);
doc.text('________________________', pageWidth - margin - 50, y + 15);
doc.text('Authorized Bank Official', pageWidth - margin - 50, y + 21);
doc.text('Name: _______________', pageWidth - margin - 50, y + 27);
doc.text('Designation: _______________', pageWidth - margin - 50, y + 33);
const footerY = doc.internal.pageSize.getHeight() - 10;
doc.setFontSize(7);
doc.setTextColor(150, 150, 150);
doc.text(
'This is a system-generated document. Generated from CBS Nexora — Loan Origination Module.',
pageWidth / 2,
footerY,
{
align: 'center'
}
);
return doc;
}
private cicConsentFileName(a: LoanApplication): string {
return `CIC_Consent_${a.applicationNo}.pdf`;
}
/** Branch channel -- the officer has the customer in front of them, so the declaration opens
* straight into the print dialog rather than needing an extra "download, then print" hop. */
printCicConsentLetter(a: LoanApplication): void {
const doc = this.buildCicConsentPdf(a);
doc.autoPrint();
window.open(doc.output('bloburl'), '_blank');
}
downloadCicConsentLetter(a: LoanApplication): void {
this.buildCicConsentPdf(a).save(this.cicConsentFileName(a));
}
/** BC / Self-Apply channel -- there's no customer standing at a branch printer, so this hands
* the declaration to the OS share sheet (Email, WhatsApp, Drive, etc.) when the browser
* supports sharing files; falls back to a plain download if it doesn't. */
async shareCicConsentLetter(a: LoanApplication): Promise<void> {
const blob = this.buildCicConsentPdf(a).output('blob');
const file = new File([blob], this.cicConsentFileName(a), { type: 'application/pdf' });
const nav = navigator as Navigator & { canShare?: (data: ShareData) => boolean };
if (nav.canShare?.({ files: [file] })) {
try {
await navigator.share({
title: 'Credit Bureau Check — Consent Declaration',
text: `Consent declaration for ${a.applicantName} (${a.applicationNo})`,
files: [file]
});
return;
} catch {
// User cancelled the share sheet, or the OS declined -- fall through to a plain download
// so the officer still has the file to send manually.
}
}
this.downloadCicConsentLetter(a);
}
/** Email/WhatsApp can't attach a file via a plain link -- these open a pre-filled compose
* window and rely on the officer attaching the already-downloaded PDF. */
emailCicConsentLetter(a: LoanApplication): void {
const subject = `Credit Bureau Check Consent — ${a.applicationNo}`;
const body = `Dear ${a.applicantName},\n\nPlease find attached the consent declaration for the credit bureau check on your loan application ${a.applicationNo}. Kindly sign and return it.\n\nRegards,\n${this.currentUserName}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
whatsappCicConsentLetter(a: LoanApplication): void {
const msg = `Dear ${a.applicantName}, please find the consent declaration for the credit bureau check on your loan application ${a.applicationNo}. Kindly sign and share it back with us.`;
window.open(`https://wa.me/?text=${encodeURIComponent(msg)}`, '_blank', 'noopener');
}
sanctioningAuthorityOf(a: LoanApplication): string {
return sanctioningAuthority(a.requestedAmount);
}
@@ -198,6 +943,26 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
proceedToNextStage(a, this.currentUserName, remarks);
}
/** Any stage the application has ever reached is clickable -- whether it's currently behind or
* ahead of `currentStage` -- except the current stage itself. So going back for a correction
* and then wanting to jump forward again to where you actually were still works; only a stage
* that's never been reached (genuinely still ahead) is blocked, since skipping into it would
* bypass whatever it still gates. */
canJumpToStage(a: LoanApplication, stage: OriginationStage): boolean {
return a.overallStatus === 'IN_PROGRESS' && stage !== a.currentStage && stageIndex(stage) <= a.furthestStageIndex;
}
/** Jumps the application to any stage it has already reached, for review/correction. Only
* `currentStage` moves -- every checkbox, upload, score and approval already recorded stays
* exactly as it was, and `furthestStageIndex` (how far the application actually got) is left
* untouched either way. */
goToStage(a: LoanApplication, stage: OriginationStage): void {
if (!this.canJumpToStage(a, stage)) {
return;
}
moveToStage(a, stage, this.currentUserName);
}
// -------------------- KYC & Documents --------------------
/** Live preview of what Eligible Loan Amount would be for whatever's currently typed into the
* Monthly Income box — before the officer actually commits it via "Assess Income". */
@@ -0,0 +1,62 @@
<!--
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/.
-->
<h2 mat-dialog-title>Share Account Details</h2>
<mat-dialog-content class="sadd-content">
<div class="sadd-grid">
<div class="sadd-field">
<span class="sadd-label">Account No.</span>
<span class="sadd-value">{{ account.accountNo ?? '—' }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Product</span>
<span class="sadd-value">{{ account.productName ?? account.productId?.name ?? '—' }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Status</span>
<span class="sadd-value">{{ statusLabel }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Currency</span>
<span class="sadd-value">{{ account.currency?.name ?? account.currency?.code ?? '—' }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Approved Shares</span>
<span class="sadd-value">{{ account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0 }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Pending-for-Approval Shares</span>
<span class="sadd-value">{{
account.summary?.totalPendingForApprovalShares ?? account.totalPendingForApprovalShares ?? 0
}}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Submitted On</span>
<span class="sadd-value">{{ submittedDate }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Activated On</span>
<span class="sadd-value">{{ activatedDate }}</span>
</div>
@if (account.savingsAccountNumber) {
<div class="sadd-field">
<span class="sadd-label">Linked Savings A/c (Dividend Posting)</span>
<span class="sadd-value">{{ account.savingsAccountNumber }}</span>
</div>
}
@if (account.externalId) {
<div class="sadd-field">
<span class="sadd-label">External ID</span>
<span class="sadd-value">{{ account.externalId }}</span>
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,37 @@
/**
* 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/.
*/
.sadd-content {
min-width: 420px;
}
.sadd-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px 20px;
}
.sadd-field {
display: flex;
flex-direction: column;
}
.sadd-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.sadd-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,61 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
/**
* Read-only popup showing a real Fineract share account's details, opened from the Loan
* Origination application-detail page's "Share Member" section for an application that's linked
* to a real existing client (see `LoanApplication.clientId`) -- lets the officer confirm the
* customer's actual membership/holding without leaving the loan sanctioning screen.
*/
@Component({
selector: 'mifosx-share-account-details-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
templateUrl: './share-account-details-dialog.component.html',
styleUrls: ['./share-account-details-dialog.component.scss']
})
export class ShareAccountDetailsDialogComponent {
data = inject(MAT_DIALOG_DATA);
get account(): any {
return this.data?.account ?? {};
}
get statusLabel(): string {
return this.account?.status?.value ?? this.account?.status?.code ?? '—';
}
get activatedDate(): string {
return this.formatDate(this.account?.timeline?.activatedOnDate ?? this.account?.timeline?.activatedDate);
}
get submittedDate(): string {
return this.formatDate(this.account?.timeline?.submittedOnDate);
}
private formatDate(value: any): string {
if (!Array.isArray(value) || value.length < 3) {
return '—';
}
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
}
}
@@ -0,0 +1,61 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container lec-container">
<mat-card>
<mat-card-content>
<h2 class="lec-title">RBI Loan Eligibility Checklist</h2>
<p class="lec-subtitle">
Run the bank's RBI-circular-based eligibility checks for any client, independently of the loan application
itself.
</p>
@if (!selectedClient) {
<div class="lec-search">
<mat-form-field class="lec-search-field">
<mat-label>Search Client (name, account no., mobile...)</mat-label>
<input matInput [(ngModel)]="searchText" (ngModelChange)="onSearch($event)" />
</mat-form-field>
@if (searching) {
<p class="lec-hint">Searching…</p>
} @else if (searchText.length >= 2 && searchResults.length === 0) {
<p class="lec-hint">No clients found.</p>
}
@if (searchResults.length > 0) {
<div class="lec-results">
@for (client of searchResults; track client.id) {
<button type="button" class="lec-result-row" (click)="selectClient(client)">
<span class="lec-result-name">{{ client.displayName }}</span>
<span class="lec-result-meta">{{ client.accountNo ?? client.officeName ?? '' }}</span>
</button>
}
</div>
}
</div>
} @else {
<div class="lec-selected-client">
<div>
<span class="lec-selected-label">Client</span>
<span class="lec-selected-name">{{ selectedClient.displayName }}</span>
</div>
<button type="button" mat-button (click)="changeClient()">Change Client</button>
</div>
<mat-form-field class="lec-amount-field">
<mat-label>Proposed Loan Amount (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="loanAmount" />
</mat-form-field>
<mifosx-rbi-compliance-step [clientId]="selectedClient.id" [loanAmount]="loanAmount">
</mifosx-rbi-compliance-step>
}
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,104 @@
/**
* 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/.
*/
.lec-container {
max-width: 900px;
}
.lec-title {
margin: 0 0 4px;
}
.lec-subtitle {
color: #64748b;
font-size: 13.5px;
margin: 0 0 20px;
}
.lec-search {
max-width: 480px;
}
.lec-search-field {
width: 100%;
}
.lec-hint {
color: #94a3b8;
font-size: 12.5px;
margin: -8px 0 0;
}
.lec-results {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.lec-result-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 14px;
background: #fff;
border: none;
border-bottom: 1px solid #f1f5f9;
text-align: left;
cursor: pointer;
font: inherit;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8fafc;
}
}
.lec-result-name {
font-weight: 600;
color: #0f172a;
}
.lec-result-meta {
color: #94a3b8;
font-size: 12.5px;
}
.lec-selected-client {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
margin-bottom: 16px;
}
.lec-selected-label {
display: block;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.lec-selected-name {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.lec-amount-field {
max-width: 320px;
display: block;
margin-bottom: 16px;
}
@@ -0,0 +1,76 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from 'app/clients/clients.service';
import { RbiComplianceStepComponent } from '../rbi-compliance-step/rbi-compliance-step.component';
/**
* Standalone RBI eligibility checklist -- a loan officer picks any client and runs the same
* RBI-circular-based checks (age, area of operation, PSL classification, director/related-party,
* wilful defaulter, exposure norms, LTV/margin, fair practices code) independently of the loan
* account creation wizard, before or while a loan is being considered for that client.
*/
@Component({
selector: 'mifosx-loan-eligibility-checklist',
templateUrl: './loan-eligibility-checklist.component.html',
styleUrls: ['./loan-eligibility-checklist.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
RbiComplianceStepComponent
]
})
export class LoanEligibilityChecklistComponent {
private clientsService = inject(ClientsService);
searchText = '';
searchResults: any[] = [];
searching = false;
private searchTimer: any;
selectedClient: { id: number; displayName: string } | null = null;
loanAmount: number | null = null;
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
if (!text || text.length < 2) {
this.searchResults = [];
return;
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
},
error: () => {
this.searchResults = [];
this.searching = false;
}
});
}, 400);
}
selectClient(client: any): void {
this.selectedClient = { id: client.id, displayName: client.displayName };
this.searchResults = [];
this.searchText = '';
this.loanAmount = null;
}
changeClient(): void {
this.selectedClient = null;
this.loanAmount = null;
}
}
@@ -108,6 +108,15 @@ export const CIC_BUREAUS: CicBureau[] = [
'CRIF High Mark'
];
/** What the bank recovers from the customer for pulling a bureau report — a flat pass-through
* of the bureau's per-pull fee, same for every application regardless of loan size. */
export const CIC_CHECK_CHARGE = 50;
/** How the CIC check charge was collected from the customer -- debited from an account they
* already hold with the bank (Existing Customer), or paid externally via a payment link
* (no account exists yet for a New Customer). */
export type CicChargeMode = 'ACCOUNT_DEBIT' | 'PAYMENT_LINK';
export type DiscrepancyType =
| 'NONE'
| 'DOCUMENT_DISCREPANCY'
@@ -172,6 +181,10 @@ export interface LoanApplication {
/** '—' unless channel === 'BC'. */
bcAgentName: string;
customerType: CustomerType;
/** Real Fineract client id -- only set when this application was raised against a client picked
* via the Existing Customer search (see loan-pipeline.component.ts); null for every other
* (dummy-data-generated or New Customer) application, which have no real backing client. */
clientId?: number | null;
productCode: string;
productName: string;
@@ -201,6 +214,25 @@ export interface LoanApplication {
coBorrowerName: string;
// CIC / Credit Bureau check — mandatory before sanction under the CICRA, 2005 framework.
/** RBI's Fair Practices Code requires the customer's informed consent before a credit bureau
* pull -- true once the officer confirms the signed declaration letter has come back from
* the customer, which is what actually unlocks the "Check Credit Score" action below. */
cicConsentSigned: boolean;
/** True once the CIC_CHECK_CHARGE has actually been collected -- via account debit for an
* Existing Customer with a real linked account, or a confirmed payment-link reference
* otherwise. Also gates "Check Credit Score", same as the consent declaration. */
cicChargeCollected: boolean;
cicChargeMode: CicChargeMode | null;
/** Which of the client's real savings/current accounts the charge was debited from --
* e.g. "SB0012345 — Savings (Regular Savings)" -- kept as a display label since this mock
* pipeline never posts a real Fineract transaction. */
cicChargeAccountLabel: string;
/** A mock payment-gateway link generated for a customer with no bank account yet -- this
* pipeline is entirely dummy data, so nothing is actually charged; the link is illustrative
* of the real-world flow (generate link → share with customer → they pay → officer records
* the reference they get back). */
cicPaymentLinkUrl: string;
cicPaymentReference: string;
cicBureau: CicBureau;
cibilScore: number;
cicReportUploaded: boolean;
@@ -273,6 +305,10 @@ export interface LoanApplication {
emiActivationDate: string;
currentStage: OriginationStage;
/** The furthest stage this application has ever reached, as a STAGE_ORDER index -- kept
* separate from `currentStage` so jumping back to an earlier stage for review (or forward
* again afterwards) doesn't make the stepper forget how far the application actually got. */
furthestStageIndex: number;
overallStatus: OverallStatus;
stageHistory: StageHistoryEntry[];
}
@@ -697,6 +733,12 @@ function generateApplications(count: number): LoanApplication[] {
eligibleLoanAmount,
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
coBorrowerName: '—',
cicConsentSigned: false,
cicChargeCollected: false,
cicChargeMode: null,
cicChargeAccountLabel: '—',
cicPaymentLinkUrl: '—',
cicPaymentReference: '—',
cicBureau: pick(CIC_BUREAUS),
cibilScore,
cicReportUploaded: false,
@@ -747,6 +789,7 @@ function generateApplications(count: number): LoanApplication[] {
emiActivationConfirmed: false,
emiActivationDate: '—',
currentStage: 'APPLICATION',
furthestStageIndex: 0,
overallStatus: 'IN_PROGRESS',
stageHistory: []
};
@@ -808,6 +851,11 @@ function generateApplications(count: number): LoanApplication[] {
break;
case 'CIC_CHECK':
app.cicConsentSigned = true;
app.cicChargeCollected = true;
app.cicChargeMode = app.customerType === 'EXISTING' ? 'ACCOUNT_DEBIT' : 'PAYMENT_LINK';
app.cicChargeAccountLabel = app.customerType === 'EXISTING' ? 'Existing account on file' : '—';
app.cicPaymentReference = app.customerType === 'EXISTING' ? '—' : `PAY-${app.applicationNo}`;
app.cicReportUploaded = true;
app.cicReportFileName = `${app.cicBureau.replace(/\s+/g, '')}_${app.applicationNo}.pdf`;
app.cicUploadDate = fmt(cursor);
@@ -1063,6 +1111,7 @@ function generateApplications(count: number): LoanApplication[] {
}
app.currentStage = STAGE_ORDER[bucket.atStage];
app.furthestStageIndex = bucket.atStage;
app.overallStatus = bucket.outcome;
apps.push(app);
}
@@ -1083,6 +1132,14 @@ function recordStageProgress(app: LoanApplication, actionBy: string, outcome: st
app.stageHistory.push({ stage: app.currentStage, date: todayDDMMYYYY(), actionBy, outcome, remarks });
}
/** Moves `currentStage` forward and keeps `furthestStageIndex` in sync with it -- the one place
* every genuine forward move goes through, so the stepper never forgets how far the application
* actually got even after `moveToStage` below sends it back for a correction. */
function advanceStage(app: LoanApplication, newStage: OriginationStage): void {
app.currentStage = newStage;
app.furthestStageIndex = Math.max(app.furthestStageIndex, stageIndex(newStage));
}
/** Generic "this stage is done, move to the next one" action — used by every stage that doesn't
* carry its own ApprovalRecord (Application, KYC, CIC, Field Investigation, Customer Signing,
* Cheque/ACH). */
@@ -1090,10 +1147,33 @@ export function proceedToNextStage(app: LoanApplication, actionBy: string, remar
recordStageProgress(app, actionBy, 'Completed', remarks);
const next = nextStage(app.currentStage);
if (next) {
app.currentStage = next;
advanceStage(app, next);
}
}
/** Lets an officer correct course -- jump the application straight to any stage it has already
* reached (clicking that stage's dot in the stepper), whether that stage is currently behind or
* ahead of `currentStage`. Only `currentStage` moves; every checkbox, upload, score and approval
* already recorded on the application is left exactly as it was, so nothing has to be re-entered
* after a correction, and jumping back then forward again doesn't lose the spot the application
* was actually at -- `furthestStageIndex` is deliberately left untouched here. Refuses to move
* to a stage the application has never actually reached, so this can never be used to skip
* ahead into unfinished territory. */
export function moveToStage(app: LoanApplication, stage: OriginationStage, actionBy: string): void {
const targetIdx = stageIndex(stage);
if (targetIdx < 0 || targetIdx > app.furthestStageIndex || stage === app.currentStage) {
return;
}
const goingBack = targetIdx < stageIndex(app.currentStage);
recordStageProgress(
app,
actionBy,
goingBack ? 'Sent Back' : 'Returned',
`${goingBack ? 'Sent back' : 'Returned'} to "${STAGE_LABELS[stage]}" for review/correction.`
);
app.currentStage = stage;
}
export function flagDiscrepancy(app: LoanApplication, type: DiscrepancyType, actionBy: string, notes: string): void {
app.discrepancyType = type;
app.personalDiscussionDate = todayDDMMYYYY();
@@ -1143,7 +1223,7 @@ export function decideApproval(
recordStageProgress(app, approverName, APPROVAL_LABELS[decision], remarks);
const next = nextStage(app.currentStage);
if (next) {
app.currentStage = next;
advanceStage(app, next);
}
}
@@ -1341,6 +1421,7 @@ export interface NewApplicationInput {
bcAgentName: string;
customerType: CustomerType;
applicantName: string;
clientId?: number | null;
branchCode: string;
branchName: string;
productCode: string;
@@ -1363,6 +1444,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
channel: input.channel,
bcAgentName: input.channel === 'BC' ? input.bcAgentName : '—',
customerType: input.customerType,
clientId: input.clientId ?? null,
productCode: input.productCode,
productName: input.productName,
secured: input.secured,
@@ -1384,6 +1466,12 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
eligibleLoanAmount: 0,
coBorrowerRequired: false,
coBorrowerName: '—',
cicConsentSigned: false,
cicChargeCollected: false,
cicChargeMode: null,
cicChargeAccountLabel: '—',
cicPaymentLinkUrl: '—',
cicPaymentReference: '—',
cicBureau: 'CIBIL',
cibilScore: 0,
cicReportUploaded: false,
@@ -1434,6 +1522,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
emiActivationConfirmed: false,
emiActivationDate: '—',
currentStage: 'APPLICATION',
furthestStageIndex: 0,
overallStatus: 'IN_PROGRESS',
stageHistory: [
{
@@ -12,6 +12,8 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
import { LoanEligibilityChecklistComponent } from './loan-eligibility-checklist/loan-eligibility-checklist.component';
import { RbiRulesReferenceComponent } from './rbi-rules-reference/rbi-rules-reference.component';
const routes: Routes = [
Route.withShell([
@@ -33,6 +35,16 @@ const routes: Routes = [
path: 'application/:applicationNo',
component: ApplicationDetailComponent,
data: { title: 'Loan Application', breadcrumb: 'Application' }
},
{
path: 'eligibility-checklist',
component: LoanEligibilityChecklistComponent,
data: { title: 'RBI Eligibility Checklist', breadcrumb: 'RBI Eligibility Checklist' }
},
{
path: 'rbi-rules-reference',
component: RbiRulesReferenceComponent,
data: { title: 'RBI Compliance Rules', breadcrumb: 'RBI Rules Reference' }
}
]
}
@@ -59,6 +59,18 @@
color: #15803d;
border: 1px solid #bbf7d0;
}
&--verified {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--missing {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
}
.pg-btn--success {
@@ -320,3 +332,339 @@
color: #94a3b8;
}
}
// New Application intake — existing-customer search (real Fineract client lookup, dropped into
// this otherwise fully mock/dummy-data pipeline screen).
.loi-selected-client {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
font-weight: 600;
font-size: 13.5px;
}
.loi-search-hint {
color: #94a3b8;
font-size: 12.5px;
margin: -8px 0 12px;
}
.loi-search-results {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
max-width: 480px;
margin-bottom: 16px;
}
.loi-search-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 14px;
background: #fff;
border: none;
border-bottom: 1px solid #f1f5f9;
text-align: left;
cursor: pointer;
font: inherit;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8fafc;
}
}
.loi-search-name {
font-weight: 600;
color: #0f172a;
}
.loi-search-meta {
color: #94a3b8;
font-size: 12.5px;
}
// Application Detail — real share-account chips (clickable, opens the details popup)
.loi-share-account-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.loi-share-account-chip {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
background: #dcfce7;
color: #166534;
font-size: 12.5px;
font-weight: 600;
border: none;
cursor: pointer;
&:hover {
background: #bbf7d0;
}
}
// Application Detail — KYC & Documents: real verified-status cards (existing client) and the
// local-only capture UI (no linked real client) both lay out as a compact card grid instead of
// one long stacked column, so a 6-7 item checklist reads as a glanceable board, not a scroll.
.loi-kyc-doc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.loi-kyc-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
// Cards holding nothing but a single checkbox item (e-KYC fingerprint, secured-loan property
// proofs) would otherwise look like empty boxes next to the document cards -- centering the
// one line vertically makes the shorter card read as intentional, not unfinished.
&--checkbox {
justify-content: center;
min-height: 46px;
}
// Compact everything inside a card -- the section-wide .pg-btn/.inv-form-field sizes are tuned
// for full-width action rows elsewhere on this page, and read oversized packed into a card.
.pg-btn {
padding: 4px 10px;
font-size: 12px;
min-height: 30px;
}
.inv-form-field {
gap: 2px;
span {
font-size: 11px;
}
}
select {
height: 30px;
font-size: 12.5px;
}
// stylelint-disable-next-line selector-no-qualifying-type
input[type='date'] {
height: 30px;
font-size: 12.5px;
}
}
.loi-kyc-card-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.loi-kyc-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.loi-kyc-masked {
font-variant-numeric: tabular-nums;
font-family: 'Courier New', monospace;
color: #475569;
font-size: 12.5px;
}
.loi-kyc-card-files {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
// Thumbnail(s) of whatever was just uploaded -- clicking one opens the full file in
// DocumentPreviewDialogComponent, straight from the in-memory File (nothing is uploaded
// anywhere yet in this local-only capture UI).
.loi-kyc-thumbs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.loi-kyc-thumb {
width: 56px;
height: 56px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:hover {
border-color: #94a3b8;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.loi-kyc-thumb-label {
font-size: 9.5px;
font-weight: 600;
color: #64748b;
text-align: center;
padding: 2px;
}
.loi-kyc-card-verify {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
// stylelint-disable-next-line selector-no-qualifying-type
input[type='text'] {
height: 30px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12.5px;
width: 120px;
}
}
.loi-kyc-note {
color: #94a3b8;
font-size: 12px;
margin: -2px 0 10px;
}
// CIC / Credit Bureau Check stage -- the Consent and Charge steps each get their own bordered
// sub-card (instead of loose elements sharing the page-level .loan-action-card) so the two
// distinct steps read as separate units rather than one long unbroken list.
.loi-cic-subsection {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
margin: 0 0 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.loi-cic-subsection-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #1e40af;
}
.loi-kyc-error {
color: #dc2626;
font-size: 11.5px;
font-weight: 600;
margin: -2px 0 0;
}
.loi-otp-digits {
display: flex;
gap: 5px;
// stylelint-disable-next-line selector-no-qualifying-type
input[type='text'] {
width: 28px;
height: 30px;
text-align: center;
padding: 0;
}
}
// KYC & Documents' Monthly Income card -- full basis for the EMI figure, not just the number.
.loi-emi-breakdown {
margin-top: 4px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
.loan-kv-grid {
padding: 0;
}
}
.loi-emi-breakdown-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 8px;
}
.loi-emi-formula {
margin: 10px 0 0;
}
// CIC check charge -- picking one of the client's real accounts to debit reuses the .loi-kyc-card
// grid/card look, just made clickable as a whole (a plain button, not a form control) with a
// clear selected state.
.loi-charge-account {
text-align: left;
cursor: pointer;
font: inherit;
&--selected {
border-color: #15803d;
background: #f0fdf4;
}
}
// A completed stepper step is a real <button> (see canJumpToStage) so the officer can jump
// straight back to it -- reset the browser's own button chrome and only add a hover affordance,
// the done/current/rejected/hold color treatment is already handled by the shared .loan-step
// rules above regardless of the element being a <div> or a <button>.
.loan-step--clickable {
border: none;
background: transparent;
padding: 0;
font: inherit;
cursor: pointer;
&:hover {
.loan-step__dot {
box-shadow: 0 0 0 3px rgb(21 128 61 / 20%);
}
.loan-step__label {
text-decoration: underline;
}
}
}
@@ -12,13 +12,17 @@ import { SharedModule } from '../shared/shared.module';
import { LoanOriginationRoutingModule } from './loan-origination-routing.module';
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
import { LoanEligibilityChecklistComponent } from './loan-eligibility-checklist/loan-eligibility-checklist.component';
import { RbiRulesReferenceComponent } from './rbi-rules-reference/rbi-rules-reference.component';
@NgModule({
imports: [
SharedModule,
LoanOriginationRoutingModule,
LoanPipelineComponent,
ApplicationDetailComponent
ApplicationDetailComponent,
LoanEligibilityChecklistComponent,
RbiRulesReferenceComponent
]
})
export class LoanOriginationModule {}
@@ -256,15 +256,37 @@
}
<label class="inv-form-field">
<span>* Customer Type</span>
<select [(ngModel)]="newApp.customerType">
<select [(ngModel)]="newApp.customerType" (ngModelChange)="onCustomerTypeChange()">
<option value="NEW">New Customer</option>
<option value="EXISTING">Existing Customer</option>
</select>
</label>
<label class="inv-form-field">
<span>* Applicant Name</span>
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
</label>
@if (newApp.customerType === 'EXISTING') {
@if (!newApp.clientId) {
<label class="inv-form-field">
<span>* Search Existing Client</span>
<input
type="text"
[(ngModel)]="existingClientSearchText"
(ngModelChange)="onExistingClientSearch($event)"
placeholder="Name, account no., mobile..."
/>
</label>
} @else {
<label class="inv-form-field">
<span>* Applicant</span>
<div class="loi-selected-client">
<span>{{ newApp.applicantName }}</span>
<button type="button" class="pg-btn" (click)="clearExistingClient()">Change</button>
</div>
</label>
}
} @else {
<label class="inv-form-field">
<span>* Applicant Name</span>
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
</label>
}
<label class="inv-form-field">
<span>* Branch</span>
<select [(ngModel)]="newApp.branchCode" [disabled]="!isHeadOffice">
@@ -290,6 +312,25 @@
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
</label>
</div>
@if (newApp.customerType === 'EXISTING' && !newApp.clientId) {
@if (searchingExistingClient) {
<p class="loi-search-hint">Searching…</p>
} @else if (existingClientSearchText.length >= 2 && existingClientSearchResults.length === 0) {
<p class="loi-search-hint">No clients found.</p>
}
@if (existingClientSearchResults.length > 0) {
<div class="loi-search-results">
@for (client of existingClientSearchResults; track client.id) {
<button type="button" class="loi-search-row" (click)="selectExistingClient(client)">
<span class="loi-search-name">{{ client.displayName }}</span>
<span class="loi-search-meta">{{ client.accountNo ?? client.officeName ?? '' }}</span>
</button>
}
</div>
}
}
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
</div>
@@ -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<typeof setTimeout> | 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
@@ -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;
}
}
@@ -0,0 +1,87 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container rrr-container">
<mat-card>
<mat-card-content>
<h2 class="rrr-title">RBI Compliance Rules -- Reference</h2>
<p class="rrr-subtitle">
Every rule enforced on the RBI Eligibility Checklist, with its underlying regulation and exact wording.
</p>
<div class="rrr-rule-list">
@for (rule of rules; track rule.code) {
<div class="rrr-rule-row" [ngClass]="rule.blockLevel === 'HARD' ? 'rrr-rule-row--hard' : ''">
<div class="rrr-rule-row-header">
<span class="rrr-rule-title">{{ rule.title }}</span>
<div class="rrr-rule-tags">
<span class="rrr-tag rrr-tag--type">{{ rule.checkType === 'AUTO' ? 'Automatic' : 'Manual' }}</span>
<span class="rrr-tag" [ngClass]="rule.blockLevel === 'HARD' ? 'rrr-tag--hard' : 'rrr-tag--soft'">
{{ rule.blockLevel === 'HARD' ? 'Legal Prohibition -- No Override' : 'Policy -- Override Allowed' }}
</span>
</div>
</div>
<p class="rrr-rule-reference">{{ rule.rbiReference }}</p>
<p class="rrr-rule-statement">{{ rule.statement }}</p>
</div>
}
</div>
<h3 class="rrr-section-title">Priority Sector Lending -- Category Reference</h3>
<div class="rrr-psl-grid">
@for (category of pslCategories; track category.code) {
<span class="rrr-psl-chip">{{ category.name }}</span>
}
</div>
<h3 class="rrr-section-title">Loan-to-Value Cap Reference</h3>
<table class="rrr-ltv-table">
<thead>
<tr>
<th>Collateral</th>
<th>Value Band</th>
<th>Max LTV</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="3">Gold Loan</td>
<td>Loan amount up to ₹2.5 lakh</td>
<td>85%</td>
</tr>
<tr>
<td>Loan amount ₹2.5 lakh -- ₹5 lakh</td>
<td>80%</td>
</tr>
<tr>
<td>Loan amount above ₹5 lakh</td>
<td>75%</td>
</tr>
<tr>
<td rowspan="2">Housing Loan</td>
<td>Property value below ₹30 lakh</td>
<td>90%</td>
</tr>
<tr>
<td>Property value ₹30 lakh and above</td>
<td>80%</td>
</tr>
<tr>
<td rowspan="2">Shares / Debentures</td>
<td>Held in physical form (min. 50% margin)</td>
<td>50%</td>
</tr>
<tr>
<td>Held in demat form (min. 25% margin)</td>
<td>75%</td>
</tr>
</tbody>
</table>
</mat-card-content>
</mat-card>
</div>
@@ -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;
}
}
@@ -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');
}