feat(ai): loan eligibility demo screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:19:16 +05:30
co-authored by Claude Opus 5.5
parent 7eac2dcf3b
commit f9748036c3
9 changed files with 1906 additions and 1 deletions
+2
View File
@@ -79,6 +79,7 @@ import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
import { ElectionModule } from './election/election.module';
import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module';
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
import { PortalModule } from '@angular/cdk/portal';
@@ -197,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
LetterMgmtModule,
AccountsModule,
ElectionModule,
LoanEligibilityModule,
SponsorDDModule,
SponsorDDReconciliationModule,
AppRoutingModule,
@@ -154,7 +154,7 @@
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
<span>Lending &amp; Risk</span>
</div>
<button mat-menu-item class="ai-card" tabindex="0">
<button mat-menu-item class="ai-card" routerLink="/ai/loan-eligibility" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="hand-holding-usd"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">Loan Eligibility</span>
@@ -0,0 +1,27 @@
/**
* 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 { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
Route.withShell([
{
path: 'ai/loan-eligibility',
loadComponent: () => import('./loan-eligibility.component').then((m) => m.LoanEligibilityComponent),
data: { title: 'Loan Eligibility', breadcrumb: 'Loan Eligibility' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoanEligibilityRoutingModule {}
@@ -0,0 +1,482 @@
<!--
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="pg">
@if (!loading) {
<header class="pg-head">
<div>
<div class="pg-eyebrow">AI / INTELLIGENCE · LENDING &amp; RISK</div>
<h1 class="pg-title">Loan Eligibility</h1>
<p class="pg-sub">
How our loans are running (RBI asset classification) and which loan products the good borrowers are eligible
for, using the eligibility rules set on each loan product.
@if (summary) {
As on {{ summary.asOnDate }}.
}
</p>
</div>
</header>
}
@if (error) {
<p class="pg-sub le-error">{{ error }}</p>
}
@if (loading) {
<div class="le-loader">
<div class="le-spinner"></div>
<div class="le-loader-title">Loading loan book…</div>
<ul class="le-loader-steps">
@for (st of loadingSteps; track $index) {
<li [class.le-done]="$index < loadingStep" [class.le-now]="$index === loadingStep">
{{ $index < loadingStep ? '✓' : $index === loadingStep ? '…' : '' }} {{ st }}
</li>
}
</ul>
<div class="le-progress"><span [style.width.%]="((loadingStep + 1) / loadingSteps.length) * 100"></span></div>
</div>
}
@if (summary; as s) {
<div class="le-kpis">
<div class="le-kpi">
<span class="le-kpi-l">Active loans</span>
<span class="le-kpi-v">{{ s.activeLoans }}</span>
<span class="le-kpi-s">₹{{ s.activeOutstanding | number: '1.0-0' }} outstanding</span>
</div>
<div class="le-kpi le-kpi--good">
<span class="le-kpi-l">Running well (Standard + SMA)</span>
<span class="le-kpi-v">{{ s.performingLoans }}</span>
<span class="le-kpi-s">₹{{ s.performingOutstanding | number: '1.0-0' }}</span>
</div>
<div class="le-kpi le-kpi--bad">
<span class="le-kpi-l">Gone bad (NPA)</span>
<span class="le-kpi-v">{{ s.npaLoans }}</span>
<span class="le-kpi-s">₹{{ s.npaOutstanding | number: '1.0-0' }} · Gross NPA {{ s.grossNpaPct }}%</span>
</div>
<div class="le-kpi">
<span class="le-kpi-l">Written off / Closed</span>
<span class="le-kpi-v">{{ s.writtenOffLoans }} / {{ s.closedLoans }}</span>
<span class="le-kpi-s">₹{{ s.writtenOffAmount | number: '1.0-0' }} written off</span>
</div>
</div>
<div class="cctr-tabs le-tabs le-views">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="view === 'BOOK'"
(click)="view = 'BOOK'"
>
Loan book (RBI classes &amp; products)
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="view === 'BORROWERS'"
(click)="view = 'BORROWERS'"
>
Borrowers &amp; eligibility (Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad
{{ s.badBorrowers }})
</button>
</div>
@if (view === 'BOOK') {
<div class="pg-card">
<div class="pos-section-label">Loans by RBI asset classification (days past due)</div>
<table class="pg-table">
<thead>
<tr>
<th>Class</th>
<th>Days past due</th>
<th class="num">Loans</th>
<th class="num">Outstanding</th>
<th class="num">Overdue</th>
<th>Share of book</th>
</tr>
</thead>
<tbody>
@for (st of performingStages; track st.stage) {
<tr>
<td>
<span
class="le-dot"
[class.le-dot--good]="st.stage === 'STANDARD'"
[class.le-dot--warn]="st.stage !== 'STANDARD'"
></span>
{{ st.label }}
</td>
<td>{{ st.dpdRange }}</td>
<td class="num">{{ st.loans }}</td>
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
<td>
<span class="le-bar"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
</td>
</tr>
}
@for (st of npaStages; track st.stage) {
<tr>
<td><span class="le-dot le-dot--bad"></span> {{ st.label }} (NPA)</td>
<td>{{ st.dpdRange }}</td>
<td class="num">{{ st.loans }}</td>
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
<td>
<span class="le-bar le-bar--bad"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
</td>
</tr>
}
@for (st of closedStages; track st.stage) {
<tr class="le-muted">
<td>{{ st.label }}</td>
<td>{{ st.dpdRange }}</td>
<td class="num">{{ st.loans }}</td>
<td class="num">–</td>
<td class="num">–</td>
<td></td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Active loans by product</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th class="num">Active</th>
<th class="num">Running well</th>
<th class="num">NPA</th>
<th class="num">Outstanding</th>
<th class="num">NPA outstanding</th>
</tr>
</thead>
<tbody>
@for (p of s.products; track p.productId) {
<tr>
<td>{{ p.productName }}</td>
<td class="num">{{ p.activeLoans }}</td>
<td class="num">{{ p.performingLoans }}</td>
<td class="num">{{ p.npaLoans }}</td>
<td class="num">₹{{ p.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ p.npaOutstanding | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No active loans.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-card">
<div class="pos-section-label">Share linking to borrowing (5% unsecured · 2.5% secured)</div>
<div class="le-facts">
<span>Shares required on running loans: ₹{{ s.shareRequired | number: '1.0-0' }}</span>
<span>Shares held by these members: ₹{{ s.shareHeld | number: '1.0-0' }}</span>
<span [class.le-late]="s.shareShortMembers > 0"
>Members short: {{ s.shareShortMembers }} (₹{{ s.shareShortfall | number: '1.0-0' }} to collect)</span
>
</div>
</div>
}
@if (view === 'BORROWERS') {
<div class="pg-card">
<div class="pos-section-label">
Borrowers — Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad {{ s.badBorrowers }}
</div>
<div class="cctr-tabs le-tabs">
@for (c of categories; track c.value) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="category === c.value"
(click)="setCategory(c.value)"
>
{{ c.label }}
</button>
}
<input
type="text"
class="le-search"
placeholder="Search name / account no"
[(ngModel)]="search"
(keyup.enter)="loadBorrowers()"
/>
<button type="button" class="pg-btn pg-btn--ghost" (click)="loadBorrowers()">Search</button>
</div>
<p class="pg-sub le-help">
Good: every running loan is regular (0 days past due) and nothing was ever written off. Watch: a loan is
overdue up to 90 days (SMA). Bad: a loan is NPA or written off. Eligibility is worked out for good borrowers
only; a fresh loan also needs a clean EMI record (at most 2 EMIs ever paid late).
</p>
<table class="pg-table le-compact">
<thead>
<tr>
<th>Borrower</th>
<th>Category</th>
<th class="num">Running / Closed</th>
<th>EMI record</th>
<th class="num">Outstanding</th>
<th class="num">Max days overdue</th>
<th>Worst class</th>
<th>More loan?</th>
<th></th>
</tr>
</thead>
<tbody>
@if (borrowersLoading) {
<tr>
<td colspan="9" class="pg-empty">
<span class="le-spinner le-spinner--sm"></span> Fetching borrowers…
</td>
</tr>
}
@for (b of borrowersLoading ? [] : pagedBorrowers; track b.clientId) {
<tr [class.le-selected]="selected?.clientId === b.clientId">
<td>
{{ b.clientName }} <small>({{ b.clientAccountNo }})</small>
</td>
<td>
<span class="le-chip" [ngClass]="'le-chip--' + b.category.toLowerCase()">{{
categoryLabel(b.category)
}}</span>
</td>
<td class="num">{{ b.activeLoans }} / {{ b.closedLoans }}</td>
<td>
{{ b.emisPaid - b.emisLate }} / {{ b.emisPaid }} on time
@if (b.emisLate) {
<small class="le-late">({{ b.emisLate }} late)</small>
}
</td>
<td class="num">₹{{ b.outstanding | number: '1.2-2' }}</td>
<td class="num">{{ b.maxDpd }}</td>
<td>{{ b.worstStage || '–' }}</td>
<td>
@if (b.eligibleProducts > 0) {
<span class="le-chip le-chip--good">Yes · {{ b.eligibleProducts }} products</span>
} @else {
<span class="le-chip le-chip--bad">No</span>
}
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="openingId !== null" (click)="open(b)">
{{ openingId === b.clientId ? 'Loading…' : 'View' }}
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="pg-empty">No borrowers in this category.</td>
</tr>
}
</tbody>
</table>
@if (borrowers.length > pageSize) {
<div class="le-pager">
<span>Showing {{ page * pageSize + 1 }}–{{ pageEnd }} of {{ borrowers.length }}</span>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="page === 0" (click)="page = page - 1">
‹ Prev
</button>
@for (p of pages; track p) {
<button type="button" class="pg-btn pg-btn--ghost" [class.pg-btn--active]="p === page" (click)="page = p">
{{ p + 1 }}
</button>
}
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="page >= pages.length - 1"
(click)="page = page + 1"
>
Next ›
</button>
</div>
}
</div>
}
@if (selected; as b) {
<div class="le-overlay" (click)="close()">
<div class="le-modal" role="dialog" aria-modal="true" (click)="$event.stopPropagation()">
<div class="le-modal-head">
<div class="pos-section-label">
{{ b.clientName }} ({{ b.clientAccountNo }}) — {{ categoryLabel(b.category) }} borrower
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="close()">Close</button>
</div>
<div class="le-facts">
<span>Age: {{ b.age ?? 'not recorded' }}</span>
<span
>Monthly income: {{ b.monthlyIncome ? '₹' + (b.monthlyIncome | number: '1.0-0') : 'not recorded' }}</span
>
<span>Current EMIs: ₹{{ b.currentEmi | number: '1.0-0' }}</span>
<span>EMI record: {{ b.emisPaid - b.emisLate }} of {{ b.emisPaid }} paid on time</span>
<span>Deposits (savings / FD / RD): ₹{{ b.depositBalance | number: '1.0-0' }}</span>
<span [class.le-late]="b.shareHeld < b.shareRequired"
>Shares held: ₹{{ b.shareHeld | number: '1.0-0' }} (needed ₹{{ b.shareRequired | number: '1.0-0' }} incl.
membership)</span
>
</div>
@for (a of b.accounts ?? []; track a.accountNo) {
<div class="pos-section-label">
{{ a.type }} account {{ a.accountNo }} — last 6 months (money in, expenses, balance)
</div>
<table class="pg-table">
<thead>
<tr>
<th>Month</th>
<th class="num">Credits (in)</th>
<th class="num">Debits (expenses + EMIs)</th>
<th class="num">Left over</th>
<th class="num">Average balance</th>
<th class="num">Cheque / ECS returns</th>
</tr>
</thead>
<tbody>
@for (m of a.months; track m.month) {
<tr>
<td>{{ m.month }}</td>
<td class="num">₹{{ m.credits | number: '1.0-0' }}</td>
<td class="num">₹{{ m.debits | number: '1.0-0' }}</td>
<td class="num" [class.le-late]="m.credits < m.debits">
₹{{ m.credits - m.debits | number: '1.0-0' }}
</td>
<td class="num">₹{{ m.avgBalance | number: '1.0-0' }}</td>
<td class="num" [class.le-late]="m.returns > 0">{{ m.returns }}</td>
</tr>
}
<tr class="le-total">
<td>Monthly average</td>
<td class="num">₹{{ b.avgCredits | number: '1.0-0' }}</td>
<td class="num">₹{{ b.avgDebits | number: '1.0-0' }}</td>
<td class="num" [class.le-late]="b.avgCredits < b.avgDebits">
₹{{ b.avgCredits - b.avgDebits | number: '1.0-0' }}
</td>
<td class="num">
@if (b.avgCredits > 0) {
Spends {{ (b.avgDebits / b.avgCredits) * 100 | number: '1.0-0' }}% of income
}
</td>
<td class="num">{{ b.returns }}</td>
</tr>
</tbody>
</table>
}
<div class="pos-section-label">Loans</div>
<table class="pg-table">
<thead>
<tr>
<th>Loan</th>
<th>Product</th>
<th>Status</th>
<th>Class</th>
<th class="num">Days overdue</th>
<th class="num">EMIs on time</th>
<th class="num">Disbursed</th>
<th class="num">Outstanding</th>
<th class="num">Overdue</th>
<th class="num">Shares needed</th>
</tr>
</thead>
<tbody>
@for (l of b.loans ?? []; track l.loanId) {
<tr>
<td>{{ l.accountNo }}</td>
<td>{{ l.productName }}</td>
<td>{{ l.status }}</td>
<td>{{ l.stage }}</td>
<td class="num">{{ l.dpd }}</td>
<td class="num">{{ l.emisPaid - l.emisLate }} / {{ l.emisPaid }}</td>
<td class="num">₹{{ l.disbursed | number: '1.2-2' }}</td>
<td class="num">₹{{ l.outstanding | number: '1.2-2' }}</td>
<td class="num">₹{{ l.overdue | number: '1.2-2' }}</td>
<td class="num">
@if (l.shareRequired) {
₹{{ l.shareRequired | number: '1.0-0' }} <small>({{ l.sharePct }}%)</small>
} @else {
–
}
</td>
</tr>
}
</tbody>
</table>
@if (b.category === 'GOOD') {
<div class="pos-section-label">Eligible loan products</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Eligible</th>
<th class="num">Up to</th>
<th class="num">Shares to take</th>
<th class="num">In hand</th>
<th>Why</th>
</tr>
</thead>
<tbody>
@for (e of b.eligibility ?? []; track e.productId) {
<tr>
<td>{{ e.productName }}</td>
<td>
<span class="le-chip" [ngClass]="e.eligible ? 'le-chip--good' : 'le-chip--bad'">{{
e.eligible ? 'Yes' : 'No'
}}</span>
</td>
<td class="num">{{ e.eligibleAmount ? '₹' + (e.eligibleAmount | number: '1.0-0') : '–' }}</td>
<td class="num">
@if (e.shareToBuy) {
₹{{ e.shareToBuy | number: '1.0-0' }} <small>({{ e.sharePct }}%)</small>
} @else {
–
}
</td>
<td class="num">{{ e.netInHand ? '₹' + (e.netInHand | number: '1.0-0') : '–' }}</td>
<td class="le-why">
@for (f of e.failed; track $index) {
<div class="le-fail">✗ {{ f }}</div>
}
@for (p of e.passed; track $index) {
<div class="le-pass">✓ {{ p }}</div>
}
@for (n of e.notes; track $index) {
<div class="le-note">• {{ n }}</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No active loan products.</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="pg-sub le-help">
Eligibility is not worked out for this borrower because a loan is overdue, NPA or written off.
</p>
}
</div>
</div>
}
<ul class="le-notes">
@for (n of s.notes; track $index) {
<li>{{ n }}</li>
}
</ul>
}
</div>
@@ -0,0 +1,312 @@
/**
* 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/.
*/
.le-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.le-kpi {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border: 1px solid #e4e7ec;
border-radius: 10px;
background: #fff;
}
.le-kpi--good {
border-left: 4px solid #12b76a;
}
.le-kpi--bad {
border-left: 4px solid #d92d20;
}
.le-kpi-l {
font-size: 12px;
color: #667085;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.le-kpi-v {
font-size: 26px;
font-weight: 700;
}
.le-kpi-s {
font-size: 13px;
color: #475467;
}
.le-dot {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 6px;
border-radius: 50%;
}
.le-dot--good {
background: #12b76a;
}
.le-dot--warn {
background: #f79009;
}
.le-dot--bad {
background: #d92d20;
}
.le-bar {
display: inline-block;
width: 120px;
height: 8px;
margin-right: 6px;
overflow: hidden;
border-radius: 4px;
background: #f2f4f7;
vertical-align: middle;
span {
display: block;
height: 100%;
background: #12b76a;
}
}
.le-bar--bad span {
background: #d92d20;
}
.le-muted {
color: #667085;
}
.le-tabs {
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 0;
}
.le-search {
min-width: 220px;
padding: 8px 10px;
border: 1px solid #d0d5dd;
border-radius: 8px;
}
.le-help {
padding: 0 16px;
}
.le-chip {
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.le-chip--good {
background: #ecfdf3;
color: #067647;
}
.le-chip--watch {
background: #fffaeb;
color: #b54708;
}
.le-chip--bad {
background: #fef3f2;
color: #b42318;
}
.le-selected {
background: #f5f8ff;
}
.le-facts {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 8px 16px 12px;
color: #344054;
}
.le-why div {
font-size: 13px;
line-height: 1.5;
}
.le-fail {
color: #b42318;
}
.le-pass {
color: #067647;
}
.le-note {
color: #667085;
}
.le-error {
color: #b42318;
}
.le-notes {
margin: 8px 0 24px;
color: #667085;
font-size: 13px;
}
.le-late {
color: #b42318;
}
.le-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
overflow-y: auto;
background: rgb(15 23 42 / 55%);
}
.le-modal {
width: min(1100px, 100%);
padding: 20px 24px;
border-radius: 12px;
background: #fff;
box-shadow: 0 20px 50px rgb(0 0 0 / 30%);
}
.le-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.le-views {
margin-bottom: 16px;
}
.le-compact td {
padding-top: 6px;
padding-bottom: 6px;
}
.le-compact .pg-btn {
padding: 4px 14px;
}
.le-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
span {
margin-right: auto;
color: #667085;
}
}
.le-loader {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
margin: 32px auto;
padding: 36px 24px;
max-width: 520px;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 16px rgb(16 24 40 / 8%);
}
.le-loader-title {
font-weight: 600;
font-size: 16px;
color: #1d2939;
}
.le-loader-steps {
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
line-height: 1.9;
color: #98a2b3;
.le-done {
color: #067647;
}
.le-now {
color: #1d4ed8;
font-weight: 600;
}
}
.le-progress {
width: 100%;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: #eaecf0;
span {
display: block;
height: 100%;
background: #1d4ed8;
transition: width 0.6s ease;
}
}
.le-spinner {
width: 44px;
height: 44px;
border: 4px solid #dbe4ff;
border-top-color: #1d4ed8;
border-radius: 50%;
animation: le-spin 0.8s linear infinite;
}
.le-spinner--sm {
display: inline-block;
width: 14px;
height: 14px;
border-width: 2px;
margin-right: 6px;
vertical-align: middle;
}
@keyframes le-spin {
to {
transform: rotate(360deg);
}
}
.le-total td {
font-weight: 600;
border-top: 2px solid #d0d5dd;
}
@@ -0,0 +1,187 @@
/**
* 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, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import {
Borrower,
BorrowerCategory,
LoanEligibilityService,
LoanEligibilitySummary,
LoanStageRow
} from './loan-eligibility.service';
/**
* Loan Eligibility – how the loan book is running (RBI asset classes) and which loan products the good borrowers
* qualify for, using their EMI record and the eligibility rules set on each loan product.
*/
@Component({
selector: 'mifosx-loan-eligibility',
templateUrl: './loan-eligibility.component.html',
styleUrls: [
'../cashier/cashier-shared.scss',
'../investment/investment-shared.scss',
'./loan-eligibility.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class LoanEligibilityComponent implements OnInit, OnDestroy {
private readonly service = inject(LoanEligibilityService);
summary: LoanEligibilitySummary | null = null;
borrowers: Borrower[] = [];
category: BorrowerCategory | '' = 'GOOD';
search = '';
selected: Borrower | null = null;
/** Which part of the page is shown, so the user need not scroll past the loan book to reach borrowers. */
view: 'BOOK' | 'BORROWERS' = 'BOOK';
page = 0;
readonly pageSize = 10;
loading = false;
borrowersLoading = false;
/** Borrower whose detail is being fetched (View button shows it). */
openingId: number | null = null;
error = '';
/** Steps shown while the book is being read. */
readonly loadingSteps = [
'Fetching loan accounts…',
'Reading repayment schedules and overdues…',
'Classifying loans as per RBI IRAC norms…',
'Running product eligibility rules…'
];
loadingStep = 0;
private stepTimer?: ReturnType<typeof setInterval>;
readonly categories: Array<{ value: BorrowerCategory | ''; label: string }> = [
{ value: 'GOOD', label: 'Good borrowers' },
{ value: 'WATCH', label: 'Watch (SMA)' },
{ value: 'BAD', label: 'Bad (NPA / written off)' },
{ value: '', label: 'All borrowers' }
];
ngOnInit(): void {
this.loading = true;
this.loadingStep = 0;
this.stepTimer = setInterval(
() => (this.loadingStep = Math.min(this.loadingStep + 1, this.loadingSteps.length - 1)),
700
);
this.service.getSummary().subscribe({
next: (s) => {
this.summary = s;
this.stopLoading();
},
error: (err) => {
this.stopLoading();
this.error = this.message(err);
}
});
this.loadBorrowers();
}
loadBorrowers(): void {
this.selected = null;
this.borrowersLoading = true;
this.service.getBorrowers(this.category, this.search).subscribe({
next: (b) => {
this.borrowers = b;
this.page = 0;
this.borrowersLoading = false;
},
error: (err) => {
this.borrowersLoading = false;
this.error = this.message(err);
}
});
}
setCategory(c: BorrowerCategory | ''): void {
this.category = c;
this.loadBorrowers();
}
open(b: Borrower): void {
this.openingId = b.clientId;
this.service.getBorrower(b.clientId).subscribe({
next: (d) => {
this.openingId = null;
this.selected = d;
},
error: (err) => {
this.openingId = null;
this.error = this.message(err);
}
});
}
ngOnDestroy(): void {
this.stopLoading();
}
private stopLoading(): void {
this.loading = false;
clearInterval(this.stepTimer);
}
get pagedBorrowers(): Borrower[] {
return this.borrowers.slice(this.page * this.pageSize, (this.page + 1) * this.pageSize);
}
get pageEnd(): number {
return Math.min((this.page + 1) * this.pageSize, this.borrowers.length);
}
get pages(): number[] {
return Array.from({ length: Math.ceil(this.borrowers.length / this.pageSize) }, (_, i) => i);
}
close(): void {
this.selected = null;
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
get performingStages(): LoanStageRow[] {
return (this.summary?.stages ?? []).filter((s) => s.group === 'PERFORMING');
}
get npaStages(): LoanStageRow[] {
return (this.summary?.stages ?? []).filter((s) => s.group === 'NPA');
}
get closedStages(): LoanStageRow[] {
return (this.summary?.stages ?? []).filter((s) => s.group === 'CLOSED');
}
/** Share of the active book in a stage, for the bar. */
share(s: LoanStageRow): number {
const total = this.summary?.activeOutstanding ?? 0;
return total > 0 ? Math.round((s.outstanding / total) * 1000) / 10 : 0;
}
categoryLabel(c: BorrowerCategory): string {
return c === 'GOOD' ? 'Good' : c === 'WATCH' ? 'Watch' : 'Bad';
}
private message(err: unknown): string {
if (err instanceof HttpErrorResponse) {
const e = err.error as { errors?: Array<{ defaultUserMessage?: string }>; defaultUserMessage?: string } | null;
return e?.errors?.[0]?.defaultUserMessage || e?.defaultUserMessage || err.message;
}
return String(err);
}
}
@@ -0,0 +1,726 @@
/**
* 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/.
*/
/**
* Demo data for Loan Eligibility. The bank's rules are applied to it: RBI asset classification on days past due, and
* product eligibility rules (age, age at maturity, income, FOIR, loans per customer, LTV on pledged deposits). The book is generated from a fixed seed, so the
* figures are the same on every load.
*/
import {
Borrower,
BorrowerCategory,
BorrowerLoan,
LoanEligibilitySummary,
LoanStageRow,
ProductEligibility
} from './loan-eligibility.service';
interface DemoProduct {
id: number;
name: string;
minAmount: number;
maxAmount: number;
annualRate: number;
tenureMonths: number;
minAge?: number;
maxAgeAtMaturity?: number;
minMonthlyIncome?: number;
maxFoir?: number;
maxLoansPerCustomer?: number;
/** Loan against deposits: % of the FD / RD balance that can be lent. */
ltvPct?: number;
/** Secured loans need shares of 2.5% of the loan, unsecured 5% (share linking to borrowing). */
secured: boolean;
}
interface DemoMonth {
month: string;
credits: number;
debits: number;
avgBalance: number;
returns: number;
}
interface DemoAccount {
type: 'Savings' | 'Current';
accountNo: string;
months: DemoMonth[];
}
interface DemoLoan {
id: number;
accountNo: string;
productId: number;
status: 'Active' | 'Closed' | 'Written off';
dpd: number;
disbursed: number;
outstanding: number;
overdue: number;
emi: number;
/** EMIs fallen due so far, and how many of them were paid after the due date. */
emisPaid: number;
emisLate: number;
}
interface DemoClient {
id: number;
name: string;
accountNo: string;
age: number;
monthlyIncome?: number;
fdBalance: number;
savingsBalance: number;
loans: DemoLoan[];
/** Paid-up share capital held as a member. */
shareHeld: number;
accounts: DemoAccount[];
}
export const DEMO_PRODUCTS: DemoProduct[] = [
{
id: 1,
name: 'Personal Loan',
minAmount: 25000,
maxAmount: 1000000,
annualRate: 11.5,
tenureMonths: 60,
minAge: 21,
maxAgeAtMaturity: 60,
minMonthlyIncome: 25000,
maxFoir: 50,
maxLoansPerCustomer: 1,
secured: false
},
{
id: 2,
name: 'Home Loan',
minAmount: 500000,
maxAmount: 5000000,
annualRate: 9,
tenureMonths: 240,
minAge: 23,
maxAgeAtMaturity: 70,
minMonthlyIncome: 40000,
maxFoir: 55,
maxLoansPerCustomer: 1,
secured: true
},
{
id: 3,
name: 'Vehicle Loan',
minAmount: 100000,
maxAmount: 1500000,
annualRate: 9.75,
tenureMonths: 84,
minAge: 21,
maxAgeAtMaturity: 65,
minMonthlyIncome: 20000,
maxFoir: 50,
maxLoansPerCustomer: 2,
secured: true
},
{
id: 4,
name: 'Gold Loan',
minAmount: 10000,
maxAmount: 1000000,
annualRate: 9,
tenureMonths: 12,
minAge: 18,
maxLoansPerCustomer: 3,
secured: true
},
{
id: 5,
name: 'Loan against FD',
minAmount: 5000,
maxAmount: 2500000,
annualRate: 8.5,
tenureMonths: 12,
minAge: 18,
ltvPct: 90,
secured: true
},
{
id: 6,
name: 'MSME / Business Loan',
minAmount: 200000,
maxAmount: 2500000,
annualRate: 12,
tenureMonths: 84,
minAge: 25,
maxAgeAtMaturity: 65,
minMonthlyIncome: 50000,
maxFoir: 60,
maxLoansPerCustomer: 1,
secured: true
}
];
/** Membership share every member holds, over and above the shares linked to loans. */
const MEMBERSHIP_SHARE = 1000;
const MONTHS = [
'Apr 2026',
'May 2026',
'Jun 2026',
'Jul 2026',
'Aug 2026',
'Sep 2026'
];
/** Late EMIs a borrower may have in their history and still get a fresh loan. */
const MAX_LATE_EMIS = 2;
/** Cheque / ECS returns allowed in the last six months for a fresh loan. */
const MAX_RETURNS = 1;
function sharePct(productId: number): number {
return DEMO_PRODUCTS.find((p) => p.id === productId)?.secured === false ? 5 : 2.5;
}
function ceil100(n: number): number {
return Math.ceil(n / 100) * 100;
}
const FIRST = [
'Ramesh',
'Sunita',
'Amit',
'Priya',
'Rajesh',
'Kavita',
'Suresh',
'Anjali',
'Mahesh',
'Pooja',
'Dinesh',
'Neha',
'Vijay',
'Rekha',
'Sanjay',
'Meena',
'Arvind',
'Geeta',
'Manoj',
'Seema',
'Rahul',
'Asha',
'Deepak',
'Nisha',
'Ashok',
'Lata',
'Prakash',
'Usha',
'Anil',
'Shobha'
];
const LAST = [
'Sharma',
'Verma',
'Patel',
'Gupta',
'Singh',
'Yadav',
'Jain',
'Agrawal',
'Choudhary',
'Mishra',
'Tiwari',
'Rathore',
'Soni',
'Malviya',
'Dubey',
'Saxena'
];
/** Small deterministic generator so the demo looks the same every time. */
function rng(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) % 4294967296;
return s / 4294967296;
};
}
function emiFor(principal: number, annualRate: number, months: number): number {
const r = annualRate / 1200;
return r === 0 ? principal / months : (principal * r) / (1 - Math.pow(1 + r, -months));
}
function principalForEmi(emi: number, annualRate: number, months: number): number {
const r = annualRate / 1200;
return r === 0 ? emi * months : (emi * (1 - Math.pow(1 + r, -months))) / r;
}
function round(n: number, step = 1): number {
return Math.round(n / step) * step;
}
function buildBook(): DemoClient[] {
const rand = rng(20261019);
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
const clients: DemoClient[] = [];
// Separate streams for the repayment record and the bank accounts, so the book above stays as it was.
const track = rng(7);
const bank = rng(11);
let loanId = 1;
for (let i = 0; i < 64; i++) {
const salaried = rand() < 0.85;
const client: DemoClient = {
id: 1001 + i,
name: `${FIRST[i % FIRST.length]} ${pick(LAST)}`,
accountNo: String(100010 + i).padStart(9, '0'),
age: 22 + Math.floor(rand() * 40),
monthlyIncome: salaried ? round(18000 + rand() * 132000, 500) : undefined,
fdBalance: rand() < 0.45 ? round(25000 + rand() * 775000, 1000) : 0,
savingsBalance: round(2000 + rand() * 148000, 100),
loans: [],
shareHeld: 0,
accounts: []
};
const count = 1 + Math.floor(rand() * 3);
// Behaviour of the borrower decides how all their loans run: most pay on time, some slip, a few go bad.
const behaviour = rand();
for (let k = 0; k < count; k++) {
// Stay within each product's age and loans-per-customer rules, as the bank would have at sanction.
const product = pick(
DEMO_PRODUCTS.filter(
(p) =>
(p.id !== 5 || client.fdBalance > 0) &&
client.age >= (p.minAge ?? 18) &&
client.loans.filter((l) => l.productId === p.id).length < (p.maxLoansPerCustomer ?? 3)
)
);
const disbursed = round(
product.minAmount + rand() * Math.min(product.maxAmount - product.minAmount, 1800000),
1000
);
const closed = rand() < 0.25;
let dpd = 0;
if (!closed) {
if (behaviour > 0.975) {
dpd = 91 + Math.floor(rand() * 900);
} else if (behaviour > 0.72) {
dpd = 1 + Math.floor(rand() * 89);
} else if (behaviour > 0.705) {
dpd = 91 + Math.floor(rand() * 300);
}
}
const writtenOff = !closed && dpd > 700 && rand() < 0.5;
const outstanding = closed || writtenOff ? 0 : round(disbursed * (0.25 + rand() * 0.7), 1);
const emi = round(emiFor(disbursed, product.annualRate, product.tenureMonths), 1);
const emisPaid = closed
? Math.min(product.tenureMonths, 12 + Math.floor(track() * 24))
: writtenOff
? Math.max(3, Math.round(product.tenureMonths * 0.3))
: Math.max(1, Math.round(product.tenureMonths * (1 - outstanding / disbursed)));
// Most good payers never miss a date; some were late once or a few times but are regular today.
let emisLate = 0;
if (behaviour > 0.705) {
emisLate = 2 + Math.floor(track() * 6);
} else if (behaviour > 0.55) {
emisLate = 1 + Math.floor(track() * 4);
}
emisLate = Math.min(emisLate, emisPaid);
client.loans.push({
id: loanId,
accountNo: String(500000 + loanId).padStart(9, '0'),
productId: product.id,
status: closed ? 'Closed' : writtenOff ? 'Written off' : 'Active',
dpd: closed || writtenOff ? 0 : dpd,
disbursed,
outstanding,
overdue: dpd > 0 && !writtenOff ? round(Math.min(outstanding, emi * Math.ceil(dpd / 30)), 1) : 0,
emi: closed || writtenOff ? 0 : emi,
emisPaid,
emisLate
});
loanId++;
}
addBanking(client, bank);
clients.push(client);
}
return clients;
}
/** Savings / current account statement for the last six months and the member's shareholding. */
function addBanking(c: DemoClient, bank: () => number): void {
const active = c.loans.filter((l) => l.status === 'Active');
const emi = active.reduce((s, l) => s + l.emi, 0);
const overdue = active.some((l) => l.dpd > 0);
const late = c.loans.reduce((s, l) => s + l.emisLate, 0);
// Salaried members run a savings account; traders and businesses run a current account.
const business = !c.monthlyIncome;
const turnover = c.monthlyIncome ?? 150000 + bank() * 650000;
const spendRatio = business ? 0.82 + bank() * 0.12 : 0.3 + bank() * 0.3;
let returns = overdue ? 1 + Math.floor(bank() * 3) : late > MAX_LATE_EMIS ? 1 : bank() < 0.08 ? 1 : 0;
const months: DemoMonth[] = MONTHS.map((month, i) => {
const credits = round(turnover * (0.9 + bank() * 0.2) + (business ? 0 : bank() * 3000), 10);
// EMIs go out first; household or business spending is a share of what is left.
const debits = round(Math.max(0, credits - emi) * Math.min(0.97, spendRatio * (0.9 + bank() * 0.2)) + emi, 10);
const r = returns > 0 && (i >= MONTHS.length - returns || bank() < 0.2) ? 1 : 0;
returns -= r;
return {
month,
credits,
debits: overdue ? Math.max(debits, round(credits * 1.02, 10)) : debits,
avgBalance: round(c.savingsBalance * (0.7 + bank() * 0.6), 100),
returns: r
};
});
c.accounts.push({
type: business ? 'Current' : 'Savings',
accountNo: String((business ? 300000 : 200000) + c.id).padStart(9, '0'),
months
});
// Shares linked to borrowing: 2.5% of secured and 5% of unsecured loans. A few members are short.
const required = active.reduce((s, l) => s + (l.disbursed * sharePct(l.productId)) / 100, 0);
const short = required > 0 && bank() < 0.15;
c.shareHeld = MEMBERSHIP_SHARE + ceil100(short ? required * 0.5 : required);
}
const BOOK = buildBook();
const AS_ON = '19-10-2026';
const STAGES: Array<Omit<LoanStageRow, 'loans' | 'outstanding' | 'overdue'>> = [
{ stage: 'STANDARD', label: 'Standard', dpdRange: '0', group: 'PERFORMING' },
{ stage: 'SMA0', label: 'SMA-0', dpdRange: '1-30', group: 'PERFORMING' },
{ stage: 'SMA1', label: 'SMA-1', dpdRange: '31-60', group: 'PERFORMING' },
{ stage: 'SMA2', label: 'SMA-2', dpdRange: '61-90', group: 'PERFORMING' },
{ stage: 'SUBSTANDARD', label: 'Sub-standard', dpdRange: '91-455', group: 'NPA' },
{ stage: 'DOUBTFUL1', label: 'Doubtful D1', dpdRange: '456-820', group: 'NPA' },
{ stage: 'DOUBTFUL2', label: 'Doubtful D2', dpdRange: '821-1550', group: 'NPA' },
{ stage: 'DOUBTFUL3', label: 'Doubtful D3', dpdRange: 'above 1550', group: 'NPA' },
{ stage: 'LOSS', label: 'Loss / written off', dpdRange: 'written off', group: 'CLOSED' },
{ stage: 'CLOSED', label: 'Closed', dpdRange: 'repaid', group: 'CLOSED' }
];
/** RBI IRAC class of a loan on days past due. */
function stageOf(l: DemoLoan): string {
if (l.status === 'Written off') {
return 'LOSS';
}
if (l.status === 'Closed') {
return 'CLOSED';
}
const d = l.dpd;
if (d <= 0) {
return 'STANDARD';
}
if (d <= 30) {
return 'SMA0';
}
if (d <= 60) {
return 'SMA1';
}
if (d <= 90) {
return 'SMA2';
}
if (d <= 455) {
return 'SUBSTANDARD';
}
if (d <= 820) {
return 'DOUBTFUL1';
}
return d <= 1550 ? 'DOUBTFUL2' : 'DOUBTFUL3';
}
function labelOf(stage: string): string {
return STAGES.find((s) => s.stage === stage)?.label ?? stage;
}
function isNpa(stage: string): boolean {
return STAGES.find((s) => s.stage === stage)?.group === 'NPA';
}
function categoryOf(c: DemoClient): BorrowerCategory {
let cat: BorrowerCategory = 'GOOD';
for (const l of c.loans) {
const st = stageOf(l);
if (st === 'LOSS' || isNpa(st)) {
return 'BAD';
}
if (l.status === 'Active' && st !== 'STANDARD') {
cat = 'WATCH';
}
}
return cat;
}
function productName(id: number): string {
return DEMO_PRODUCTS.find((p) => p.id === id)?.name ?? String(id);
}
/** Shares the member should hold against the running loans. */
function shareRequired(c: DemoClient): number {
return c.loans
.filter((l) => l.status === 'Active')
.reduce((s, l) => s + (l.disbursed * sharePct(l.productId)) / 100, 0);
}
/** Six-month averages of the operative account. */
function statement(c: DemoClient) {
const ms = c.accounts.flatMap((a) => a.months);
const n = MONTHS.length;
const credits = ms.reduce((s, m) => s + m.credits, 0) / n;
const debits = ms.reduce((s, m) => s + m.debits, 0) / n;
return {
type: c.accounts[0]?.type ?? 'Savings',
credits,
debits,
surplus: credits - debits,
avgBalance: ms.reduce((s, m) => s + m.avgBalance, 0) / n,
returns: ms.reduce((s, m) => s + m.returns, 0)
};
}
const inr = (n: number) => '₹' + Math.round(n).toLocaleString('en-IN');
function eligibility(c: DemoClient, p: DemoProduct): ProductEligibility {
const passed: string[] = [];
const failed: string[] = [];
const notes: string[] = [];
const active = c.loans.filter((l) => l.status === 'Active');
const currentEmi = active.reduce((s, l) => s + l.emi, 0);
let limit = p.maxAmount;
const paid = c.loans.reduce((s, l) => s + l.emisPaid, 0);
const late = c.loans.reduce((s, l) => s + l.emisLate, 0);
if (late > MAX_LATE_EMIS) {
failed.push(`EMI record: ${late} of ${paid} EMIs paid late (allowed up to ${MAX_LATE_EMIS})`);
} else {
passed.push(`EMI record: ${paid - late} of ${paid} EMIs paid on time`);
}
const st = statement(c);
if (st.returns > MAX_RETURNS) {
failed.push(`${st.returns} cheque / ECS returns in the last 6 months (allowed up to ${MAX_RETURNS})`);
} else {
passed.push(`${st.returns} cheque / ECS return(s) in the last 6 months`);
}
if (st.surplus <= 0) {
failed.push(`Account spending ${inr(st.debits)} a month is more than the credits ${inr(st.credits)}`);
} else {
passed.push(
`${st.type} account: credits ${inr(st.credits)} − expenses ${inr(st.debits)} = ${inr(st.surplus)} left a month`
);
if (p.maxFoir !== undefined) {
limit = Math.min(limit, principalForEmi(st.surplus, p.annualRate, p.tenureMonths));
}
}
if (p.minAge !== undefined) {
if (c.age < p.minAge) {
failed.push(`Age ${c.age} is below the minimum ${p.minAge}`);
} else {
passed.push(`Age ${c.age} (minimum ${p.minAge})`);
}
}
if (p.maxAgeAtMaturity !== undefined) {
const atMaturity = c.age + Math.ceil(p.tenureMonths / 12);
if (atMaturity > p.maxAgeAtMaturity) {
failed.push(`Age at maturity ${atMaturity} exceeds ${p.maxAgeAtMaturity}`);
} else {
passed.push(`Age at maturity ${atMaturity} (maximum ${p.maxAgeAtMaturity})`);
}
}
if (p.minMonthlyIncome !== undefined) {
if (!c.monthlyIncome) {
failed.push(`Monthly income not recorded (minimum ${inr(p.minMonthlyIncome)})`);
} else if (c.monthlyIncome < p.minMonthlyIncome) {
failed.push(`Monthly income ${inr(c.monthlyIncome)} is below ${inr(p.minMonthlyIncome)}`);
} else {
passed.push(`Monthly income ${inr(c.monthlyIncome)} (minimum ${inr(p.minMonthlyIncome)})`);
}
}
if (p.maxLoansPerCustomer !== undefined) {
const running = active.filter((l) => l.productId === p.id).length;
if (running >= p.maxLoansPerCustomer) {
failed.push(`${running} loan(s) of this product already running (maximum ${p.maxLoansPerCustomer})`);
} else {
passed.push(`${running} loan(s) of this product running (maximum ${p.maxLoansPerCustomer})`);
}
}
if (p.maxFoir !== undefined && c.monthlyIncome) {
const room = (c.monthlyIncome * p.maxFoir) / 100 - currentEmi;
if (room <= 0) {
failed.push(`Current EMIs ${inr(currentEmi)} already use the FOIR limit of ${p.maxFoir}% of income`);
} else {
const byFoir = principalForEmi(room, p.annualRate, p.tenureMonths);
passed.push(`FOIR ${p.maxFoir}%: EMI room ${inr(room)} a month supports up to ${inr(byFoir)}`);
limit = Math.min(limit, byFoir);
}
}
if (p.ltvPct !== undefined) {
if (c.fdBalance <= 0) {
failed.push('No FD / RD to pledge');
} else {
const byDeposit = (c.fdBalance * p.ltvPct) / 100;
passed.push(`FD ${inr(c.fdBalance)} at ${p.ltvPct}% allows up to ${inr(byDeposit)}`);
limit = Math.min(limit, byDeposit);
}
}
if (p.id === 4) {
notes.push('Final amount depends on the gold valued at the branch');
}
let eligible = failed.length === 0;
if (eligible && limit < p.minAmount) {
failed.push(`Eligible amount ${inr(limit)} is below the product minimum ${inr(p.minAmount)}`);
eligible = false;
}
const amount = eligible ? round(limit, 1000) : undefined;
const pct = p.secured ? 2.5 : 5;
const shortfall = Math.max(0, shareRequired(c) - (c.shareHeld - MEMBERSHIP_SHARE));
const shareToBuy = amount ? ceil100((amount * pct) / 100 + shortfall) : undefined;
if (amount) {
notes.push(
`Shares of ${inr((amount * pct) / 100)} to be taken (${pct}% of the loan, ${p.secured ? 'secured' : 'unsecured'})`
);
if (shortfall > 0) {
notes.push(`Existing share shortfall of ${inr(shortfall)} on running loans is collected too`);
}
}
notes.push('CIBIL score not checked in this demo');
return {
productId: p.id,
productName: p.name,
eligible,
eligibleAmount: amount,
sharePct: pct,
shareToBuy,
netInHand: amount && shareToBuy !== undefined ? amount - shareToBuy : undefined,
minAmount: p.minAmount,
maxAmount: p.maxAmount,
passed,
failed,
notes
};
}
function toBorrower(c: DemoClient, detail: boolean): Borrower {
const category = categoryOf(c);
const active = c.loans.filter((l) => l.status === 'Active');
const order = STAGES.map((s) => s.stage);
const worst = active.map(stageOf).sort((a, b) => order.indexOf(b) - order.indexOf(a))[0];
const elig = category === 'GOOD' ? DEMO_PRODUCTS.map((p) => eligibility(c, p)) : [];
const loans: BorrowerLoan[] = c.loans.map((l) => ({
loanId: l.id,
accountNo: l.accountNo,
productName: productName(l.productId),
status: l.status,
stage: labelOf(stageOf(l)),
dpd: l.dpd,
emisPaid: l.emisPaid,
emisLate: l.emisLate,
disbursed: l.disbursed,
outstanding: l.outstanding,
overdue: l.overdue,
sharePct: sharePct(l.productId),
shareRequired: l.status === 'Active' ? (l.disbursed * sharePct(l.productId)) / 100 : 0
}));
const st = statement(c);
return {
clientId: c.id,
clientName: c.name,
clientAccountNo: c.accountNo,
category,
activeLoans: active.length,
closedLoans: c.loans.filter((l) => l.status === 'Closed').length,
writtenOffLoans: c.loans.filter((l) => l.status === 'Written off').length,
outstanding: active.reduce((s, l) => s + l.outstanding, 0),
maxDpd: active.reduce((m, l) => Math.max(m, l.dpd), 0),
worstStage: worst ? labelOf(worst) : undefined,
monthlyIncome: c.monthlyIncome,
age: c.age,
depositBalance: c.fdBalance + c.savingsBalance,
currentEmi: active.reduce((s, l) => s + l.emi, 0),
eligibleProducts: elig.filter((e) => e.eligible).length,
emisPaid: c.loans.reduce((s, l) => s + l.emisPaid, 0),
emisLate: c.loans.reduce((s, l) => s + l.emisLate, 0),
shareHeld: c.shareHeld,
shareRequired: MEMBERSHIP_SHARE + shareRequired(c),
accountType: st.type,
avgCredits: st.credits,
avgDebits: st.debits,
returns: st.returns,
accounts: detail ? c.accounts : undefined,
loans: detail ? loans : undefined,
eligibility: detail ? elig : undefined
};
}
export function demoSummary(): LoanEligibilitySummary {
const all = BOOK.flatMap((c) => c.loans);
const stages: LoanStageRow[] = STAGES.map((s) => {
const ls = all.filter((l) => stageOf(l) === s.stage);
return {
...s,
loans: ls.length,
outstanding: ls.reduce((a, l) => a + l.outstanding, 0),
overdue: ls.reduce((a, l) => a + l.overdue, 0)
};
});
const active = all.filter((l) => l.status === 'Active');
const npa = active.filter((l) => isNpa(stageOf(l)));
const activeOut = active.reduce((a, l) => a + l.outstanding, 0);
const npaOut = npa.reduce((a, l) => a + l.outstanding, 0);
const cats = BOOK.map(categoryOf);
const linked = BOOK.filter((c) => shareRequired(c) > 0);
const shortOf = (c: DemoClient) => Math.max(0, shareRequired(c) - (c.shareHeld - MEMBERSHIP_SHARE));
return {
shareRequired: linked.reduce((a, c) => a + shareRequired(c), 0),
shareHeld: linked.reduce((a, c) => a + c.shareHeld - MEMBERSHIP_SHARE, 0),
shareShortMembers: linked.filter((c) => shortOf(c) > 0).length,
shareShortfall: linked.reduce((a, c) => a + shortOf(c), 0),
asOnDate: AS_ON,
activeLoans: active.length,
activeOutstanding: activeOut,
performingLoans: active.length - npa.length,
performingOutstanding: activeOut - npaOut,
npaLoans: npa.length,
npaOutstanding: npaOut,
grossNpaPct: activeOut > 0 ? Math.round((npaOut / activeOut) * 10000) / 100 : 0,
closedLoans: all.filter((l) => l.status === 'Closed').length,
writtenOffLoans: all.filter((l) => l.status === 'Written off').length,
writtenOffAmount: all.filter((l) => l.status === 'Written off').reduce((a, l) => a + l.disbursed * 0.6, 0),
goodBorrowers: cats.filter((c) => c === 'GOOD').length,
watchBorrowers: cats.filter((c) => c === 'WATCH').length,
badBorrowers: cats.filter((c) => c === 'BAD').length,
stages,
products: DEMO_PRODUCTS.map((p) => {
const ls = active.filter((l) => l.productId === p.id);
const bad = ls.filter((l) => isNpa(stageOf(l)));
return {
productId: p.id,
productName: p.name,
activeLoans: ls.length,
performingLoans: ls.length - bad.length,
npaLoans: bad.length,
outstanding: ls.reduce((a, l) => a + l.outstanding, 0),
npaOutstanding: bad.reduce((a, l) => a + l.outstanding, 0)
};
}).sort((a, b) => b.outstanding - a.outstanding),
notes: [
'Demo data: the figures are illustrative, not the bank’s books.',
'Share linking to borrowing: members hold shares of 5% of unsecured and 2.5% of secured loans (UCBs keeping CRAR of 12% on a continuous basis are exempt).',
'Classification as per RBI IRAC norms on days past due (90+ days = NPA; Sub-standard up to 12 months as NPA, then Doubtful D1 / D2 / D3).',
'Eligibility uses each product’s rules: age, age at maturity, monthly income, FOIR, loans per customer and, for loans against FD, the LTV on the deposit.'
]
};
}
export function demoBorrowers(category: BorrowerCategory | '', search: string): Borrower[] {
const q = search.trim().toLowerCase();
return BOOK.filter((c) => !category || categoryOf(c) === category)
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.accountNo.includes(q))
.map((c) => toBorrower(c, false))
.sort(
(a, b) =>
b.maxDpd - a.maxDpd || b.eligibleProducts - a.eligibleProducts || a.clientName.localeCompare(b.clientName)
);
}
export function demoBorrower(clientId: number): Borrower | undefined {
const c = BOOK.find((x) => x.id === clientId);
return c ? toBorrower(c, true) : undefined;
}
@@ -0,0 +1,15 @@
/**
* 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 { NgModule } from '@angular/core';
import { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
@NgModule({
imports: [LoanEligibilityRoutingModule]
})
export class LoanEligibilityModule {}
@@ -0,0 +1,154 @@
/**
* 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 { Injectable } from '@angular/core';
import { Observable, delay, of, throwError } from 'rxjs';
import { demoBorrower, demoBorrowers, demoSummary } from './loan-eligibility.mock';
export type BorrowerCategory = 'GOOD' | 'WATCH' | 'BAD';
export interface LoanStageRow {
stage: string;
label: string;
dpdRange: string;
group: 'PERFORMING' | 'NPA' | 'CLOSED';
loans: number;
outstanding: number;
overdue: number;
}
export interface LoanProductRow {
productId: number;
productName: string;
activeLoans: number;
performingLoans: number;
npaLoans: number;
outstanding: number;
npaOutstanding: number;
}
/** Loan book health as on the business date (RBI asset classes, products, borrowers). */
export interface LoanEligibilitySummary {
asOnDate: string;
activeLoans: number;
activeOutstanding: number;
performingLoans: number;
performingOutstanding: number;
npaLoans: number;
npaOutstanding: number;
grossNpaPct: number;
closedLoans: number;
writtenOffLoans: number;
writtenOffAmount: number;
/** Share linking to borrowing across members with running loans. */
shareRequired: number;
shareHeld: number;
shareShortMembers: number;
shareShortfall: number;
goodBorrowers: number;
watchBorrowers: number;
badBorrowers: number;
stages: LoanStageRow[];
products: LoanProductRow[];
notes: string[];
}
export interface BorrowerLoan {
loanId: number;
accountNo: string;
productName: string;
status: string;
stage: string;
dpd: number;
emisPaid: number;
emisLate: number;
disbursed: number;
outstanding: number;
overdue: number;
sharePct: number;
shareRequired: number;
}
export interface AccountMonth {
month: string;
credits: number;
debits: number;
avgBalance: number;
returns: number;
}
/** Savings / current account statement, month by month. */
export interface BorrowerAccount {
type: 'Savings' | 'Current';
accountNo: string;
months: AccountMonth[];
}
export interface ProductEligibility {
productId: number;
productName: string;
eligible: boolean;
eligibleAmount?: number;
sharePct: number;
/** Shares to be taken with this loan (incl. any existing shortfall). */
shareToBuy?: number;
netInHand?: number;
minAmount?: number;
maxAmount?: number;
passed: string[];
failed: string[];
notes: string[];
}
export interface Borrower {
clientId: number;
clientName: string;
clientAccountNo: string;
category: BorrowerCategory;
activeLoans: number;
closedLoans: number;
writtenOffLoans: number;
outstanding: number;
maxDpd: number;
worstStage?: string;
monthlyIncome?: number;
age?: number;
depositBalance: number;
currentEmi: number;
eligibleProducts: number;
/** Repayment record over all loans: EMIs fallen due and how many were paid late. */
emisPaid: number;
emisLate: number;
shareHeld: number;
shareRequired: number;
/** Six-month averages of the operative account. */
accountType: 'Savings' | 'Current';
avgCredits: number;
avgDebits: number;
returns: number;
accounts?: BorrowerAccount[];
loans?: BorrowerLoan[];
eligibility?: ProductEligibility[];
}
/** Loan Eligibility (AI / Intelligence – Lending & Risk): demo data, worked out on the eligibility rules. */
@Injectable({ providedIn: 'root' })
export class LoanEligibilityService {
getSummary(): Observable<LoanEligibilitySummary> {
return of(demoSummary()).pipe(delay(2800));
}
getBorrowers(category: BorrowerCategory | '', search: string): Observable<Borrower[]> {
return of(demoBorrowers(category, search)).pipe(delay(700));
}
getBorrower(clientId: number): Observable<Borrower> {
const b = demoBorrower(clientId);
return b ? of(b).pipe(delay(900)) : throwError(() => new Error('Borrower not found'));
}
}