feat(ai): loan eligibility demo screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 & 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 & 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 & products)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="view === 'BORROWERS'"
|
||||
(click)="view = 'BORROWERS'"
|
||||
>
|
||||
Borrowers & 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user