feat(loan-origination): RBI compliance checklist, real client integration, and CIC consent/charge flows
- Add standalone RBI Eligibility Checklist and RBI Rules Reference pages (11 real RBI-circular based rules: age-at-maturity, area of operation, PSL, director/related-party, wilful defaulter, exposure norms, LTV/margin, fair practices code, prepayment charges, own-shares security, insurance bundling), wired into the Loan Origination menu. - Bridge the mock Loan Origination Pipeline to real Fineract data wherever an application is raised against a real Existing Customer: client search in the pipeline intake form, real Share Member accounts (with a details popup), and real KYC (Aadhaar/PAN verified status + masked numbers + document image view, generic document lookup for Income Proof/Bank Statement/ Electricity Bill) on the Application Detail page. - Add local-only capture UI for New Customer / no-account applications: front/back document images with crop + simulated Aadhaar OTP / PAN verification, employment-type-aware income proof, and an expiry-validated electricity bill upload. - Add an EMI Calculation Basis breakdown (principal, rate, tenure, total interest/payable, and the substituted reducing-balance formula) to the KYC stage's income assessment. - Add a CIC/Credit Bureau Check consent flow: a generated declaration letter (print for Branch, share for BC/Self-Apply), gated on the signed copy being uploaded before the credit score can be checked, plus a CIC check charge collected either by debiting a real linked account or via a mock payment-link flow for customers with none. - Let the stage stepper jump directly to any already-reached stage (forward or backward) via a new furthestStageIndex, without losing any data already recorded on the application.
This commit is contained in:
@@ -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 & 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 & 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". */
|
||||
|
||||
+62
@@ -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>
|
||||
+37
@@ -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;
|
||||
}
|
||||
+61
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+61
@@ -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>
|
||||
+104
@@ -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;
|
||||
}
|
||||
+76
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user