From f9748036c316756f775989b427f3cd29d2222e4c Mon Sep 17 00:00:00 2001 From: anshul Date: Mon, 19 Oct 2026 15:18:59 +0530 Subject: [PATCH] feat(ai): loan eligibility demo screen Co-Authored-By: Claude Opus 5.5 --- src/app/app.module.ts | 2 + .../core/shell/toolbar/toolbar.component.html | 2 +- .../loan-eligibility-routing.module.ts | 27 + .../loan-eligibility.component.html | 482 ++++++++++++ .../loan-eligibility.component.scss | 312 ++++++++ .../loan-eligibility.component.ts | 187 +++++ .../loan-eligibility/loan-eligibility.mock.ts | 726 ++++++++++++++++++ .../loan-eligibility.module.ts | 15 + .../loan-eligibility.service.ts | 154 ++++ 9 files changed, 1906 insertions(+), 1 deletion(-) create mode 100644 src/app/loan-eligibility/loan-eligibility-routing.module.ts create mode 100644 src/app/loan-eligibility/loan-eligibility.component.html create mode 100644 src/app/loan-eligibility/loan-eligibility.component.scss create mode 100644 src/app/loan-eligibility/loan-eligibility.component.ts create mode 100644 src/app/loan-eligibility/loan-eligibility.mock.ts create mode 100644 src/app/loan-eligibility/loan-eligibility.module.ts create mode 100644 src/app/loan-eligibility/loan-eligibility.service.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5d1963c87..1f9b2ba1d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 7ad4589d4..fbb86e720 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -154,7 +154,7 @@ Lending & Risk - + + + + @if (view === 'BOOK') { +
+ + + + + + + + + + + + + + @for (st of performingStages; track st.stage) { + + + + + + + + + } + @for (st of npaStages; track st.stage) { + + + + + + + + + } + @for (st of closedStages; track st.stage) { + + + + + + + + + } + +
ClassDays past dueLoansOutstandingOverdueShare of book
+ + {{ st.label }} + {{ st.dpdRange }}{{ st.loans }}₹{{ st.outstanding | number: '1.2-2' }}₹{{ st.overdue | number: '1.2-2' }} + {{ share(st) }}% +
{{ st.label }} (NPA){{ st.dpdRange }}{{ st.loans }}₹{{ st.outstanding | number: '1.2-2' }}₹{{ st.overdue | number: '1.2-2' }} + {{ share(st) }}% +
{{ st.label }}{{ st.dpdRange }}{{ st.loans }}––
+
+ +
+ + + + + + + + + + + + + + @for (p of s.products; track p.productId) { + + + + + + + + + } @empty { + + + + } + +
ProductActiveRunning wellNPAOutstandingNPA outstanding
{{ p.productName }}{{ p.activeLoans }}{{ p.performingLoans }}{{ p.npaLoans }}₹{{ p.outstanding | number: '1.2-2' }}₹{{ p.npaOutstanding | number: '1.2-2' }}
No active loans.
+
+ +
+ +
+ Shares required on running loans: ₹{{ s.shareRequired | number: '1.0-0' }} + Shares held by these members: ₹{{ s.shareHeld | number: '1.0-0' }} + Members short: {{ s.shareShortMembers }} (₹{{ s.shareShortfall | number: '1.0-0' }} to collect) +
+
+ } + + @if (view === 'BORROWERS') { +
+ +
+ @for (c of categories; track c.value) { + + } + + +
+

+ 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). +

+ + + + + + + + + + + + + + + + @if (borrowersLoading) { + + + + } + @for (b of borrowersLoading ? [] : pagedBorrowers; track b.clientId) { + + + + + + + + + + + + } @empty { + + + + } + +
BorrowerCategoryRunning / ClosedEMI recordOutstandingMax days overdueWorst classMore loan?
+ Fetching borrowers… +
+ {{ b.clientName }} ({{ b.clientAccountNo }}) + + {{ + categoryLabel(b.category) + }} + {{ b.activeLoans }} / {{ b.closedLoans }} + {{ b.emisPaid - b.emisLate }} / {{ b.emisPaid }} on time + @if (b.emisLate) { + ({{ b.emisLate }} late) + } + ₹{{ b.outstanding | number: '1.2-2' }}{{ b.maxDpd }}{{ b.worstStage || '–' }} + @if (b.eligibleProducts > 0) { + Yes · {{ b.eligibleProducts }} products + } @else { + No + } + + +
No borrowers in this category.
+ @if (borrowers.length > pageSize) { +
+ Showing {{ page * pageSize + 1 }}–{{ pageEnd }} of {{ borrowers.length }} + + @for (p of pages; track p) { + + } + +
+ } +
+ } + + @if (selected; as b) { +
+ +
+ } + + + } + diff --git a/src/app/loan-eligibility/loan-eligibility.component.scss b/src/app/loan-eligibility/loan-eligibility.component.scss new file mode 100644 index 000000000..cd8d224c6 --- /dev/null +++ b/src/app/loan-eligibility/loan-eligibility.component.scss @@ -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; +} diff --git a/src/app/loan-eligibility/loan-eligibility.component.ts b/src/app/loan-eligibility/loan-eligibility.component.ts new file mode 100644 index 000000000..937fdc0bb --- /dev/null +++ b/src/app/loan-eligibility/loan-eligibility.component.ts @@ -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; + + 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); + } +} diff --git a/src/app/loan-eligibility/loan-eligibility.mock.ts b/src/app/loan-eligibility/loan-eligibility.mock.ts new file mode 100644 index 000000000..a72517801 --- /dev/null +++ b/src/app/loan-eligibility/loan-eligibility.mock.ts @@ -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 = (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> = [ + { 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; +} diff --git a/src/app/loan-eligibility/loan-eligibility.module.ts b/src/app/loan-eligibility/loan-eligibility.module.ts new file mode 100644 index 000000000..01f9c7e46 --- /dev/null +++ b/src/app/loan-eligibility/loan-eligibility.module.ts @@ -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 {} diff --git a/src/app/loan-eligibility/loan-eligibility.service.ts b/src/app/loan-eligibility/loan-eligibility.service.ts new file mode 100644 index 000000000..f4255571d --- /dev/null +++ b/src/app/loan-eligibility/loan-eligibility.service.ts @@ -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 { + return of(demoSummary()).pipe(delay(2800)); + } + + getBorrowers(category: BorrowerCategory | '', search: string): Observable { + return of(demoBorrowers(category, search)).pipe(delay(700)); + } + + getBorrower(clientId: number): Observable { + const b = demoBorrower(clientId); + return b ? of(b).pipe(delay(900)) : throwError(() => new Error('Borrower not found')); + } +}