feat(loan-origination): product type, income/co-borrower affordability, bank statement & CIC analysis, richer Field Investigation, and meeting-scheduled Personal Discussion
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

- 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:
2026-08-13 15:14:39 +05:30
co-authored by Claude Sonnet 5
parent 5581d4180d
commit 997e8920e4
20 changed files with 4747 additions and 241 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -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;
}
}
@@ -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();
}
}
@@ -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
};
}
@@ -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>
@@ -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;
}
@@ -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();
}
}
@@ -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)
};
}
@@ -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>
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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>
@@ -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;
}
@@ -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';
}
}
+320 -28
View File
@@ -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
};