feat(loan-origination): product type, income/co-borrower affordability, bank statement & CIC analysis, richer Field Investigation, and meeting-scheduled Personal Discussion
- New Application: add Product Type (Term Loan/Overdraft) driven by product eligibility. - KYC: 3-year ITR capture with exceptions, product-specific document checklists with officer-added custom docs, Housing Loan co-owner Aadhaar/PAN capture+verify in a popup dialog. - Income & Affordability: editable income/amount/tenure with product bounds validation, 40% EMI rule, and a full co-borrower flow (relationship, salary slips, combined-income eligibility). - Bank Statement upload auto-opens a full CSV-driven cash-flow/FOIR/risk analysis dialog. - CIC report upload auto-opens a bureau-style credit report analysis (score gauge, accounts, DPD history, enquiries) generated from the application's own credit score. - Field Investigation reworked into per-address cards (Home/Office/Property/Shop) with officer assignment, 48-hour SLA tracking, GPS + multi-photo capture, verify/reject with remarks, a full-detail popup, real Fineract addresses for existing clients, and a calculated property valuation (market/guideline/realizable value, LTV). - Personal Discussion: meeting scheduler (date/time/mode/officer) gating the discussion report, plus a full Application Summary Report PDF (KYC, CIC, FI, valuation, stage history) for the meeting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+126
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="bsa-title">
|
||||
<span>Bank Statement Analysis</span>
|
||||
<span class="bsa-title-file">{{ data.fileName }}</span>
|
||||
</h2>
|
||||
<mat-dialog-content class="bsa-content">
|
||||
<div class="bsa-hero">
|
||||
<div class="bsa-gauge" [style.--bsa-gauge-pct]="summary.riskScore" [style.--bsa-gauge-color]="gaugeColor">
|
||||
<div class="bsa-gauge-inner">
|
||||
<span class="bsa-gauge-score">{{ summary.riskScore }}</span>
|
||||
<span class="bsa-gauge-outof">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bsa-hero-details">
|
||||
<span [class]="riskBadgeClass">{{ summary.riskRating }} RISK</span>
|
||||
<ul class="bsa-risk-reasons">
|
||||
@for (reason of summary.riskReasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (summary.bouncedTransactions.length > 0) {
|
||||
<div class="bsa-bounce-alert">
|
||||
<div class="bsa-bounce-alert-title">
|
||||
{{ summary.bouncedTransactions.length }} Bounced / Returned Transaction(s) Found
|
||||
</div>
|
||||
<ul>
|
||||
@for (b of summary.bouncedTransactions; track b.date) {
|
||||
<li>{{ b.date | date: 'dd-MM-yyyy' }} — {{ b.narration }} (₹{{ b.amount | number: '1.0-0' }})</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bsa-kpi-grid">
|
||||
<div class="bsa-kpi bsa-kpi--income">
|
||||
<span class="bsa-kpi-label">Avg Monthly Income</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.avgMonthlyCredit | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--expense">
|
||||
<span class="bsa-kpi-label">Avg Monthly Expense</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.avgMonthlyDebit | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Average Balance</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.avgBalance | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Closing Balance</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.closingBalance | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Minimum Balance</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.minBalance | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Maximum Balance</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.maxBalance | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--expense">
|
||||
<span class="bsa-kpi-label">Existing EMI / Loan Obligations</span>
|
||||
<span class="bsa-kpi-value">₹{{ summary.existingMonthlyObligations | number: '1.0-0' }}/mo</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">FOIR (Obligations ÷ Income)</span>
|
||||
<span class="bsa-kpi-value">{{ summary.foirPercent | number: '1.0-1' }}%</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Income Stability</span>
|
||||
<span class="bsa-kpi-value">{{ summary.incomeStability === 'STABLE' ? 'Stable' : 'Irregular' }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--income">
|
||||
<span class="bsa-kpi-label">Positive Cash-Flow Months</span>
|
||||
<span class="bsa-kpi-value">{{ summary.positiveCashFlowMonths }} / {{ summary.monthsCovered }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--expense">
|
||||
<span class="bsa-kpi-label">Negative Cash-Flow Months</span>
|
||||
<span class="bsa-kpi-value">{{ summary.negativeCashFlowMonths }} / {{ summary.monthsCovered }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Salary Credits Detected</span>
|
||||
<span class="bsa-kpi-value">{{ summary.salaryCreditsDetected }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Total Transactions</span>
|
||||
<span class="bsa-kpi-value">{{ summary.transactionCount }}</span>
|
||||
</div>
|
||||
<div class="bsa-kpi bsa-kpi--neutral">
|
||||
<span class="bsa-kpi-label">Period Covered</span>
|
||||
<span class="bsa-kpi-value">{{ summary.monthsCovered }} months</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bsa-chart-panel">
|
||||
<h3 class="bsa-chart-panel-title">Monthly Income vs Expense</h3>
|
||||
<div class="bsa-chart-canvas-wrap">
|
||||
<canvas id="bsaIncomeExpenseChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bsa-chart-panel">
|
||||
<h3 class="bsa-chart-panel-title">Account Balance Trend</h3>
|
||||
<div class="bsa-chart-canvas-wrap">
|
||||
<canvas id="bsaBalanceTrendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@if (summary.categoryBreakdown.length > 0) {
|
||||
<div class="bsa-chart-panel">
|
||||
<h3 class="bsa-chart-panel-title">Where the Money Goes</h3>
|
||||
<div class="bsa-chart-canvas-wrap bsa-chart-canvas-wrap--category">
|
||||
<canvas id="bsaCategoryChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||
</mat-dialog-actions>
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.bsa-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bsa-title-file {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bsa-content {
|
||||
min-width: 520px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
// -- Risk hero: a CSS conic-gradient gauge ring (no canvas dependency, always pixel-correct) --
|
||||
.bsa-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f8fafc, #eef2ff);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.bsa-gauge {
|
||||
--bsa-gauge-pct: 0;
|
||||
--bsa-gauge-color: #10b981;
|
||||
|
||||
flex-shrink: 0;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: conic-gradient(var(--bsa-gauge-color) calc(var(--bsa-gauge-pct) * 1%), #e2e8f0 0);
|
||||
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.bsa-gauge-inner {
|
||||
width: 98px;
|
||||
height: 98px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px #f1f5f9;
|
||||
}
|
||||
|
||||
.bsa-gauge-score {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bsa-gauge-outof {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bsa-hero-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bsa-risk-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--low {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.bsa-risk-reasons {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// -- Bounced/returned transactions -- the strongest single red flag, called out on its own --
|
||||
.bsa-bounce-alert {
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.bsa-bounce-alert-title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #b91c1c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bsa-bounce-alert ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// -- KPI cards --
|
||||
.bsa-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.bsa-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #cbd5e1;
|
||||
|
||||
&--income {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
&--expense {
|
||||
border-left-color: #f43f5e;
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
border-left-color: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
.bsa-kpi-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bsa-kpi-value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// -- Chart panels --
|
||||
.bsa-chart-panel {
|
||||
padding: 18px 20px 8px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.bsa-chart-panel-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.bsa-chart-canvas-wrap {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
|
||||
&--category {
|
||||
height: 280px;
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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 { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { BankStatementSummary } from './bank-statement-analysis.util';
|
||||
|
||||
Chart.register(...registerables);
|
||||
Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
|
||||
Chart.defaults.color = '#64748b';
|
||||
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||
Chart.defaults.plugins.legend.labels.boxWidth = 8;
|
||||
|
||||
export interface BankStatementAnalysisDialogData {
|
||||
fileName: string;
|
||||
summary: BankStatementSummary;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
'#6366f1',
|
||||
'#10b981',
|
||||
'#f43f5e',
|
||||
'#f59e0b',
|
||||
'#0ea5e9',
|
||||
'#a855f7',
|
||||
'#ec4899',
|
||||
'#14b8a6',
|
||||
'#94a3b8',
|
||||
'#84cc16',
|
||||
'#fb923c'
|
||||
];
|
||||
|
||||
/**
|
||||
* Shows a bank statement's transaction history rolled up into what a credit officer actually
|
||||
* needs from it -- a plain-language risk read, existing-obligation load (FOIR), bounced-
|
||||
* transaction red flags, monthly income vs expense, the balance trend, and where the money
|
||||
* actually goes -- opened straight after a CSV bank statement is picked in the KYC step, no
|
||||
* separate "analyze" action needed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-bank-statement-analysis-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './bank-statement-analysis-dialog.component.html',
|
||||
styleUrls: ['./bank-statement-analysis-dialog.component.scss']
|
||||
})
|
||||
export class BankStatementAnalysisDialogComponent implements AfterViewInit, OnDestroy {
|
||||
data = inject<BankStatementAnalysisDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
private barChart: any = null;
|
||||
private balanceChart: any = null;
|
||||
private categoryChart: any = null;
|
||||
|
||||
get summary(): BankStatementSummary {
|
||||
return this.data.summary;
|
||||
}
|
||||
|
||||
get riskBadgeClass(): string {
|
||||
return `bsa-risk-badge bsa-risk-badge--${this.summary.riskRating.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Drives the CSS conic-gradient gauge ring -- a plain custom-property, not a canvas chart, so
|
||||
* it always renders pixel-correct without depending on Chart.js's own gauge quirks. */
|
||||
get gaugeColor(): string {
|
||||
return this.summary.riskRating === 'LOW' ? '#10b981' : this.summary.riskRating === 'MEDIUM' ? '#f59e0b' : '#f43f5e';
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const s = this.summary;
|
||||
const labels = s.monthly.map((m) => m.label);
|
||||
|
||||
const barCtx = (document.getElementById('bsaIncomeExpenseChart') as HTMLCanvasElement)?.getContext('2d');
|
||||
const balanceCtx = (document.getElementById('bsaBalanceTrendChart') as HTMLCanvasElement)?.getContext('2d');
|
||||
|
||||
const balanceGradient = balanceCtx?.createLinearGradient(0, 0, 0, 260);
|
||||
balanceGradient?.addColorStop(0, 'rgba(99, 102, 241, 0.35)');
|
||||
balanceGradient?.addColorStop(1, 'rgba(99, 102, 241, 0)');
|
||||
|
||||
this.barChart = new Chart(barCtx ?? 'bsaIncomeExpenseChart', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Credits (Income)',
|
||||
data: s.monthly.map((m) => m.credit),
|
||||
backgroundColor: '#10b981',
|
||||
borderRadius: 6,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.6
|
||||
},
|
||||
{
|
||||
label: 'Debits (Expense)',
|
||||
data: s.monthly.map((m) => m.debit),
|
||||
backgroundColor: '#f43f5e',
|
||||
borderRadius: 6,
|
||||
barPercentage: 0.7,
|
||||
categoryPercentage: 0.6
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', align: 'end' } },
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: { beginAtZero: true, grid: { color: '#f1f5f9' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.balanceChart = new Chart(balanceCtx ?? 'bsaBalanceTrendChart', {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'End-of-Month Balance',
|
||||
data: s.monthly.map((m) => m.closingBalance),
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: balanceGradient ?? 'rgba(99, 102, 241, 0.15)',
|
||||
pointBackgroundColor: '#6366f1',
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 6,
|
||||
borderWidth: 2.5,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: { beginAtZero: false, grid: { color: '#f1f5f9' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (s.categoryBreakdown.length) {
|
||||
this.categoryChart = new Chart('bsaCategoryChart', {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: s.categoryBreakdown.map((c) => c.category),
|
||||
datasets: [
|
||||
{
|
||||
data: s.categoryBreakdown.map((c) => c.amount),
|
||||
backgroundColor: s.categoryBreakdown.map((_, i) => CATEGORY_COLORS[i % CATEGORY_COLORS.length]),
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
hoverOffset: 8
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '68%',
|
||||
plugins: { legend: { position: 'right', labels: { padding: 14 } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.barChart?.destroy();
|
||||
this.balanceChart?.destroy();
|
||||
this.categoryChart?.destroy();
|
||||
}
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
export interface BankTransaction {
|
||||
date: Date;
|
||||
narration: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface MonthlyBankSummary {
|
||||
label: string;
|
||||
credit: number;
|
||||
debit: number;
|
||||
closingBalance: number;
|
||||
netCashFlow: number;
|
||||
}
|
||||
|
||||
export interface BouncedTransaction {
|
||||
date: Date;
|
||||
narration: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface CategorySpend {
|
||||
category: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export type RiskRating = 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
|
||||
export interface BankStatementSummary {
|
||||
openingBalance: number;
|
||||
closingBalance: number;
|
||||
minBalance: number;
|
||||
maxBalance: number;
|
||||
avgBalance: number;
|
||||
totalCredit: number;
|
||||
totalDebit: number;
|
||||
avgMonthlyCredit: number;
|
||||
avgMonthlyDebit: number;
|
||||
transactionCount: number;
|
||||
monthsCovered: number;
|
||||
salaryCreditsDetected: number;
|
||||
monthly: MonthlyBankSummary[];
|
||||
|
||||
// -- Underwriting-focused signals a loan officer actually needs, not just cash-flow totals --
|
||||
/** Bounced/returned debits (ECS return, cheque return, insufficient funds) -- the single
|
||||
* strongest red flag a bank statement carries; a clean cash-flow summary can still hide these
|
||||
* if nobody goes looking for the narration text specifically. */
|
||||
bouncedTransactions: BouncedTransaction[];
|
||||
/** Recurring EMI/loan-type debits already going out every month, annualised to a monthly
|
||||
* average -- the "existing obligations" side of a FOIR (Fixed Obligation to Income Ratio)
|
||||
* calculation. */
|
||||
existingMonthlyObligations: number;
|
||||
/** existingMonthlyObligations as a % of avgMonthlyCredit -- how much of this customer's income
|
||||
* is already committed before a new EMI is even added. */
|
||||
foirPercent: number;
|
||||
/** Coefficient-of-variation based read on whether monthly income credits land at a
|
||||
* similar amount each month (salaried-style) or swing widely (irregular/gig income). */
|
||||
incomeStability: 'STABLE' | 'IRREGULAR';
|
||||
/** How many of the covered months closed with more money in than out. */
|
||||
positiveCashFlowMonths: number;
|
||||
negativeCashFlowMonths: number;
|
||||
/** Where the money actually goes -- narration-keyword-classified debit totals, for the
|
||||
* expense-breakdown chart. */
|
||||
categoryBreakdown: CategorySpend[];
|
||||
/** A transparent, rule-based read (not a black-box score) an officer can act on immediately,
|
||||
* plus the specific reasons behind it so it can be checked against the actual statement. */
|
||||
riskRating: RiskRating;
|
||||
riskScore: number;
|
||||
riskReasons: string[];
|
||||
}
|
||||
|
||||
/** Splits one CSV line into fields, honouring double-quoted values that may contain commas --
|
||||
* a plain `.split(',')` would break on a quoted narration like "UPI-RENT, LANDLORD A/C". */
|
||||
function splitCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
fields.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
fields.push(current);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseDDMMYYYY(value: string | undefined): Date | null {
|
||||
const match = (value || '').trim().match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [
|
||||
,
|
||||
day,
|
||||
month,
|
||||
year
|
||||
] = match;
|
||||
return new Date(+year, +month - 1, +day);
|
||||
}
|
||||
|
||||
/** Parses a bank-statement CSV export (Date, Narration, Debit, Credit, Balance columns, in any
|
||||
* order, matched case-insensitively) into transaction rows. Recognises a couple of common
|
||||
* header aliases (e.g. "Withdrawal"/"Deposit") since not every bank's export uses "Debit"/
|
||||
* "Credit" literally, but otherwise expects the same shape every Indian bank statement uses. */
|
||||
export function parseBankStatementCsv(text: string): BankTransaction[] {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const header = splitCsvLine(lines[0]).map((h) => h.trim().toLowerCase());
|
||||
const columnIndex = (aliases: string[]): number => {
|
||||
for (const alias of aliases) {
|
||||
const idx = header.indexOf(alias);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const dateIdx = columnIndex([
|
||||
'date',
|
||||
'txn date',
|
||||
'transaction date'
|
||||
]);
|
||||
const narrationIdx = columnIndex([
|
||||
'narration',
|
||||
'description',
|
||||
'particulars'
|
||||
]);
|
||||
const debitIdx = columnIndex([
|
||||
'debit',
|
||||
'withdrawal amt.',
|
||||
'withdrawal'
|
||||
]);
|
||||
const creditIdx = columnIndex([
|
||||
'credit',
|
||||
'deposit amt.',
|
||||
'deposit'
|
||||
]);
|
||||
const balanceIdx = columnIndex([
|
||||
'balance',
|
||||
'closing balance'
|
||||
]);
|
||||
if (dateIdx < 0 || balanceIdx < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const transactions: BankTransaction[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = splitCsvLine(lines[i]);
|
||||
const date = parseDDMMYYYY(cols[dateIdx]);
|
||||
if (!date) {
|
||||
continue;
|
||||
}
|
||||
transactions.push({
|
||||
date,
|
||||
narration: narrationIdx >= 0 ? (cols[narrationIdx] || '').trim() : '',
|
||||
debit: parseFloat(cols[debitIdx]) || 0,
|
||||
credit: parseFloat(cols[creditIdx]) || 0,
|
||||
balance: parseFloat(cols[balanceIdx]) || 0
|
||||
});
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
|
||||
const BOUNCE_KEYWORDS = [
|
||||
'RETURN',
|
||||
'BOUNCE',
|
||||
'DISHONOUR',
|
||||
'DISHONOR',
|
||||
'INSUFFICIENT FUND',
|
||||
'INSUFFICIENT BALANCE'
|
||||
];
|
||||
|
||||
function isBouncedTransaction(narration: string): boolean {
|
||||
const n = narration.toUpperCase();
|
||||
return BOUNCE_KEYWORDS.some((k) => n.includes(k));
|
||||
}
|
||||
|
||||
function isExistingObligationDebit(narration: string): boolean {
|
||||
const n = narration.toUpperCase();
|
||||
return (n.includes('EMI') || n.includes('LOAN')) && !isBouncedTransaction(n);
|
||||
}
|
||||
|
||||
/** Keyword categories checked in this order -- first match wins, so a more specific label (e.g.
|
||||
* Insurance) is claimed before a generic one (e.g. Bank Charges) can grab it. */
|
||||
const CATEGORY_RULES: Array<{ category: string; keywords: string[] }> = [
|
||||
{ category: 'Bounced / Returned', keywords: BOUNCE_KEYWORDS },
|
||||
{ category: 'Insurance', keywords: [
|
||||
'LIC',
|
||||
'INSURANCE',
|
||||
'PREMIUM'
|
||||
] },
|
||||
{ category: 'EMI / Loan Repayment', keywords: [
|
||||
'EMI',
|
||||
'LOAN'
|
||||
] },
|
||||
{ category: 'Rent', keywords: ['RENT'] },
|
||||
{ category: 'Utilities & Bills', keywords: [
|
||||
'ELECTRICITY',
|
||||
'RECHARGE',
|
||||
'BBPS',
|
||||
'BILL',
|
||||
'JIO'
|
||||
] },
|
||||
{ category: 'Groceries', keywords: [
|
||||
'BIGBASKET',
|
||||
'DMART',
|
||||
'RELIANCE FRESH',
|
||||
'KIRANA',
|
||||
'SUPERMARKET',
|
||||
'GROCERY'
|
||||
] },
|
||||
{ category: 'Dining & Food Delivery', keywords: [
|
||||
'SWIGGY',
|
||||
'ZOMATO',
|
||||
'PIZZA',
|
||||
'CAFE',
|
||||
'RESTAURANT'
|
||||
] },
|
||||
{ category: 'Shopping', keywords: [
|
||||
'AMAZON',
|
||||
'FLIPKART',
|
||||
'MYNTRA',
|
||||
'AJIO',
|
||||
'GARMENTS',
|
||||
'SHOP'
|
||||
] },
|
||||
{ category: 'Fuel & Transport', keywords: [
|
||||
'PETROL',
|
||||
'FUEL',
|
||||
'AUTO FARE'
|
||||
] },
|
||||
{ category: 'Cash Withdrawal', keywords: ['ATM'] },
|
||||
{ category: 'Bank Charges', keywords: [
|
||||
'CHARGE',
|
||||
'FEE',
|
||||
'PENALTY'
|
||||
] }
|
||||
];
|
||||
|
||||
function categorize(narration: string): string {
|
||||
const n = narration.toUpperCase();
|
||||
for (const rule of CATEGORY_RULES) {
|
||||
if (rule.keywords.some((k) => n.includes(k))) {
|
||||
return rule.category;
|
||||
}
|
||||
}
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
/** Coefficient of variation on the months that actually saw a credit -- a salaried applicant's
|
||||
* income lands at a similar figure every month (low variation); gig/business income swings
|
||||
* much more (high variation), which is exactly the distinction a lender needs to know before
|
||||
* trusting a monthly average. */
|
||||
function assessIncomeStability(monthly: MonthlyBankSummary[]): 'STABLE' | 'IRREGULAR' {
|
||||
const credits = monthly.map((m) => m.credit).filter((c) => c > 0);
|
||||
if (credits.length < 2) {
|
||||
return 'STABLE';
|
||||
}
|
||||
const mean = credits.reduce((sum, c) => sum + c, 0) / credits.length;
|
||||
const variance = credits.reduce((sum, c) => sum + (c - mean) ** 2, 0) / credits.length;
|
||||
const coefficientOfVariation = mean > 0 ? Math.sqrt(variance) / mean : 0;
|
||||
return coefficientOfVariation < 0.25 ? 'STABLE' : 'IRREGULAR';
|
||||
}
|
||||
|
||||
/** A transparent, rule-based risk read instead of an opaque score -- every point lost is tied to
|
||||
* a specific, checkable reason, which is what a real credit decision needs to be defensible. */
|
||||
function assessRisk(input: {
|
||||
bounceCount: number;
|
||||
foirPercent: number;
|
||||
minBalance: number;
|
||||
avgMonthlyCredit: number;
|
||||
incomeStability: 'STABLE' | 'IRREGULAR';
|
||||
}): { rating: RiskRating; score: number; reasons: string[] } {
|
||||
let score = 100;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (input.bounceCount > 0) {
|
||||
score -= Math.min(40, input.bounceCount * 20);
|
||||
reasons.push(`${input.bounceCount} bounced/returned transaction(s) found`);
|
||||
}
|
||||
if (input.foirPercent > 50) {
|
||||
score -= 25;
|
||||
reasons.push(`Existing obligations are ${input.foirPercent.toFixed(0)}% of average income`);
|
||||
} else if (input.foirPercent > 35) {
|
||||
score -= 10;
|
||||
reasons.push(`Existing obligations run moderate at ${input.foirPercent.toFixed(0)}% of average income`);
|
||||
}
|
||||
if (input.minBalance < 0) {
|
||||
score -= 20;
|
||||
reasons.push('Account balance went negative at least once');
|
||||
} else if (input.avgMonthlyCredit > 0 && input.minBalance < input.avgMonthlyCredit * 0.1) {
|
||||
score -= 10;
|
||||
reasons.push('Minimum balance runs very thin relative to income');
|
||||
}
|
||||
if (input.incomeStability === 'IRREGULAR') {
|
||||
score -= 15;
|
||||
reasons.push('Monthly income credits are irregular, not a steady salaried pattern');
|
||||
}
|
||||
score = Math.max(0, Math.min(100, score));
|
||||
if (reasons.length === 0) {
|
||||
reasons.push('No red flags found in this statement');
|
||||
}
|
||||
const rating: RiskRating = score >= 75 ? 'LOW' : score >= 50 ? 'MEDIUM' : 'HIGH';
|
||||
return { rating, score, reasons };
|
||||
}
|
||||
|
||||
/** Rolls a year of transactions up into the numbers a credit officer actually reads a bank
|
||||
* statement for: average monthly inflow/outflow, how tight the balance runs, how many salary-
|
||||
* like credits show up, whether anything ever bounced, how much of the income is already
|
||||
* committed to existing EMIs, and a plain-language risk read tying all of that together. */
|
||||
export function summarizeBankStatement(transactions: BankTransaction[]): BankStatementSummary {
|
||||
const sorted = [...transactions].sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
const monthlyMap = new Map<string, MonthlyBankSummary>();
|
||||
const bouncedTransactions: BouncedTransaction[] = [];
|
||||
const categoryTotals = new Map<string, number>();
|
||||
let totalCredit = 0;
|
||||
let totalDebit = 0;
|
||||
let salaryCreditsDetected = 0;
|
||||
let existingMonthlyObligations = 0;
|
||||
let minBalance = Infinity;
|
||||
let maxBalance = -Infinity;
|
||||
let balanceSum = 0;
|
||||
|
||||
for (const t of sorted) {
|
||||
const key = `${t.date.getFullYear()}-${t.date.getMonth()}`;
|
||||
if (!monthlyMap.has(key)) {
|
||||
monthlyMap.set(key, {
|
||||
label: t.date.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }),
|
||||
credit: 0,
|
||||
debit: 0,
|
||||
closingBalance: t.balance,
|
||||
netCashFlow: 0
|
||||
});
|
||||
}
|
||||
const month = monthlyMap.get(key)!;
|
||||
month.credit += t.credit;
|
||||
month.debit += t.debit;
|
||||
month.closingBalance = t.balance;
|
||||
month.netCashFlow = month.credit - month.debit;
|
||||
|
||||
totalCredit += t.credit;
|
||||
totalDebit += t.debit;
|
||||
minBalance = Math.min(minBalance, t.balance);
|
||||
maxBalance = Math.max(maxBalance, t.balance);
|
||||
balanceSum += t.balance;
|
||||
|
||||
if (t.credit >= 20000 && /salary/i.test(t.narration)) {
|
||||
salaryCreditsDetected++;
|
||||
}
|
||||
if (t.debit > 0) {
|
||||
if (isBouncedTransaction(t.narration)) {
|
||||
bouncedTransactions.push({ date: t.date, narration: t.narration, amount: t.debit });
|
||||
} else if (isExistingObligationDebit(t.narration)) {
|
||||
existingMonthlyObligations += t.debit;
|
||||
}
|
||||
const category = categorize(t.narration);
|
||||
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + t.debit);
|
||||
}
|
||||
}
|
||||
|
||||
const monthly = [...monthlyMap.values()];
|
||||
const monthsCovered = monthly.length || 1;
|
||||
const first = sorted[0];
|
||||
const avgMonthlyCredit = totalCredit / monthsCovered;
|
||||
const avgMonthlyObligations = existingMonthlyObligations / monthsCovered;
|
||||
const foirPercent = avgMonthlyCredit > 0 ? (avgMonthlyObligations / avgMonthlyCredit) * 100 : 0;
|
||||
const incomeStability = assessIncomeStability(monthly);
|
||||
const positiveCashFlowMonths = monthly.filter((m) => m.netCashFlow >= 0).length;
|
||||
const negativeCashFlowMonths = monthly.length - positiveCashFlowMonths;
|
||||
const categoryBreakdown: CategorySpend[] = [...categoryTotals.entries()]
|
||||
.map(
|
||||
([
|
||||
category,
|
||||
amount
|
||||
]) => ({ category, amount })
|
||||
)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
const risk = assessRisk({
|
||||
bounceCount: bouncedTransactions.length,
|
||||
foirPercent,
|
||||
minBalance: sorted.length ? minBalance : 0,
|
||||
avgMonthlyCredit,
|
||||
incomeStability
|
||||
});
|
||||
|
||||
return {
|
||||
openingBalance: first ? first.balance - first.credit + first.debit : 0,
|
||||
closingBalance: sorted.length ? sorted[sorted.length - 1].balance : 0,
|
||||
minBalance: sorted.length ? minBalance : 0,
|
||||
maxBalance: sorted.length ? maxBalance : 0,
|
||||
avgBalance: sorted.length ? balanceSum / sorted.length : 0,
|
||||
totalCredit,
|
||||
totalDebit,
|
||||
avgMonthlyCredit,
|
||||
avgMonthlyDebit: totalDebit / monthsCovered,
|
||||
transactionCount: sorted.length,
|
||||
monthsCovered,
|
||||
salaryCreditsDetected,
|
||||
monthly,
|
||||
bouncedTransactions,
|
||||
existingMonthlyObligations: avgMonthlyObligations,
|
||||
foirPercent,
|
||||
incomeStability,
|
||||
positiveCashFlowMonths,
|
||||
negativeCashFlowMonths,
|
||||
categoryBreakdown,
|
||||
riskRating: risk.rating,
|
||||
riskScore: risk.score,
|
||||
riskReasons: risk.reasons
|
||||
};
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="cra-title">
|
||||
<span>{{ analysis.bureau }} Credit Report Analysis</span>
|
||||
<span class="cra-title-file">{{ data.fileName }}</span>
|
||||
</h2>
|
||||
<mat-dialog-content class="cra-content">
|
||||
<div class="cra-hero">
|
||||
<div class="cra-gauge" [style.--cra-gauge-pct]="gaugePct" [style.--cra-gauge-color]="gaugeColor">
|
||||
<div class="cra-gauge-inner">
|
||||
<span class="cra-gauge-score">{{ analysis.score }}</span>
|
||||
<span class="cra-gauge-outof">/ 900</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cra-hero-details">
|
||||
<span [class]="bandBadgeClass">{{ analysis.band }}</span>
|
||||
<p class="cra-recommendation">{{ analysis.recommendation }}</p>
|
||||
<ul class="cra-score-factors">
|
||||
@for (factor of analysis.scoreFactors; track factor) {
|
||||
<li>{{ factor }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0) {
|
||||
<div class="cra-alert">
|
||||
<div class="cra-alert-title">
|
||||
@if (analysis.writtenOffAccounts > 0) {
|
||||
{{ analysis.writtenOffAccounts }} Written-Off Account(s) Found
|
||||
} @else {
|
||||
₹{{ analysis.totalOverdueAmt | number: '1.0-0' }} Currently Overdue
|
||||
}
|
||||
</div>
|
||||
<p>{{ analysis.recommendation }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cra-kpi-grid">
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Total Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.totalAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--income">
|
||||
<span class="cra-kpi-label">Active Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.activeAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Closed Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.closedAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--expense">
|
||||
<span class="cra-kpi-label">Written-Off Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.writtenOffAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Total Current Balance</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Secured Accounts Amt</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.securedAmt | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Unsecured Accounts Amt</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.unsecuredAmt | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--expense">
|
||||
<span class="cra-kpi-label">Total Overdue</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--expense">
|
||||
<span class="cra-kpi-label">Total Late Payments</span>
|
||||
<span class="cra-kpi-value">{{ analysis.totalLatePayments }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Enquiries (Last 90 Days)</span>
|
||||
<span class="cra-kpi-value">{{ analysis.enquiriesLast90Days }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (analysis.accounts.length > 0) {
|
||||
<div class="cra-chart-panel">
|
||||
<h3 class="cra-chart-panel-title">Payment Behaviour by Account (last 12 months)</h3>
|
||||
<div class="cra-chart-canvas-wrap" [style.height.px]="60 + analysis.accounts.length * 46">
|
||||
<canvas id="craDpdChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cra-table-panel">
|
||||
<h3 class="cra-chart-panel-title">Credit Account Information</h3>
|
||||
<div class="cra-table-scroll">
|
||||
<table class="cra-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lender</th>
|
||||
<th>Account Type</th>
|
||||
<th>Ownership</th>
|
||||
<th>Date Opened</th>
|
||||
<th>Sanctioned Amt</th>
|
||||
<th>Current Balance</th>
|
||||
<th>Overdue Amt</th>
|
||||
<th>Status</th>
|
||||
<th>Payment Record</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||
<tr>
|
||||
<td>{{ acc.lender }}</td>
|
||||
<td>{{ acc.accountType }}</td>
|
||||
<td>{{ acc.ownership }}</td>
|
||||
<td>{{ acc.dateOpened }}</td>
|
||||
<td>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</td>
|
||||
<td>₹{{ acc.currentBalance | number: '1.0-0' }}</td>
|
||||
<td>₹{{ acc.overdueAmt | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
|
||||
</td>
|
||||
<td>{{ worstDpdLabel(acc.worstDpd) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (analysis.enquiries.length > 0) {
|
||||
<div class="cra-table-panel">
|
||||
<h3 class="cra-chart-panel-title">Credit Enquiries</h3>
|
||||
<div class="cra-table-scroll">
|
||||
<table class="cra-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Credit Institution</th>
|
||||
<th>Date</th>
|
||||
<th>Search Type</th>
|
||||
<th>Amount Applied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (enq of analysis.enquiries; track enq.date + enq.institution) {
|
||||
<tr>
|
||||
<td>{{ enq.institution }}</td>
|
||||
<td>{{ enq.date }}</td>
|
||||
<td>{{ enq.purpose }}</td>
|
||||
<td>₹{{ enq.amountApplied | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="cra-no-enquiries">No credit enquiries found in the last 24 months.</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||
</mat-dialog-actions>
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.cra-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cra-title-file {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cra-content {
|
||||
min-width: 560px;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
// -- Score hero: a CSS conic-gradient gauge ring on the bureau's real 300-900 scale --
|
||||
.cra-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f8fafc, #eef2ff);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cra-gauge {
|
||||
--cra-gauge-pct: 0;
|
||||
--cra-gauge-color: #10b981;
|
||||
|
||||
flex-shrink: 0;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: conic-gradient(var(--cra-gauge-color) calc(var(--cra-gauge-pct) * 1%), #e2e8f0 0);
|
||||
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.cra-gauge-inner {
|
||||
width: 98px;
|
||||
height: 98px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px #f1f5f9;
|
||||
}
|
||||
|
||||
.cra-gauge-score {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cra-gauge-outof {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cra-hero-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cra-band-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--excellent {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&--good {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&--fair {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--poor {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-recommendation {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cra-score-factors {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// -- Overdue / written-off alert -- the strongest single red flag, called out on its own --
|
||||
.cra-alert {
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.cra-alert-title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #b91c1c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cra-alert p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// -- KPI cards --
|
||||
.cra-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cra-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #cbd5e1;
|
||||
|
||||
&--income {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
&--expense {
|
||||
border-left-color: #f43f5e;
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
border-left-color: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-kpi-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cra-kpi-value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// -- Chart / table panels --
|
||||
.cra-chart-panel,
|
||||
.cra-table-panel {
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cra-chart-panel-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cra-chart-canvas-wrap {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.cra-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cra-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 10px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
&--active {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--written-off {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-no-enquiries {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
padding: 0 4px;
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { CicReportAnalysis } from './cic-report-analysis.util';
|
||||
|
||||
Chart.register(...registerables);
|
||||
Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
|
||||
Chart.defaults.color = '#64748b';
|
||||
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
||||
Chart.defaults.plugins.legend.labels.boxWidth = 8;
|
||||
|
||||
export interface CicReportAnalysisDialogData {
|
||||
fileName: string;
|
||||
analysis: CicReportAnalysis;
|
||||
}
|
||||
|
||||
const BAND_COLORS: Record<string, string> = {
|
||||
Excellent: '#10b981',
|
||||
Good: '#0ea5e9',
|
||||
Fair: '#f59e0b',
|
||||
Poor: '#f43f5e'
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a full bureau-style credit report analysis -- score gauge, score factors, account
|
||||
* summary, per-account payment behaviour and enquiry history -- straight after a CIC/Credit
|
||||
* Bureau report file is uploaded, the same "auto-opens on upload" pattern as the Bank Statement
|
||||
* Analysis dialog. Everything shown is generated from this application's own cibilScore, not
|
||||
* read from the actual uploaded file, since there's no real bureau API or PDF parser behind this
|
||||
* mock pipeline -- see cic-report-analysis.util.ts for why it's still fully self-consistent.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cic-report-analysis-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './cic-report-analysis-dialog.component.html',
|
||||
styleUrls: ['./cic-report-analysis-dialog.component.scss']
|
||||
})
|
||||
export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestroy {
|
||||
data = inject<CicReportAnalysisDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
private dpdChart: any = null;
|
||||
|
||||
get analysis(): CicReportAnalysis {
|
||||
return this.data.analysis;
|
||||
}
|
||||
|
||||
get bandBadgeClass(): string {
|
||||
return `cra-band-badge cra-band-badge--${this.analysis.band.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Drives the CSS conic-gradient gauge ring -- same pixel-correct approach used for the Bank
|
||||
* Statement risk gauge, scaled to the bureau's real 300-900 score range. */
|
||||
get gaugeColor(): string {
|
||||
return BAND_COLORS[this.analysis.band];
|
||||
}
|
||||
|
||||
get gaugePct(): number {
|
||||
return Math.max(0, Math.min(100, ((this.analysis.score - 300) / 600) * 100));
|
||||
}
|
||||
|
||||
accountStatusClass(status: string): string {
|
||||
if (status === 'WRITTEN-OFF') {
|
||||
return 'cra-status-badge cra-status-badge--written-off';
|
||||
}
|
||||
if (status === 'CLOSED') {
|
||||
return 'cra-status-badge cra-status-badge--closed';
|
||||
}
|
||||
return 'cra-status-badge cra-status-badge--active';
|
||||
}
|
||||
|
||||
worstDpdLabel(worstDpd: number): string {
|
||||
return worstDpd === 0 ? 'Always on time' : `${worstDpd} days past due (worst)`;
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const a = this.analysis;
|
||||
const labels = a.accounts.map((acc) => `${acc.lender} — ${acc.accountType}`);
|
||||
const onTimeCounts = a.accounts.map((acc) => acc.dpdHistory.filter((p) => p.dpd === 0).length);
|
||||
const lateCounts = a.accounts.map((acc) => acc.dpdHistory.filter((p) => p.dpd > 0).length);
|
||||
|
||||
this.dpdChart = new Chart('craDpdChart', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'On-Time Months',
|
||||
data: onTimeCounts,
|
||||
backgroundColor: '#10b981',
|
||||
borderRadius: 6,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.6,
|
||||
stack: 'dpd'
|
||||
},
|
||||
{
|
||||
label: 'Late Months',
|
||||
data: lateCounts,
|
||||
backgroundColor: '#f43f5e',
|
||||
borderRadius: 6,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.6,
|
||||
stack: 'dpd'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', align: 'end' } },
|
||||
scales: {
|
||||
x: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { stepSize: 1 } },
|
||||
y: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.dpdChart?.destroy();
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
export interface CicDpdPoint {
|
||||
label: string;
|
||||
dpd: number;
|
||||
}
|
||||
|
||||
export interface CicCreditAccount {
|
||||
lender: string;
|
||||
accountType: string;
|
||||
accountNo: string;
|
||||
ownership: 'Individual' | 'Joint';
|
||||
secured: boolean;
|
||||
dateOpened: string;
|
||||
status: 'ACTIVE' | 'CLOSED' | 'WRITTEN-OFF';
|
||||
sanctionedAmt: number;
|
||||
currentBalance: number;
|
||||
overdueAmt: number;
|
||||
tenureMonths: number;
|
||||
rateOfInterest: number;
|
||||
dpdHistory: CicDpdPoint[];
|
||||
worstDpd: number;
|
||||
}
|
||||
|
||||
export interface CicEnquiry {
|
||||
institution: string;
|
||||
date: string;
|
||||
purpose: string;
|
||||
amountApplied: number;
|
||||
}
|
||||
|
||||
export type CicRiskBand = 'Excellent' | 'Good' | 'Fair' | 'Poor';
|
||||
|
||||
export interface CicReportAnalysis {
|
||||
bureau: string;
|
||||
reportDate: string;
|
||||
applicantName: string;
|
||||
score: number;
|
||||
band: CicRiskBand;
|
||||
scoreFactors: string[];
|
||||
totalAccounts: number;
|
||||
activeAccounts: number;
|
||||
closedAccounts: number;
|
||||
writtenOffAccounts: number;
|
||||
totalCurrentBalance: number;
|
||||
securedAmt: number;
|
||||
unsecuredAmt: number;
|
||||
totalOverdueAmt: number;
|
||||
accounts: CicCreditAccount[];
|
||||
enquiries: CicEnquiry[];
|
||||
enquiriesLast90Days: number;
|
||||
totalLatePayments: number;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
/** A tiny string-seeded PRNG (not cryptographic, just deterministic) -- the same application
|
||||
* should show the exact same "pulled" credit report every time an officer opens or re-checks it
|
||||
* in this session, the same way a real bureau pull doesn't change on a second look. */
|
||||
function seededRng(seed: string): () => number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
let state = h || 1;
|
||||
return () => {
|
||||
state = (Math.imul(1103515245, state) + 12345) & 0x7fffffff;
|
||||
return state / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
const LENDERS = [
|
||||
'HDFC Bank',
|
||||
'ICICI Bank',
|
||||
'Axis Bank',
|
||||
'State Bank of India',
|
||||
'Bajaj Finserv',
|
||||
'Hero FinCorp Ltd',
|
||||
'Equitas Finance Pvt Ltd',
|
||||
'IDFC First Bank',
|
||||
'Muthoot Finance',
|
||||
'Tata Capital',
|
||||
'Cholamandalam Finance',
|
||||
'L&T Finance'
|
||||
];
|
||||
|
||||
interface AccountTypeSpec {
|
||||
type: string;
|
||||
secured: boolean;
|
||||
minAmt: number;
|
||||
maxAmt: number;
|
||||
minTenure: number;
|
||||
maxTenure: number;
|
||||
minRate: number;
|
||||
maxRate: number;
|
||||
}
|
||||
|
||||
const ACCOUNT_TYPES: AccountTypeSpec[] = [
|
||||
{
|
||||
type: 'TWO WHEELER LOAN',
|
||||
secured: true,
|
||||
minAmt: 30000,
|
||||
maxAmt: 150000,
|
||||
minTenure: 12,
|
||||
maxTenure: 48,
|
||||
minRate: 11,
|
||||
maxRate: 19
|
||||
},
|
||||
{
|
||||
type: 'PERSONAL LOAN',
|
||||
secured: false,
|
||||
minAmt: 50000,
|
||||
maxAmt: 500000,
|
||||
minTenure: 12,
|
||||
maxTenure: 60,
|
||||
minRate: 13,
|
||||
maxRate: 22
|
||||
},
|
||||
{
|
||||
type: 'BL GENERAL',
|
||||
secured: false,
|
||||
minAmt: 100000,
|
||||
maxAmt: 900000,
|
||||
minTenure: 24,
|
||||
maxTenure: 120,
|
||||
minRate: 15,
|
||||
maxRate: 21
|
||||
},
|
||||
{
|
||||
type: 'GOLD LOAN',
|
||||
secured: true,
|
||||
minAmt: 20000,
|
||||
maxAmt: 300000,
|
||||
minTenure: 6,
|
||||
maxTenure: 24,
|
||||
minRate: 9,
|
||||
maxRate: 15
|
||||
},
|
||||
{
|
||||
type: 'CONSUMER DURABLE LOAN',
|
||||
secured: false,
|
||||
minAmt: 5000,
|
||||
maxAmt: 100000,
|
||||
minTenure: 6,
|
||||
maxTenure: 24,
|
||||
minRate: 14,
|
||||
maxRate: 24
|
||||
},
|
||||
{
|
||||
type: 'CREDIT CARD',
|
||||
secured: false,
|
||||
minAmt: 20000,
|
||||
maxAmt: 300000,
|
||||
minTenure: 0,
|
||||
maxTenure: 0,
|
||||
minRate: 36,
|
||||
maxRate: 42
|
||||
},
|
||||
{
|
||||
type: 'HOUSING LOAN',
|
||||
secured: true,
|
||||
minAmt: 1000000,
|
||||
maxAmt: 4000000,
|
||||
minTenure: 120,
|
||||
maxTenure: 240,
|
||||
minRate: 8.5,
|
||||
maxRate: 10.5
|
||||
}
|
||||
];
|
||||
|
||||
/** RBI/credit-bureau-standard CIBIL score bands -- kept in sync with loan-origination-data.ts's
|
||||
* own cibilBand() rather than importing it, so this analysis util has no dependency on the
|
||||
* pipeline's mock-data module and can be unit-tested/reused on its own. */
|
||||
function scoreBand(score: number): CicRiskBand {
|
||||
if (score >= 750) {
|
||||
return 'Excellent';
|
||||
}
|
||||
if (score >= 650) {
|
||||
return 'Good';
|
||||
}
|
||||
if (score >= 550) {
|
||||
return 'Fair';
|
||||
}
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
const SCORE_FACTORS = [
|
||||
'Recency: Recent Credit Account Defaults',
|
||||
'Leverage: Credit Accounts with on-time re-payment history',
|
||||
'Coverage: Non-delinquent and delinquent Credit Accounts',
|
||||
'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)',
|
||||
'Credit Applications: Credit Account Applications over last 30 days'
|
||||
];
|
||||
|
||||
function monthsAgoLabel(monthsBack: number): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - monthsBack);
|
||||
return d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function ddmmyyyyMonthsAgo(monthsBack: number, rng: () => number): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - monthsBack);
|
||||
d.setDate(1 + Math.floor(rng() * 27));
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function maskAccountNo(rng: () => number): string {
|
||||
const digits = Array.from({ length: 10 }, () => Math.floor(rng() * 10)).join('');
|
||||
return `XXXXXXXXXX${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** How badly (and how often) an account's monthly instalments have slipped -- driven by the
|
||||
* score band, same shape as a real bureau's per-account DPD table: mostly clean, with the worse
|
||||
* bands showing more months affected and higher day-counts on the ones that are. */
|
||||
function buildDpdHistory(band: CicRiskBand, monthsOpen: number, rng: () => number): CicDpdPoint[] {
|
||||
const span = Math.min(12, Math.max(1, monthsOpen));
|
||||
const lateChance = band === 'Excellent' ? 0 : band === 'Good' ? 0.08 : band === 'Fair' ? 0.22 : 0.4;
|
||||
const maxDpd = band === 'Excellent' ? 0 : band === 'Good' ? 29 : band === 'Fair' ? 89 : 180;
|
||||
const history: CicDpdPoint[] = [];
|
||||
for (let i = span - 1; i >= 0; i--) {
|
||||
const isLate = maxDpd > 0 && rng() < lateChance;
|
||||
history.push({ label: monthsAgoLabel(i), dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0 });
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
|
||||
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
|
||||
const sanctionedAmt = Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000;
|
||||
const monthsOpen = Math.round(3 + rng() * 48);
|
||||
const tenureMonths = spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0;
|
||||
const isClosed = tenureMonths > 0 && monthsOpen >= tenureMonths && rng() < 0.6;
|
||||
const paidFraction = tenureMonths > 0 ? Math.min(1, monthsOpen / tenureMonths) : rng() * 0.6;
|
||||
const dpdHistory = buildDpdHistory(band, monthsOpen, rng);
|
||||
const worstDpd = dpdHistory.reduce((max, p) => Math.max(max, p.dpd), 0);
|
||||
const isWrittenOff = band === 'Poor' && worstDpd >= 90 && rng() < 0.35;
|
||||
const currentBalance = isClosed ? 0 : Math.round(sanctionedAmt * (1 - paidFraction) * (0.85 + rng() * 0.3));
|
||||
const overdueAmt = worstDpd > 30 && !isClosed ? Math.round(currentBalance * (0.05 + rng() * 0.15)) : 0;
|
||||
return {
|
||||
lender: LENDERS[Math.floor(rng() * LENDERS.length)],
|
||||
accountType: spec.type,
|
||||
accountNo: maskAccountNo(rng),
|
||||
ownership: rng() < 0.75 ? 'Individual' : 'Joint',
|
||||
secured: spec.secured,
|
||||
dateOpened: ddmmyyyyMonthsAgo(monthsOpen, rng),
|
||||
status: isWrittenOff ? 'WRITTEN-OFF' : isClosed ? 'CLOSED' : 'ACTIVE',
|
||||
sanctionedAmt,
|
||||
currentBalance: Math.max(0, currentBalance),
|
||||
overdueAmt,
|
||||
tenureMonths,
|
||||
rateOfInterest: Math.round((spec.minRate + rng() * (spec.maxRate - spec.minRate)) * 100) / 100,
|
||||
dpdHistory,
|
||||
worstDpd
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnquiry(rng: () => number): CicEnquiry {
|
||||
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
|
||||
return {
|
||||
institution: LENDERS[Math.floor(rng() * LENDERS.length)],
|
||||
date: ddmmyyyyMonthsAgo(Math.round(rng() * 24), rng),
|
||||
purpose: spec.type.replace('BL GENERAL', 'BUSINESS LOAN'),
|
||||
amountApplied: Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecommendation(band: CicRiskBand, totalOverdueAmt: number, writtenOffAccounts: number): string {
|
||||
if (writtenOffAccounts > 0) {
|
||||
return `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`;
|
||||
}
|
||||
if (totalOverdueAmt > 0) {
|
||||
return `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`;
|
||||
}
|
||||
if (band === 'Excellent' || band === 'Good') {
|
||||
return `Score is ${band} with a clean repayment record and no overdue amounts — proceed with standard underwriting.`;
|
||||
}
|
||||
return `Score is ${band} — no overdue amount currently, but the repayment history is inconsistent enough to warrant additional scrutiny (co-borrower, lower amount, or tighter tenure).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a full, self-consistent mock bureau report for this applicant -- realistic account mix,
|
||||
* per-account DPD history, enquiries and a plain-language recommendation -- all rolled up around
|
||||
* the `cibilScore` this application already has from the "Check Credit Score" step. Deterministic
|
||||
* per application (seeded off applicationNo + score), so re-opening it later shows the exact same
|
||||
* "pulled" report rather than a new random one each time.
|
||||
*/
|
||||
export function generateCicReportAnalysis(app: {
|
||||
applicationNo: string;
|
||||
applicantName: string;
|
||||
cicBureau: string;
|
||||
cibilScore: number;
|
||||
}): CicReportAnalysis {
|
||||
const rng = seededRng(`${app.applicationNo}-${app.cibilScore}`);
|
||||
const score = app.cibilScore > 0 ? app.cibilScore : Math.round(300 + rng() * 600);
|
||||
const band = scoreBand(score);
|
||||
const accountCount = 1 + Math.floor(rng() * 4);
|
||||
const accounts = Array.from({ length: accountCount }, () => buildAccount(band, rng));
|
||||
const enquiryCount = band === 'Excellent' ? Math.floor(rng() * 2) : Math.floor(rng() * 4);
|
||||
const enquiries = Array.from({ length: enquiryCount }, () => buildEnquiry(rng));
|
||||
|
||||
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
|
||||
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
|
||||
const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length;
|
||||
const totalCurrentBalance = accounts.reduce((sum, a) => sum + a.currentBalance, 0);
|
||||
const securedAmt = accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
|
||||
const unsecuredAmt = totalCurrentBalance - securedAmt;
|
||||
const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
|
||||
const totalLatePayments = accounts.reduce((sum, a) => sum + a.dpdHistory.filter((p) => p.dpd > 0).length, 0);
|
||||
const enquiriesLast90Days = enquiries.filter((e) => {
|
||||
const [
|
||||
d,
|
||||
m,
|
||||
y
|
||||
] = e.date.split('-').map(Number);
|
||||
const enquiryDate = new Date(y, m - 1, d);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 90);
|
||||
return enquiryDate >= cutoff;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
bureau: app.cicBureau,
|
||||
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||
applicantName: app.applicantName,
|
||||
score,
|
||||
band,
|
||||
scoreFactors: SCORE_FACTORS,
|
||||
totalAccounts: accounts.length,
|
||||
activeAccounts,
|
||||
closedAccounts,
|
||||
writtenOffAccounts,
|
||||
totalCurrentBalance,
|
||||
securedAmt,
|
||||
unsecuredAmt,
|
||||
totalOverdueAmt,
|
||||
accounts,
|
||||
enquiries,
|
||||
enquiriesLast90Days,
|
||||
totalLatePayments,
|
||||
recommendation: buildRecommendation(band, totalOverdueAmt, writtenOffAccounts)
|
||||
};
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Co-Owner {{ docLabel }} Verification</h2>
|
||||
<mat-dialog-content class="cokv-content">
|
||||
<div class="cokv-files">
|
||||
<label class="cokv-upload-btn">
|
||||
{{ data.state.front ? 'Front ✓' : 'Upload Front' }}
|
||||
<input type="file" accept="image/*,application/pdf" hidden (change)="onFile('front', $event)" />
|
||||
</label>
|
||||
<label class="cokv-upload-btn">
|
||||
{{ data.state.back ? 'Back ✓' : 'Upload Back' }}
|
||||
<input type="file" accept="image/*,application/pdf" hidden (change)="onFile('back', $event)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (data.state.frontPreviewUrl || data.state.backPreviewUrl) {
|
||||
<div class="cokv-thumbs">
|
||||
@if (data.state.frontPreviewUrl) {
|
||||
<img [src]="data.state.frontPreviewUrl" alt="Front" class="cokv-thumb" />
|
||||
}
|
||||
@if (data.state.backPreviewUrl) {
|
||||
<img [src]="data.state.backPreviewUrl" alt="Back" class="cokv-thumb" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.state.verified) {
|
||||
<span class="cokv-verified-badge">✓ Verified</span>
|
||||
} @else {
|
||||
<div class="cokv-verify-row">
|
||||
<input
|
||||
type="text"
|
||||
[maxlength]="data.docType === 'aadhaar' ? 12 : 10"
|
||||
[placeholder]="data.docType === 'aadhaar' ? 'Aadhaar Number' : 'PAN Number'"
|
||||
[(ngModel)]="data.state.number"
|
||||
/>
|
||||
@if (data.docType === 'aadhaar') {
|
||||
<button type="button" class="cokv-btn" (click)="sendOtp()">Send OTP</button>
|
||||
} @else {
|
||||
<button type="button" class="cokv-btn cokv-btn--primary" (click)="verifyPan()">Verify PAN</button>
|
||||
}
|
||||
</div>
|
||||
@if (data.docType === 'aadhaar' && otpSent) {
|
||||
<div class="cokv-otp-row">
|
||||
<div class="cokv-otp-digits">
|
||||
@for (d of otpDigits; track $index) {
|
||||
<input
|
||||
type="text"
|
||||
maxlength="1"
|
||||
[value]="d"
|
||||
(input)="onOtpDigit($index, $event)"
|
||||
(keydown)="onOtpKeydown($index, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="cokv-btn cokv-btn--primary" (click)="verifyAadhaar()">Verify</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" mat-button mat-dialog-close>Done</button>
|
||||
</mat-dialog-actions>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.cokv-content {
|
||||
min-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cokv-files {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cokv-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.cokv-thumbs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cokv-thumb {
|
||||
width: 100px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cokv-verified-badge {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cokv-verify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
// stylelint-disable-next-line selector-no-qualifying-type
|
||||
input[type='text'] {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.cokv-otp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cokv-otp-digits {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
|
||||
// stylelint-disable-next-line selector-no-qualifying-type
|
||||
input[type='text'] {
|
||||
width: 30px;
|
||||
height: 34px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.cokv-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
border-color: #4338ca;
|
||||
background: #4338ca;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #3730a3;
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { KycUploadHelperService } from '../../../clients/shared/kyc-upload-helper.service';
|
||||
|
||||
export interface CoOwnerDocSide {
|
||||
front: File | null;
|
||||
back: File | null;
|
||||
frontPreviewUrl: string | null;
|
||||
backPreviewUrl: string | null;
|
||||
number: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface CoOwnerKycVerifyDialogData {
|
||||
docType: 'aadhaar' | 'pan';
|
||||
/** Passed by reference on purpose -- every capture/verify action here mutates the caller's own
|
||||
* object directly, so the result is already in place the instant this dialog closes, no
|
||||
* matter whether it was closed via the Done button, the backdrop, or Esc. */
|
||||
state: CoOwnerDocSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Front/back capture + OTP (Aadhaar) or format (PAN) verification for a Housing Loan co-owner,
|
||||
* lifted into its own popup for exactly the same reason the applicant's own Aadhaar/PAN capture
|
||||
* needed one: that much UI (two upload buttons, thumbnails, a number field, six OTP boxes) makes
|
||||
* a flat document-card grid balloon in height and break its layout.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-co-owner-kyc-verify-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './co-owner-kyc-verify-dialog.component.html',
|
||||
styleUrls: ['./co-owner-kyc-verify-dialog.component.scss']
|
||||
})
|
||||
export class CoOwnerKycVerifyDialogComponent {
|
||||
data = inject<CoOwnerKycVerifyDialogData>(MAT_DIALOG_DATA);
|
||||
private kycUploadHelper = inject(KycUploadHelperService);
|
||||
|
||||
otpSent = false;
|
||||
otpDigits: string[] = [
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
''
|
||||
];
|
||||
|
||||
get docLabel(): string {
|
||||
return this.data.docType === 'aadhaar' ? 'Aadhaar' : 'PAN';
|
||||
}
|
||||
|
||||
async onFile(side: 'front' | 'back', event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const cropped = await this.kycUploadHelper.pickWithCrop(file, `Co-Owner ${this.docLabel} — ${side}`);
|
||||
if (!cropped) {
|
||||
return;
|
||||
}
|
||||
const state = this.data.state;
|
||||
if (side === 'front') {
|
||||
if (state.frontPreviewUrl) {
|
||||
URL.revokeObjectURL(state.frontPreviewUrl);
|
||||
}
|
||||
state.front = cropped;
|
||||
state.frontPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
||||
} else {
|
||||
if (state.backPreviewUrl) {
|
||||
URL.revokeObjectURL(state.backPreviewUrl);
|
||||
}
|
||||
state.back = cropped;
|
||||
state.backPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
|
||||
}
|
||||
}
|
||||
|
||||
sendOtp(): void {
|
||||
if (this.data.state.number.trim().length !== 12) {
|
||||
return;
|
||||
}
|
||||
this.otpSent = true;
|
||||
this.otpDigits = [
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
''
|
||||
];
|
||||
}
|
||||
|
||||
onOtpDigit(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const val = input.value.replace(/\D/g, '');
|
||||
this.otpDigits[index] = val.charAt(0) || '';
|
||||
input.value = this.otpDigits[index];
|
||||
if (this.otpDigits[index] && index < 5) {
|
||||
const next = input.parentElement?.querySelectorAll('input')[index + 1];
|
||||
(next as HTMLInputElement)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onOtpKeydown(index: number, event: KeyboardEvent): void {
|
||||
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
|
||||
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
|
||||
if (inputs) {
|
||||
this.otpDigits[index - 1] = '';
|
||||
(inputs[index - 1] as HTMLInputElement).value = '';
|
||||
(inputs[index - 1] as HTMLInputElement).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Local-only "verification" -- no real Aadhaar API is reachable here (no clientId exists for
|
||||
* a co-owner who isn't even a client), same as the applicant's own capture flow. */
|
||||
verifyAadhaar(): void {
|
||||
if (this.otpDigits.every((d) => d !== '')) {
|
||||
this.data.state.verified = true;
|
||||
}
|
||||
}
|
||||
|
||||
verifyPan(): void {
|
||||
if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.data.state.number.trim().toUpperCase())) {
|
||||
this.data.state.verified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>{{ data.label }} — Visit Details</h2>
|
||||
<mat-dialog-content class="fvd-content">
|
||||
<span [class]="statusBadgeClass">{{ data.check.status }}</span>
|
||||
|
||||
<dl class="fvd-kv-grid">
|
||||
<div class="fvd-kv-field">
|
||||
<dt>Officer Assigned</dt>
|
||||
<dd>{{ data.check.officerAssigned }}</dd>
|
||||
</div>
|
||||
<div class="fvd-kv-field">
|
||||
<dt>Assigned On</dt>
|
||||
<dd>{{ data.check.assignedDate }}</dd>
|
||||
</div>
|
||||
<div class="fvd-kv-field">
|
||||
<dt>GPS Coordinates</dt>
|
||||
<dd>{{ data.check.latitude || '—' }}, {{ data.check.longitude || '—' }}</dd>
|
||||
</div>
|
||||
<div class="fvd-kv-field">
|
||||
<dt>{{ data.check.status === 'FAILED' ? 'Rejected By' : 'Verified By' }}</dt>
|
||||
<dd>{{ data.check.verifiedBy }}</dd>
|
||||
</div>
|
||||
<div class="fvd-kv-field">
|
||||
<dt>{{ data.check.status === 'FAILED' ? 'Rejected On' : 'Verified On' }}</dt>
|
||||
<dd>{{ data.check.verifiedDate }}</dd>
|
||||
</div>
|
||||
<div class="fvd-kv-field">
|
||||
<dt>Remarks</dt>
|
||||
<dd>{{ data.check.remarks }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="fvd-photos-title">Site Photos ({{ data.check.photoCount }})</p>
|
||||
@if (data.photos.length > 0) {
|
||||
<div class="fvd-photo-grid">
|
||||
@for (photo of data.photos; track $index) {
|
||||
<div class="fvd-photo">
|
||||
@if (photo.previewUrl) {
|
||||
<img [src]="photo.previewUrl" alt="Site photo {{ $index + 1 }}" />
|
||||
} @else {
|
||||
<span class="fvd-photo-label">{{ photo.file.name }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="fvd-no-photos">
|
||||
No photo files available in this browser session -- they were captured earlier and aren't retained after a page
|
||||
refresh, though the count above is.
|
||||
</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||
</mat-dialog-actions>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.fvd-content {
|
||||
min-width: 420px;
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fvd-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--verified {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
.fvd-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fvd-kv-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
dt {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.fvd-photos-title {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.fvd-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fvd-photo {
|
||||
height: 90px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.fvd-photo-label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fvd-no-photos {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { VerificationCheck } from '../../loan-origination-data';
|
||||
|
||||
export interface FieldVisitPhoto {
|
||||
file: File;
|
||||
previewUrl: string | null;
|
||||
}
|
||||
|
||||
export interface FieldVisitDetailsDialogData {
|
||||
label: string;
|
||||
check: VerificationCheck;
|
||||
photos: FieldVisitPhoto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything saved for one Field Investigation address visit -- officer, assignment date, GPS,
|
||||
* status/remarks and every site photo -- in one popup, since the inline card only ever shows a
|
||||
* short summary once the visit is Verified/Failed. Photos are local-only File objects (same as
|
||||
* every other document capture in this mock pipeline), so a photo count with no files behind it
|
||||
* just means the page was refreshed since the visit was captured, not that anything was lost from
|
||||
* the application record itself.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-field-visit-details-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule
|
||||
],
|
||||
templateUrl: './field-visit-details-dialog.component.html',
|
||||
styleUrls: ['./field-visit-details-dialog.component.scss']
|
||||
})
|
||||
export class FieldVisitDetailsDialogComponent {
|
||||
data = inject<FieldVisitDetailsDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
get statusBadgeClass(): string {
|
||||
if (this.data.check.status === 'VERIFIED') {
|
||||
return 'fvd-badge fvd-badge--verified';
|
||||
}
|
||||
if (this.data.check.status === 'FAILED') {
|
||||
return 'fvd-badge fvd-badge--failed';
|
||||
}
|
||||
return 'fvd-badge fvd-badge--pending';
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,16 @@ export interface VerificationCheck {
|
||||
verifiedBy: string;
|
||||
verifiedDate: string;
|
||||
remarks: string;
|
||||
/** Field Investigation only -- the officer assigned to physically visit this address, when they
|
||||
* were assigned (both a display date and a raw epoch-ms timestamp for the 48-hour SLA clock),
|
||||
* and what they captured on the visit (GPS + a photo count -- the actual File objects are kept
|
||||
* local-only in the component, same as every other document capture in this mock pipeline). */
|
||||
officerAssigned: string;
|
||||
assignedDate: string;
|
||||
assignedAt: number;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
photoCount: number;
|
||||
}
|
||||
|
||||
export interface ApprovalRecord {
|
||||
@@ -188,6 +198,9 @@ export interface LoanApplication {
|
||||
|
||||
productCode: string;
|
||||
productName: string;
|
||||
/** Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed annually) --
|
||||
* see allowedProductTypes() for which products can be which. */
|
||||
productType: ProductType;
|
||||
secured: boolean;
|
||||
requestedAmount: number;
|
||||
tenureMonths: number;
|
||||
@@ -203,11 +216,11 @@ export interface LoanApplication {
|
||||
panVerified: boolean;
|
||||
incomeProofSubmitted: boolean;
|
||||
bankStatementSubmitted: boolean;
|
||||
propertyDetailsSubmitted: boolean;
|
||||
taxReceiptSubmitted: boolean;
|
||||
electricityBillSubmitted: boolean;
|
||||
/** Aadhaar e-KYC biometric authentication (UIDAI) — captured on top of the OVD paper check. */
|
||||
aadhaarFingerprintCaptured: boolean;
|
||||
/** Documents specific to this application's loan product (e.g. property papers for a Housing
|
||||
* Loan, land records for a Kisan Credit Card) -- seeded from PRODUCT_DOCUMENT_REQUIREMENTS at
|
||||
* creation time, plus whatever the officer adds on top with "Add Custom Document". */
|
||||
productDocuments: ProductDocumentEntry[];
|
||||
monthlyIncome: number;
|
||||
eligibleLoanAmount: number;
|
||||
coBorrowerRequired: boolean;
|
||||
@@ -248,12 +261,34 @@ export interface LoanApplication {
|
||||
addressVerification: VerificationCheck;
|
||||
officeVerification: VerificationCheck;
|
||||
propertyVerification: VerificationCheck;
|
||||
/** Whether this applicant runs a shop/business premises worth sending a field officer to --
|
||||
* officer-ticked at Field Investigation itself (not derivable from anything else on the
|
||||
* application), same conditional-row pattern propertyVerification already uses off `secured`. */
|
||||
hasShop: boolean;
|
||||
shopVerification: VerificationCheck;
|
||||
/** propertyValuation itself is always derived (builtUpAreaSqFt × marketRatePerSqFt) -- kept as
|
||||
* its own field since every other part of the app already reads it directly (stage-history
|
||||
* remarks, Risk Approval), rather than making every one of those call sites recompute it. */
|
||||
propertyType: string;
|
||||
builtUpAreaSqFt: number;
|
||||
marketRatePerSqFt: number;
|
||||
guidelineRatePerSqFt: number;
|
||||
valuerName: string;
|
||||
valuationDate: string;
|
||||
propertyValuation: number;
|
||||
agentReportUploaded: boolean;
|
||||
agentReportFileName: string;
|
||||
agentReportRemark: string;
|
||||
|
||||
// Personal Discussion / discrepancy handling
|
||||
discrepancyType: DiscrepancyType;
|
||||
/** Scheduled before the meeting happens -- date/time/mode/officer -- so there's a real
|
||||
* appointment on record instead of the discussion notes just appearing from nowhere. */
|
||||
meetingScheduled: boolean;
|
||||
meetingDate: string;
|
||||
meetingTime: string;
|
||||
meetingMode: 'ONLINE' | 'OFFLINE' | 'CALL' | '';
|
||||
meetingWith: string;
|
||||
personalDiscussionNotes: string;
|
||||
personalDiscussionDate: string;
|
||||
/** Customer's signed declaration taken after the personal discussion clears. */
|
||||
@@ -468,6 +503,80 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
}
|
||||
];
|
||||
|
||||
/** One product-specific document a loan officer has to collect for a given product's collateral/
|
||||
* eligibility type -- distinct from the generic KYC docs (Aadhaar, PAN, Income Proof, ...) every
|
||||
* application needs regardless of product. */
|
||||
export interface ProductDocumentEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
/** True for a document the officer added on the spot with "Add Custom Document" -- these can
|
||||
* be removed again; the product's own required list cannot. */
|
||||
custom: boolean;
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
interface ProductDocumentTemplate {
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** What each product needs beyond the generic KYC checklist -- a Housing or Kisan Credit loan is
|
||||
* secured against a specific asset (property / agricultural land), so the paperwork proving and
|
||||
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
|
||||
* borrower's own identity/income proof. */
|
||||
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
|
||||
HL: [
|
||||
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
|
||||
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
|
||||
{ key: 'propertyElectricityBill', label: 'Electricity Bill (of the Property)' },
|
||||
{
|
||||
key: 'coOwnerKyc',
|
||||
label: 'KYC Documents of Co-Owners',
|
||||
hint: 'If the registry lists a second co-owner, use "Add More Doc" below for their KYC too'
|
||||
},
|
||||
{ key: 'mutationExtract', label: 'Property Mutation Extract (Namantaran)' },
|
||||
{ key: 'noDuesCertificate', label: 'No-Dues Certificate' },
|
||||
{ key: 'encumbranceCertificate', label: 'Encumbrance Certificate (EC)' },
|
||||
{ key: 'approvedBuildingPlan', label: 'Approved Building Plan / Sanctioned Layout' }
|
||||
],
|
||||
KCC: [
|
||||
{ key: 'landRegistry', label: 'Land Registry / Title Deed' },
|
||||
{ key: 'khasraKhatauni', label: 'Khasra-Khatauni (Land Revenue Record)' },
|
||||
{ key: 'girdawari', label: 'Girdawari (Crop Inspection Report)' },
|
||||
{ key: 'noDuesOtherBank', label: 'No-Dues Certificate from Other Banks/Societies' }
|
||||
],
|
||||
VL: [
|
||||
{ key: 'vehicleQuotation', label: 'Vehicle Quotation / Proforma Invoice' },
|
||||
{ key: 'drivingLicense', label: 'Driving License' },
|
||||
{ key: 'vehicleRC', label: 'RC Copy', hint: 'For a used vehicle' },
|
||||
{ key: 'vehicleInsurance', label: 'Insurance Copy' }
|
||||
],
|
||||
CC: [
|
||||
{ key: 'udyamRegistration', label: 'Udyam / Business Registration Certificate' },
|
||||
{ key: 'gstCertificate', label: 'GST Registration Certificate' },
|
||||
{ key: 'businessFinancials', label: "Last 2 Years' Business Financial Statements" },
|
||||
{ key: 'businessAddressProof', label: 'Business Address Proof' },
|
||||
{ key: 'partnershipDeed', label: 'Partnership Deed / MOA-AOA', hint: 'If applicable' }
|
||||
],
|
||||
EL: [
|
||||
{ key: 'admissionLetter', label: 'Admission Letter from Institution' },
|
||||
{ key: 'feeStructure', label: 'Fee Structure / Schedule' },
|
||||
{ key: 'marksheets', label: 'Previous Qualifying Exam Marksheets' },
|
||||
{ key: 'coBorrowerKyc', label: 'Co-Borrower (Parent/Guardian) KYC & Income Proof' }
|
||||
],
|
||||
GL: [
|
||||
{ key: 'goldValuation', label: 'Gold Appraisal / Valuation Certificate' },
|
||||
{ key: 'goldPurchaseInvoice', label: 'Purchase Invoice of Gold Ornaments', hint: 'If available' }
|
||||
],
|
||||
PL: [{ key: 'employmentProof', label: 'Employment Proof / Appointment Letter' }]
|
||||
};
|
||||
|
||||
export function buildProductDocuments(productCode: string): ProductDocumentEntry[] {
|
||||
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Suresh',
|
||||
@@ -534,7 +643,7 @@ const BC_AGENTS = [
|
||||
'Digital Seva Kendra — Sangli'
|
||||
];
|
||||
|
||||
const MANAGERS = [
|
||||
export const MANAGERS = [
|
||||
'Anil Deshpande (Branch Manager)',
|
||||
'Sunil Kadam (Branch Manager)',
|
||||
'Vikas Pathak (Credit Manager)',
|
||||
@@ -558,6 +667,20 @@ const FI_AGENCIES = [
|
||||
'VerifyIndia Field Services'
|
||||
];
|
||||
|
||||
export const PROPERTY_TYPES = [
|
||||
'Residential',
|
||||
'Commercial',
|
||||
'Plot',
|
||||
'Under Construction'
|
||||
];
|
||||
|
||||
const VALUERS = [
|
||||
'Apex Valuers & Engineers',
|
||||
'Prime Property Valuation Services',
|
||||
'National Valuation Associates',
|
||||
'Metro Approved Valuers'
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Delegation of Powers — sanctioning authority by loan amount, exactly the kind
|
||||
// of amount-based approval matrix every Indian bank/co-operative society runs.
|
||||
@@ -607,7 +730,18 @@ export function stageBadgeClass(app: LoanApplication): string {
|
||||
}
|
||||
|
||||
function blankVerification(): VerificationCheck {
|
||||
return { status: 'PENDING', verifiedBy: '—', verifiedDate: '—', remarks: '—' };
|
||||
return {
|
||||
status: 'PENDING',
|
||||
verifiedBy: '—',
|
||||
verifiedDate: '—',
|
||||
remarks: '—',
|
||||
officerAssigned: '—',
|
||||
assignedDate: '—',
|
||||
assignedAt: 0,
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
photoCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function blankApproval(): ApprovalRecord {
|
||||
@@ -703,6 +837,14 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
const legalOfficer = pick(LEGAL_OFFICERS);
|
||||
const fiAgency = pick(FI_AGENCIES);
|
||||
|
||||
// Reverse-engineered the same way monthlyIncome is -- area × rate lands close to a plausible
|
||||
// multiple of the requested amount, rather than picking propertyValuation directly and having
|
||||
// no consistent area/rate breakdown behind it.
|
||||
const propertyBuiltUpAreaSqFt = product.secured ? round(between(600, 2400)) : 0;
|
||||
const propertyMarketRatePerSqFt = product.secured
|
||||
? round((requestedAmount * between(1.1, 1.4)) / propertyBuiltUpAreaSqFt)
|
||||
: 0;
|
||||
|
||||
const app: LoanApplication = {
|
||||
applicationNo: `LN${branch.code.slice(2)}${product.code}${seq}`,
|
||||
applicationDate,
|
||||
@@ -714,6 +856,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
customerType,
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
productType: pick(allowedProductTypes(product.code)),
|
||||
secured: product.secured,
|
||||
requestedAmount,
|
||||
tenureMonths,
|
||||
@@ -725,10 +868,8 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
panVerified: false,
|
||||
incomeProofSubmitted: false,
|
||||
bankStatementSubmitted: false,
|
||||
propertyDetailsSubmitted: false,
|
||||
taxReceiptSubmitted: false,
|
||||
electricityBillSubmitted: false,
|
||||
aadhaarFingerprintCaptured: false,
|
||||
productDocuments: buildProductDocuments(product.code),
|
||||
monthlyIncome,
|
||||
eligibleLoanAmount,
|
||||
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
|
||||
@@ -750,10 +891,24 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
addressVerification: blankVerification(),
|
||||
officeVerification: blankVerification(),
|
||||
propertyVerification: blankVerification(),
|
||||
propertyValuation: product.secured ? round(requestedAmount * between(1.1, 1.4)) : 0,
|
||||
hasShop: rng() < 0.3,
|
||||
shopVerification: blankVerification(),
|
||||
propertyType: product.secured ? pick(PROPERTY_TYPES) : '—',
|
||||
builtUpAreaSqFt: propertyBuiltUpAreaSqFt,
|
||||
marketRatePerSqFt: propertyMarketRatePerSqFt,
|
||||
guidelineRatePerSqFt: product.secured ? round(propertyMarketRatePerSqFt * between(0.6, 0.85)) : 0,
|
||||
valuerName: product.secured ? pick(VALUERS) : '—',
|
||||
valuationDate: product.secured ? fmt(addDays(today, -round(between(5, 60)))) : '—',
|
||||
propertyValuation: product.secured ? propertyBuiltUpAreaSqFt * propertyMarketRatePerSqFt : 0,
|
||||
agentReportUploaded: false,
|
||||
agentReportFileName: '—',
|
||||
agentReportRemark: '—',
|
||||
discrepancyType: 'NONE',
|
||||
meetingScheduled: false,
|
||||
meetingDate: '',
|
||||
meetingTime: '',
|
||||
meetingMode: '',
|
||||
meetingWith: '',
|
||||
personalDiscussionNotes: '—',
|
||||
personalDiscussionDate: '—',
|
||||
declarationTaken: false,
|
||||
@@ -836,10 +991,8 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
app.panVerified = true;
|
||||
app.incomeProofSubmitted = true;
|
||||
app.bankStatementSubmitted = true;
|
||||
app.propertyDetailsSubmitted = product.secured;
|
||||
app.taxReceiptSubmitted = product.secured;
|
||||
app.electricityBillSubmitted = true;
|
||||
app.aadhaarFingerprintCaptured = true;
|
||||
app.productDocuments.forEach((doc) => (doc.submitted = true));
|
||||
app.stageHistory.push({
|
||||
stage,
|
||||
date: fmt(cursor),
|
||||
@@ -876,24 +1029,57 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
status: 'VERIFIED',
|
||||
verifiedBy: fiAgency,
|
||||
verifiedDate: fmt(cursor),
|
||||
remarks: 'Registered address confirmed on physical visit.'
|
||||
remarks: 'Registered address confirmed on physical visit.',
|
||||
officerAssigned: fiAgency,
|
||||
assignedDate: fmt(cursor),
|
||||
assignedAt: cursor.getTime(),
|
||||
latitude: (23.2 + rng() * 5).toFixed(6),
|
||||
longitude: (77.4 + rng() * 5).toFixed(6),
|
||||
photoCount: 2 + Math.floor(rng() * 3)
|
||||
};
|
||||
app.officeVerification = {
|
||||
status: 'VERIFIED',
|
||||
verifiedBy: fiAgency,
|
||||
verifiedDate: fmt(cursor),
|
||||
remarks: 'Employment/business confirmed — Agent Report filed.'
|
||||
remarks: 'Employment/business confirmed — Agent Report filed.',
|
||||
officerAssigned: fiAgency,
|
||||
assignedDate: fmt(cursor),
|
||||
assignedAt: cursor.getTime(),
|
||||
latitude: (23.2 + rng() * 5).toFixed(6),
|
||||
longitude: (77.4 + rng() * 5).toFixed(6),
|
||||
photoCount: 2 + Math.floor(rng() * 3)
|
||||
};
|
||||
if (product.secured) {
|
||||
app.propertyVerification = {
|
||||
status: 'VERIFIED',
|
||||
verifiedBy: fiAgency,
|
||||
verifiedDate: fmt(cursor),
|
||||
remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.`
|
||||
remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.`,
|
||||
officerAssigned: fiAgency,
|
||||
assignedDate: fmt(cursor),
|
||||
assignedAt: cursor.getTime(),
|
||||
latitude: (23.2 + rng() * 5).toFixed(6),
|
||||
longitude: (77.4 + rng() * 5).toFixed(6),
|
||||
photoCount: 2 + Math.floor(rng() * 3)
|
||||
};
|
||||
}
|
||||
if (app.hasShop) {
|
||||
app.shopVerification = {
|
||||
status: 'VERIFIED',
|
||||
verifiedBy: fiAgency,
|
||||
verifiedDate: fmt(cursor),
|
||||
remarks: 'Shop/business premises visited and confirmed operational.',
|
||||
officerAssigned: fiAgency,
|
||||
assignedDate: fmt(cursor),
|
||||
assignedAt: cursor.getTime(),
|
||||
latitude: (23.2 + rng() * 5).toFixed(6),
|
||||
longitude: (77.4 + rng() * 5).toFixed(6),
|
||||
photoCount: 2 + Math.floor(rng() * 3)
|
||||
};
|
||||
}
|
||||
app.agentReportUploaded = true;
|
||||
app.agentReportFileName = `AgentReport_${app.applicationNo}.pdf`;
|
||||
app.agentReportRemark = 'All addresses visited and cross-verified — no discrepancies noted.';
|
||||
app.stageHistory.push({
|
||||
stage,
|
||||
date: fmt(cursor),
|
||||
@@ -906,6 +1092,15 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
break;
|
||||
|
||||
case 'PERSONAL_DISCUSSION':
|
||||
app.meetingScheduled = true;
|
||||
app.meetingDate = fmt(cursor);
|
||||
app.meetingTime = `${String(10 + Math.floor(rng() * 6)).padStart(2, '0')}:${rng() < 0.5 ? '00' : '30'}`;
|
||||
app.meetingMode = pick<'ONLINE' | 'OFFLINE' | 'CALL'>([
|
||||
'ONLINE',
|
||||
'OFFLINE',
|
||||
'CALL'
|
||||
]);
|
||||
app.meetingWith = manager;
|
||||
if (isHoldStage) {
|
||||
app.discrepancyType = pick<DiscrepancyType>([
|
||||
'DOCUMENT_DISCREPANCY',
|
||||
@@ -1174,6 +1369,30 @@ export function moveToStage(app: LoanApplication, stage: OriginationStage, actio
|
||||
app.currentStage = stage;
|
||||
}
|
||||
|
||||
/** Books the Personal Discussion appointment -- date, time, mode and which bank officer is
|
||||
* taking it -- before any discussion notes exist, so there's a real scheduled meeting on record
|
||||
* rather than a discussion report just appearing with no appointment behind it. */
|
||||
export function scheduleMeeting(
|
||||
app: LoanApplication,
|
||||
date: string,
|
||||
time: string,
|
||||
mode: 'ONLINE' | 'OFFLINE' | 'CALL',
|
||||
withWhom: string,
|
||||
actionBy: string
|
||||
): void {
|
||||
app.meetingScheduled = true;
|
||||
app.meetingDate = date;
|
||||
app.meetingTime = time;
|
||||
app.meetingMode = mode;
|
||||
app.meetingWith = withWhom;
|
||||
recordStageProgress(
|
||||
app,
|
||||
actionBy,
|
||||
'Meeting Scheduled',
|
||||
`Personal Discussion scheduled with ${withWhom} on ${date} ${time} (${mode}).`
|
||||
);
|
||||
}
|
||||
|
||||
export function flagDiscrepancy(app: LoanApplication, type: DiscrepancyType, actionBy: string, notes: string): void {
|
||||
app.discrepancyType = type;
|
||||
app.personalDiscussionDate = todayDDMMYYYY();
|
||||
@@ -1264,20 +1483,48 @@ export function pushFieldInquiry(app: LoanApplication, agencyName: string, actio
|
||||
recordStageProgress(app, actionBy, 'FI Pushed', `Field Inquiry request pushed to ${agencyName}.`);
|
||||
}
|
||||
|
||||
export function uploadAgentReport(app: LoanApplication, fileName: string, actionBy: string): void {
|
||||
export function uploadAgentReport(app: LoanApplication, fileName: string, remark: string, actionBy: string): void {
|
||||
app.agentReportUploaded = true;
|
||||
app.agentReportFileName = fileName;
|
||||
recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded.`);
|
||||
app.agentReportRemark = remark || 'Agent Report filed.';
|
||||
recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded — ${app.agentReportRemark}`);
|
||||
}
|
||||
|
||||
export type FiCheckKey = 'addressVerification' | 'officeVerification' | 'propertyVerification' | 'shopVerification';
|
||||
|
||||
export const FI_CHECK_LABELS: Record<FiCheckKey, string> = {
|
||||
addressVerification: 'Home Address',
|
||||
officeVerification: 'Office / Employment Address',
|
||||
propertyVerification: 'Property Address',
|
||||
shopVerification: 'Shop / Business Premises'
|
||||
};
|
||||
|
||||
/** Sends a field officer out to physically visit and verify one of the 3 FI addresses -- resets
|
||||
* that address's whole visit cycle (GPS, photo count, prior remarks) fresh, since a newly
|
||||
* assigned visit (including a retry after a FAILED one) starts the 48-hour SLA clock over. */
|
||||
export function assignFieldOfficer(app: LoanApplication, which: FiCheckKey, officerName: string): void {
|
||||
app[which] = {
|
||||
status: 'PENDING',
|
||||
verifiedBy: '—',
|
||||
verifiedDate: '—',
|
||||
remarks: '—',
|
||||
officerAssigned: officerName,
|
||||
assignedDate: todayDDMMYYYY(),
|
||||
assignedAt: Date.now(),
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
photoCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function recordVerification(
|
||||
app: LoanApplication,
|
||||
which: 'addressVerification' | 'officeVerification' | 'propertyVerification',
|
||||
which: FiCheckKey,
|
||||
status: VerificationStatus,
|
||||
verifiedBy: string,
|
||||
remarks: string
|
||||
): void {
|
||||
app[which] = { status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks };
|
||||
app[which] = { ...app[which], status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks };
|
||||
}
|
||||
|
||||
/** Cooperative-bank membership can be allotted independently of the loan's own progress — a
|
||||
@@ -1348,14 +1595,45 @@ export function disburseApplication(app: LoanApplication, actionBy: string, mode
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows
|
||||
// once a term loan is disbursed. Cash Credit / KCC are revolving limits renewed annually, not
|
||||
// repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading one.
|
||||
// Product Type — Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed
|
||||
// annually rather than repaid on a fixed instalment schedule).
|
||||
// -------------------------------------------------------------------------
|
||||
export function isRevolvingProduct(productCode: string): boolean {
|
||||
return productCode === 'CC' || productCode === 'KCC';
|
||||
export type ProductType = 'TERM_LOAN' | 'OVERDRAFT';
|
||||
|
||||
export const PRODUCT_TYPE_LABELS: Record<ProductType, string> = {
|
||||
TERM_LOAN: 'Term Loan',
|
||||
OVERDRAFT: 'Overdraft'
|
||||
};
|
||||
|
||||
/** Which Product Type(s) a given product can be sanctioned as. Cash Credit and Kisan Credit Card
|
||||
* are inherently revolving limits, so Overdraft is the only option. Gold Loan is commonly
|
||||
* written up either way at a UCB -- a straight EMI term loan, or a 1-year overdraft/cash-credit
|
||||
* limit renewed annually -- so both are offered, defaulting to Overdraft (the more common
|
||||
* cooperative-bank practice for gold loans). Every other product here is a straight term loan. */
|
||||
export function allowedProductTypes(productCode: string): ProductType[] {
|
||||
if (productCode === 'CC' || productCode === 'KCC') {
|
||||
return ['OVERDRAFT'];
|
||||
}
|
||||
if (productCode === 'GL') {
|
||||
return [
|
||||
'OVERDRAFT',
|
||||
'TERM_LOAN'
|
||||
];
|
||||
}
|
||||
return ['TERM_LOAN'];
|
||||
}
|
||||
|
||||
export function defaultProductType(productCode: string): ProductType {
|
||||
return allowedProductTypes(productCode)[0];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows
|
||||
// once a term loan is disbursed. Overdraft-type products are revolving limits renewed annually,
|
||||
// not repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading
|
||||
// one (see LoanApplication.productType).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface EmiScheduleRow {
|
||||
installmentNo: number;
|
||||
dueDate: string;
|
||||
@@ -1426,6 +1704,7 @@ export interface NewApplicationInput {
|
||||
branchName: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
productType: ProductType;
|
||||
secured: boolean;
|
||||
requestedAmount: number;
|
||||
tenureMonths: number;
|
||||
@@ -1447,6 +1726,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
clientId: input.clientId ?? null,
|
||||
productCode: input.productCode,
|
||||
productName: input.productName,
|
||||
productType: input.productType,
|
||||
secured: input.secured,
|
||||
requestedAmount: input.requestedAmount,
|
||||
tenureMonths: input.tenureMonths,
|
||||
@@ -1458,10 +1738,8 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
panVerified: false,
|
||||
incomeProofSubmitted: false,
|
||||
bankStatementSubmitted: false,
|
||||
propertyDetailsSubmitted: false,
|
||||
taxReceiptSubmitted: false,
|
||||
electricityBillSubmitted: false,
|
||||
aadhaarFingerprintCaptured: false,
|
||||
productDocuments: buildProductDocuments(input.productCode),
|
||||
monthlyIncome: 0,
|
||||
eligibleLoanAmount: 0,
|
||||
coBorrowerRequired: false,
|
||||
@@ -1483,10 +1761,24 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
addressVerification: blankVerification(),
|
||||
officeVerification: blankVerification(),
|
||||
propertyVerification: blankVerification(),
|
||||
hasShop: false,
|
||||
shopVerification: blankVerification(),
|
||||
propertyType: '—',
|
||||
builtUpAreaSqFt: 0,
|
||||
marketRatePerSqFt: 0,
|
||||
guidelineRatePerSqFt: 0,
|
||||
valuerName: '—',
|
||||
valuationDate: '—',
|
||||
propertyValuation: 0,
|
||||
agentReportUploaded: false,
|
||||
agentReportFileName: '—',
|
||||
agentReportRemark: '—',
|
||||
discrepancyType: 'NONE',
|
||||
meetingScheduled: false,
|
||||
meetingDate: '',
|
||||
meetingTime: '',
|
||||
meetingMode: '',
|
||||
meetingWith: '',
|
||||
personalDiscussionNotes: '—',
|
||||
personalDiscussionDate: '—',
|
||||
declarationTaken: false,
|
||||
|
||||
@@ -268,6 +268,14 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 20px;
|
||||
padding: 18px;
|
||||
|
||||
&--tight-bottom {
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
&--tight-top {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loan-kv-field {
|
||||
@@ -594,6 +602,53 @@
|
||||
margin: -2px 0 0;
|
||||
}
|
||||
|
||||
// Field Investigation -- one card per address (Home/Office/Property/Shop) instead of stacked
|
||||
// full-width sections, since each visit's officer/SLA/GPS/photo capture is a self-contained unit.
|
||||
.loi-fi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.loi-fi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-fi-card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.loi-fi-view-details {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-otp-digits {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
@@ -668,3 +723,53 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-opens the bank statement analysis dialog for a CSV already on file -- the analysis only
|
||||
// pops up once automatically (right after upload), so this is the only way back into it once an
|
||||
// officer has closed it and wants to double-check something.
|
||||
.loi-bsa-reopen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e0e7ff;
|
||||
border-color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
// Loan-specific documents (Housing/KCC/Vehicle/... papers) -- an officer-added custom document
|
||||
// gets a small remove control right next to its title; the product's own required list never
|
||||
// shows one, since removeCustomProductDoc() is a no-op for a non-custom entry anyway.
|
||||
.loi-doc-remove {
|
||||
margin-left: 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-custom-doc-input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -297,12 +297,20 @@
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product</span>
|
||||
<select [(ngModel)]="newApp.productCode">
|
||||
<select [(ngModel)]="newApp.productCode" (ngModelChange)="onProductChange()">
|
||||
@for (p of products; track p.code) {
|
||||
<option [value]="p.code">{{ p.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product Type</span>
|
||||
<select [(ngModel)]="newApp.productType">
|
||||
@for (t of newAppProductTypeOptions; track t) {
|
||||
<option [value]="t">{{ productTypeLabels[t] }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Amount</span>
|
||||
<input type="number" [(ngModel)]="newApp.requestedAmount" placeholder="₹" />
|
||||
|
||||
@@ -27,12 +27,16 @@ import {
|
||||
OriginationStage,
|
||||
OverallStatus,
|
||||
PRODUCT_TARGET_PCT,
|
||||
PRODUCT_TYPE_LABELS,
|
||||
ProductType,
|
||||
STAGE_LABELS,
|
||||
STAGE_ORDER,
|
||||
STATUS_LABELS,
|
||||
TARGET_STATUS_LABELS,
|
||||
TargetStatus,
|
||||
allowedProductTypes,
|
||||
createApplication,
|
||||
defaultProductType,
|
||||
progressPct,
|
||||
stageBadgeClass,
|
||||
targetStatus,
|
||||
@@ -138,10 +142,26 @@ export class LoanPipelineComponent implements OnInit {
|
||||
clientId: null as number | null,
|
||||
branchCode: this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
productType: defaultProductType(this.products[0].code),
|
||||
requestedAmount: null as number | null,
|
||||
tenureMonths: null as number | null
|
||||
};
|
||||
|
||||
readonly productTypeLabels = PRODUCT_TYPE_LABELS;
|
||||
|
||||
/** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
|
||||
* are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
|
||||
get newAppProductTypeOptions(): ProductType[] {
|
||||
return allowedProductTypes(this.newApp.productCode);
|
||||
}
|
||||
|
||||
/** Switching Product can change which Product Types are even valid -- reset to that product's
|
||||
* default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
|
||||
* on a product that doesn't offer it. */
|
||||
onProductChange(): void {
|
||||
this.newApp.productType = defaultProductType(this.newApp.productCode);
|
||||
}
|
||||
|
||||
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
|
||||
existingClientSearchText = '';
|
||||
existingClientSearchResults: any[] = [];
|
||||
@@ -513,6 +533,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
branchName: branch.name,
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
productType: this.newApp.productType,
|
||||
secured: product.secured,
|
||||
requestedAmount: this.newApp.requestedAmount,
|
||||
tenureMonths: this.newApp.tenureMonths || product.minTenure,
|
||||
@@ -530,6 +551,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
clientId: null,
|
||||
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
productType: defaultProductType(this.products[0].code),
|
||||
requestedAmount: null,
|
||||
tenureMonths: null
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user