feat: letter-mgmt module + digital UPI/IMPS/ATM-POS reports + header redesign

letter-mgmt (new module):
- Notice page: overdue loan list with filters, KPI strip, bulk actions,
  call disposition logging (Promise-to-Pay, Not Reachable, etc.), Speed
  Post dispatch dialog with consignment + post-office tracking,
  PDF preview + download, call-contact dialog with primary / alt /
  family / nominee numbers and per-contact disposition history.
- Certificates: Loan Interest, Share, Balance, Form 60 (no-PAN flow),
  Form 15G/H (auto age-based), Hypothecation, NOC - each with shared
  preview dialog (six tone variants) and jsPDF generator using a
  common letterhead / summary-grid / signature-seal helper.

digital:
- IMPS module: report (P2A/P2P/Aadhaar tabs) + entry form with
  MMID/mobile lookup, NPCI switch resolve-to-A/C+IFSC BANL flow,
  sender autocomplete, hide sender field for P2P/Aadhaar, charges +
  18% GST + waive checkbox, per-sender today-only history dialog.
- UPI report with app-mix tab and full transaction filtering.
- ATM/POS/ECOM hub with three ticket cards routing to today/status/cycle.
- NEFT/RTGS entry: sender-account autocomplete, charges + GST + waive,
  field reorder (sender before amount), NEFT cap / RTGS floor
  validation, History button opening per-sender today's transactions.
- NEFT/RTGS report: horizontal-bar trend leaderboard replacing tall bars.

shell + theme:
- Header redesigned with IBM Plex Sans 12/500 typography, 11-column grid
  per row, top-accent hover indicator, bordered cells via grid-gap.
- Live KPI ticker below menu (CASH, TXN TODAY, CLEARING IN/OUT, NPA,
  CASA, CD RATIO, RBI CIRC, REPO) with marquee scroll, pause-on-hover,
  brand-blue gradient, amber bottom strip, pulsing red LIVE chip.
- Sidebar 'KYC Management' -> 'KYC & ReKYC Management'.
- Letter Mgmt / certificate menu items wired with green icon hints for
  items that have a working page.

access-control:
- MFA card layout compacted; method header inlines back button + title.

icons:
- Register faTruck/TruckFast, faEllipsisV/H, faCommentDots.
- Add IBM Plex Sans Google Fonts import to index.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-19 10:09:06 +05:30
co-authored by Claude Opus 4.7
parent 4322f37c6d
commit 325228c514
138 changed files with 27473 additions and 3997 deletions
@@ -0,0 +1,425 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nrr-root">
<!-- HEADER -->
<div class="nrr-head">
<div class="nrr-head-left">
<fa-icon icon="mobile-alt" class="nrr-head-ic"></fa-icon>
<div>
<h2 class="nrr-head-t">UPI — Report &amp; Reconciliation</h2>
<div class="nrr-head-s">
Digital · Unified Payments Interface · Showing
<strong>{{ totalCount | number }}</strong> transactions worth
<strong>₹{{ totalAmount | number: '1.0-0' }}</strong>
</div>
</div>
</div>
<div class="nrr-head-actions">
<button mat-stroked-button (click)="exportCsv()" matTooltip="Download filtered transactions as CSV">
<fa-icon icon="download"></fa-icon> Export CSV
</button>
</div>
</div>
<!-- KPI STRIP -->
<div class="nrr-kpis">
<div class="nrr-kpi nrr-kpi--blue">
<div class="nrr-kpi-ic"><fa-icon icon="mobile-alt"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ totalCount | number }}</div>
<div class="nrr-kpi-l">Total Transactions</div>
<div class="nrr-kpi-sub">{{ p2pCount }} P2P &middot; {{ p2mCount }} P2M</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--teal">
<div class="nrr-kpi-ic"><fa-icon icon="coins"></fa-icon></div>
<div>
<div class="nrr-kpi-n">₹{{ totalAmount | number: '1.0-0' }}</div>
<div class="nrr-kpi-l">Total Value</div>
<div class="nrr-kpi-sub">avg ₹{{ avgTicket | number: '1.0-0' }} / txn</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--green">
<div class="nrr-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ successCount | number }}</div>
<div class="nrr-kpi-l">Successful</div>
<div class="nrr-kpi-sub">success {{ successRate | number: '1.1-1' }}%</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--amber">
<div class="nrr-kpi-ic"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ pendingCount | number }}</div>
<div class="nrr-kpi-l">In-Flight</div>
<div class="nrr-kpi-sub">initiated / pending</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--red">
<div class="nrr-kpi-ic"><fa-icon icon="times-circle"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ failedCount | number }}</div>
<div class="nrr-kpi-l">Failed / Expired</div>
<div class="nrr-kpi-sub">check failure codes</div>
</div>
</div>
</div>
<!-- TABS -->
<div class="nrr-tabs">
@for (t of tabs; track t.id) {
<button class="nrr-tab" [class.active]="activeTab === t.id" (click)="showTab(t.id)">
<fa-icon [icon]="t.icon"></fa-icon> {{ t.label }}
</button>
}
</div>
<!-- TRENDS -->
@if (activeTab === 'trends') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="chart-line" class="nrr-card-h-ic"></fa-icon> Daily Throughput (Last 7 Value Dates)</span>
<span class="nrr-pill nrr-pill--blue">P2P</span>
<span class="nrr-pill nrr-pill--purple">P2M</span>
</div>
<div class="nrr-trend">
@for (b of trendBuckets; track b.date) {
<div class="nrr-trend-row">
<div class="nrr-trend-row-date">
<span class="nrr-trend-row-day">{{ b.date | date: 'EEE' }}</span>
<span class="nrr-trend-row-md">{{ b.date | date: 'd MMM' }}</span>
</div>
<div
class="nrr-trend-row-bar-wrap"
[matTooltip]="'P2P ' + b.p2p + ' · P2M ' + b.p2m + ' · ' + (b.count | number) + ' txns'"
>
<div class="nrr-trend-row-bar" [style.width.%]="trendBarHeight(b.amount)"></div>
</div>
<div class="nrr-trend-row-pills">
<span class="nrr-pill nrr-pill--blue mini">P2P {{ b.p2p }}</span>
<span class="nrr-pill nrr-pill--purple mini">P2M {{ b.p2m }}</span>
</div>
<div class="nrr-trend-row-amt">₹{{ b.amount | number: '1.0-0' }}</div>
</div>
}
</div>
</div>
}
<!-- HANDLER BANK -->
@if (activeTab === 'bank') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="university" class="nrr-card-h-ic"></fa-icon> Payee / Handler Bank Distribution</span>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table">
<thead>
<tr>
<th>Bank</th>
<th class="num">Count</th>
<th class="num">Amount</th>
<th class="num">Success</th>
<th class="num">Pending</th>
<th class="num">Failed</th>
</tr>
</thead>
<tbody>
@for (r of bankRows; track r.bank) {
<tr>
<td>
<strong>{{ r.bank }}</strong>
</td>
<td class="num">{{ r.count | number }}</td>
<td class="num">
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--green mini">{{ r.success }}</span>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--amber mini">{{ r.pending }}</span>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--red mini">{{ r.failed }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- DATE-WISE -->
@if (activeTab === 'date') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="calendar-alt" class="nrr-card-h-ic"></fa-icon> Value-Date Aggregation</span>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table">
<thead>
<tr>
<th>Value Date</th>
<th class="num">Count</th>
<th class="num">P2P</th>
<th class="num">P2M</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of dateRows; track r.date) {
<tr>
<td>{{ r.date | date: 'EEE, d MMM y' }}</td>
<td class="num">{{ r.count | number }}</td>
<td class="num">
<span class="nrr-pill nrr-pill--blue mini">{{ r.p2p }}</span>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--purple mini">{{ r.p2m }}</span>
</td>
<td class="num">
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- SUCCESS / PENDING / FAILED -->
@if (activeTab === 'pending') {
<div class="nrr-grid-2">
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="tasks" class="nrr-card-h-ic"></fa-icon> Processing-State Funnel</span>
</div>
<div class="nrr-funnel">
<div class="nrr-funnel-row">
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--amber"></span> Initiated / Pending </span>
<span class="nrr-funnel-v">{{ pendingCount | number }}</span>
</div>
<div class="nrr-funnel-row">
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--green"></span> Successful </span>
<span class="nrr-funnel-v">{{ successCount | number }}</span>
</div>
<div class="nrr-funnel-row">
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--red"></span> Failed / Expired / Declined </span>
<span class="nrr-funnel-v">{{ failedCount | number }}</span>
</div>
<div class="nrr-funnel-meter">
<span class="seg green" [style.flex]="successCount || 0"></span>
<span class="seg amber" [style.flex]="pendingCount || 0"></span>
<span class="seg red" [style.flex]="failedCount || 0"></span>
</div>
</div>
</div>
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="exclamation-triangle" class="nrr-card-h-ic"></fa-icon> Failure-Code Breakdown</span>
</div>
@if (failureCodeRows.length === 0) {
<div class="nrr-empty">No failed transactions in current window.</div>
} @else {
<table class="nrr-table">
<thead>
<tr>
<th>Code</th>
<th>Reason</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of failureCodeRows; track r.code) {
<tr>
<td>
<span class="nrr-pill nrr-pill--red mini">{{ r.code }}</span>
</td>
<td>{{ r.reason }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<!-- APP-WISE -->
@if (activeTab === 'app') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="mobile-alt" class="nrr-card-h-ic"></fa-icon> UPI App Mix</span>
</div>
<div class="nrr-channel-grid">
@for (r of appRows; track r.app) {
<div class="nrr-channel-card">
<div class="nrr-channel-ic">
<fa-icon icon="mobile-alt"></fa-icon>
</div>
<div class="nrr-channel-body">
<div class="nrr-channel-t">{{ r.app }}</div>
<div class="nrr-channel-n">{{ r.count | number }} <span>txns</span></div>
<div class="nrr-channel-amt">₹{{ r.amount | number: '1.0-0' }}</div>
<div class="nrr-channel-bar">
<span [style.width.%]="r.share"></span>
</div>
<div class="nrr-channel-share">{{ r.share | number: '1.1-1' }}% of value</div>
</div>
</div>
}
</div>
</div>
}
<!-- USER-WISE -->
@if (activeTab === 'user') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="user-tie" class="nrr-card-h-ic"></fa-icon> Top Payers</span>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table">
<thead>
<tr>
<th>Payer</th>
<th>Branch</th>
<th class="num">Txns</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of userRows; track r.user + r.branch) {
<tr>
<td>
<strong>{{ r.user }}</strong>
</td>
<td>{{ r.branch }}</td>
<td class="num">{{ r.count | number }}</td>
<td class="num">
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TRANSACTION DETAIL TABLE -->
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="list-alt" class="nrr-card-h-ic"></fa-icon> Transactions</span>
<div class="nrr-filters">
<div class="nrr-search">
<fa-icon icon="search" class="nrr-search-ic"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="Search RRN, UPI Ref, VPA, Name, Bank…" />
</div>
<select [(ngModel)]="typeFilter" class="nrr-select" matTooltip="Filter by UPI sub-type">
<option value="">All Types</option>
<option value="P2P">P2P · Person-to-Person</option>
<option value="P2M">P2M · Person-to-Merchant</option>
<option value="COLLECT">Collect Request</option>
</select>
<select [(ngModel)]="stateFilter" class="nrr-select" matTooltip="Filter by processing state">
<option value="">All States</option>
<option value="INITIATED">Initiated</option>
<option value="PENDING">Pending</option>
<option value="SUCCESS">Success</option>
<option value="FAILED">Failed</option>
<option value="EXPIRED">Expired</option>
<option value="DECLINED">Declined</option>
</select>
@if (search || typeFilter || stateFilter) {
<button type="button" class="nrr-clear-btn" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
</div>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table nrr-table-detail">
<thead>
<tr>
<th>RRN / UPI Ref</th>
<th>Payer</th>
<th>Payee</th>
<th>App</th>
<th>Type</th>
<th class="num">Amount</th>
<th>Value Date</th>
<th>State</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
@for (t of filteredTxns; track t.rrn) {
<tr>
<td>
<div class="mono small">{{ t.rrn }}</div>
<div class="mono small muted">{{ t.upiRef }}</div>
</td>
<td>
<div>{{ t.payerName }}</div>
<div class="mono small muted">{{ t.payerVpa }}</div>
</td>
<td>
<div>
{{ t.payeeName }}
@if (t.payeeType === 'MERCHANT') {
<span class="nrr-pill nrr-pill--teal mini">Merchant</span>
}
</div>
<div class="mono small muted">{{ t.payeeVpa }} · {{ t.payeeBank }}</div>
</td>
<td>
<span class="nrr-pill mini">{{ t.payerApp }}</span>
</td>
<td>
<span
class="nrr-pill mini"
[class.nrr-pill--purple]="t.txnType === 'P2M'"
[class.nrr-pill--blue]="t.txnType === 'P2P'"
[class.nrr-pill--teal]="t.txnType === 'COLLECT'"
>
{{ t.txnType }}
</span>
</td>
<td class="num">
<strong>₹{{ t.amount | number: '1.2-2' }}</strong>
</td>
<td>{{ t.valueDate | date: 'd MMM y' }}</td>
<td>
<span class="nrr-pill mini" [ngClass]="'nrr-pill--' + stateColor(t.state)">
{{ t.state }}
</span>
@if (t.failureCode) {
<div class="small muted">{{ t.failureCode }} · {{ t.failureReason }}</div>
}
</td>
<td class="mono small">{{ t.sentTimestamp }}</td>
</tr>
}
@if (filteredTxns.length === 0) {
<tr>
<td colspan="9" class="nrr-empty">No transactions match the current filters.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,618 @@
/**
* 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, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Person-to-Person, Person-to-Merchant, or Collect-Request (payee-initiated). */
export type UpiTxnType = 'P2P' | 'P2M' | 'COLLECT';
export type UpiState = 'INITIATED' | 'PENDING' | 'SUCCESS' | 'FAILED' | 'EXPIRED' | 'DECLINED';
export type UpiApp = 'BHIM' | 'PhonePe' | 'GPay' | 'Paytm' | 'Amazon Pay' | 'WhatsApp' | 'Other';
export interface UpiTxn {
rrn: string;
upiRef: string;
payerVpa: string;
payerName: string;
payerBranch: string;
payerApp: UpiApp;
payeeVpa: string;
payeeName: string;
payeeBank: string;
payeeType: 'PERSON' | 'MERCHANT';
txnType: UpiTxnType;
amount: number;
valueDate: string;
state: UpiState;
failureCode?: string;
failureReason?: string;
sentTimestamp: string;
}
type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'app' | 'user';
interface BankRow {
bank: string;
count: number;
amount: number;
success: number;
pending: number;
failed: number;
}
interface DateRow {
date: string;
count: number;
amount: number;
p2p: number;
p2m: number;
}
interface UserRow {
user: string;
count: number;
amount: number;
branch: string;
}
interface AppRow {
app: UpiApp;
count: number;
amount: number;
share: number;
}
@Component({
selector: 'mifosx-upi-report',
templateUrl: './upi-report.component.html',
// Reuse the NEFT/RTGS report SCSS so all three rail-reports share the same shell.
styleUrls: ['../neft-rtgs-report/neft-rtgs-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class UpiReportComponent implements OnInit {
activeTab: ReportTab = 'trends';
search = '';
typeFilter: '' | UpiTxnType = '';
stateFilter: '' | UpiState = '';
txns: UpiTxn[] = [];
readonly tabs: { id: ReportTab; label: string; icon: string }[] = [
{ id: 'trends', label: 'Trends', icon: 'chart-line' },
{ id: 'bank', label: 'Handler-Bank', icon: 'university' },
{ id: 'date', label: 'Date-wise', icon: 'calendar-alt' },
{ id: 'pending', label: 'Success / Pending / Failed', icon: 'tasks' },
{ id: 'app', label: 'App-wise', icon: 'mobile-alt' },
{ id: 'user', label: 'User-wise', icon: 'user-tie' }
];
ngOnInit(): void {
this.txns = seedUpiTransactions();
}
// ───────────────────── KPIs ─────────────────────
get totalCount(): number {
return this.txns.length;
}
get totalAmount(): number {
return this.txns.reduce((s, t) => s + t.amount, 0);
}
get successCount(): number {
return this.txns.filter((t) => t.state === 'SUCCESS').length;
}
get pendingCount(): number {
return this.txns.filter((t) => [
'INITIATED',
'PENDING'
].includes(t.state)).length;
}
get failedCount(): number {
return this.txns.filter((t) => [
'FAILED',
'EXPIRED',
'DECLINED'
].includes(t.state)).length;
}
get p2pCount(): number {
return this.txns.filter((t) => t.txnType === 'P2P').length;
}
get p2mCount(): number {
return this.txns.filter((t) => t.txnType === 'P2M').length;
}
get successRate(): number {
if (this.totalCount === 0) return 0;
return (this.successCount / this.totalCount) * 100;
}
get avgTicket(): number {
if (this.totalCount === 0) return 0;
return this.totalAmount / this.totalCount;
}
// ───────────────────── Filtered list ─────────────────────
get filteredTxns(): UpiTxn[] {
const q = this.search.trim().toLowerCase();
return this.txns.filter((t) => {
if (this.typeFilter && t.txnType !== this.typeFilter) return false;
if (this.stateFilter && t.state !== this.stateFilter) return false;
if (!q) return true;
return (
t.rrn.toLowerCase().includes(q) ||
t.upiRef.toLowerCase().includes(q) ||
t.payerVpa.toLowerCase().includes(q) ||
t.payerName.toLowerCase().includes(q) ||
t.payeeVpa.toLowerCase().includes(q) ||
t.payeeName.toLowerCase().includes(q) ||
t.payeeBank.toLowerCase().includes(q)
);
});
}
// ───────────────────── Aggregations ─────────────────────
get bankRows(): BankRow[] {
const map = new Map<string, BankRow>();
for (const t of this.txns) {
const key = t.payeeBank;
if (!map.has(key)) {
map.set(key, { bank: key, count: 0, amount: 0, success: 0, pending: 0, failed: 0 });
}
const r = map.get(key)!;
r.count++;
r.amount += t.amount;
if (t.state === 'SUCCESS') r.success++;
else if (t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED') r.failed++;
else r.pending++;
}
return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
}
get dateRows(): DateRow[] {
const map = new Map<string, DateRow>();
for (const t of this.txns) {
if (!map.has(t.valueDate)) {
map.set(t.valueDate, { date: t.valueDate, count: 0, amount: 0, p2p: 0, p2m: 0 });
}
const r = map.get(t.valueDate)!;
r.count++;
r.amount += t.amount;
if (t.txnType === 'P2P') r.p2p++;
else if (t.txnType === 'P2M') r.p2m++;
}
return Array.from(map.values()).sort((a, b) => (a.date < b.date ? 1 : -1));
}
get userRows(): UserRow[] {
const map = new Map<string, UserRow>();
for (const t of this.txns) {
const key = `${t.payerName}|${t.payerBranch}`;
if (!map.has(key)) {
map.set(key, { user: t.payerName, branch: t.payerBranch, count: 0, amount: 0 });
}
const r = map.get(key)!;
r.count++;
r.amount += t.amount;
}
return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
}
get appRows(): AppRow[] {
const total = this.totalAmount || 1;
const map = new Map<UpiApp, AppRow>();
for (const t of this.txns) {
if (!map.has(t.payerApp)) {
map.set(t.payerApp, { app: t.payerApp, count: 0, amount: 0, share: 0 });
}
const r = map.get(t.payerApp)!;
r.count++;
r.amount += t.amount;
}
map.forEach((r) => (r.share = (r.amount / total) * 100));
return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
}
get trendBuckets(): DateRow[] {
return this.dateRows.slice(0, 7).reverse();
}
get trendMaxAmount(): number {
const buckets = this.trendBuckets;
if (!buckets.length) return 1;
return Math.max(...buckets.map((b) => b.amount));
}
get failureCodeRows(): { code: string; reason: string; count: number; amount: number }[] {
const map = new Map<string, { code: string; reason: string; count: number; amount: number }>();
for (const t of this.txns) {
const failed = t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED';
if (!failed || !t.failureCode) continue;
if (!map.has(t.failureCode)) {
map.set(t.failureCode, {
code: t.failureCode,
reason: t.failureReason || '',
count: 0,
amount: 0
});
}
const r = map.get(t.failureCode)!;
r.count++;
r.amount += t.amount;
}
return Array.from(map.values()).sort((a, b) => b.count - a.count);
}
// ───────────────────── Actions ─────────────────────
showTab(tab: ReportTab): void {
this.activeTab = tab;
}
exportCsv(): void {
const headers = [
'RRN',
'UPI Ref',
'Payer VPA',
'Payer Name',
'Payer App',
'Payee VPA',
'Payee Name',
'Payee Bank',
'Payee Type',
'Txn Type',
'Amount',
'Value Date',
'State',
'Failure Code',
'Sent Timestamp'
];
const rows: string[][] = [headers];
for (const t of this.filteredTxns) {
rows.push([
t.rrn,
t.upiRef,
t.payerVpa,
t.payerName,
t.payerApp,
t.payeeVpa,
t.payeeName,
t.payeeBank,
t.payeeType,
t.txnType,
t.amount.toFixed(2),
t.valueDate,
t.state,
t.failureCode || '',
t.sentTimestamp
]);
}
const escape = (v: string): string => {
const s = (v || '').replace(/"/g, '""');
return /[,"\n]/.test(s) ? `"${s}"` : s;
};
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `UPI_Report_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
clearFilters(): void {
this.search = '';
this.typeFilter = '';
this.stateFilter = '';
}
// ───────────────────── Helpers ─────────────────────
stateColor(state: UpiState): string {
switch (state) {
case 'SUCCESS':
return 'green';
case 'FAILED':
case 'EXPIRED':
case 'DECLINED':
return 'red';
case 'INITIATED':
return 'teal';
case 'PENDING':
return 'amber';
}
}
trendBarHeight(amount: number): number {
return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100));
}
}
// ───────────────────── Shared mock seed (top-level export) ─────────────────────
/**
* Mock UPI history. Exported so any per-sender history dialogs (or future
* dashboards) can render from the same dataset. Replace with an API call.
*/
export function seedUpiTransactions(): UpiTxn[] {
const today = new Date();
const dt = (offset: number, h = 10, m = 0): string => {
const d = new Date(today);
d.setDate(d.getDate() - offset);
const pad = (n: number) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00';
};
const vd = (offset: number): string => dt(offset).slice(0, 10);
return [
{
rrn: 'UPI52608001',
upiRef: 'YBL5202608111',
payerVpa: 'anita.desai@ybl',
payerName: 'Anita Desai',
payerBranch: 'Mumbai — BKC',
payerApp: 'PhonePe',
payeeVpa: 'amazon@apb',
payeeName: 'Amazon Pay India',
payeeBank: 'Axis Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 1899,
valueDate: vd(0),
state: 'SUCCESS',
sentTimestamp: dt(0, 9, 14)
},
{
rrn: 'UPI52608002',
upiRef: 'OKAXIS520260822',
payerVpa: 'anita.desai@ybl',
payerName: 'Anita Desai',
payerBranch: 'Mumbai — BKC',
payerApp: 'PhonePe',
payeeVpa: 'rohan.m@oksbi',
payeeName: 'Rohan S. Mehta',
payeeBank: 'State Bank of India',
payeeType: 'PERSON',
txnType: 'P2P',
amount: 2500,
valueDate: vd(0),
state: 'SUCCESS',
sentTimestamp: dt(0, 10, 38)
},
{
rrn: 'UPI52608003',
upiRef: 'OKHDFC520260833',
payerVpa: 'karthik@okhdfcbank',
payerName: 'Karthik Rao',
payerBranch: 'Bengaluru — MG Road',
payerApp: 'GPay',
payeeVpa: 'swiggy@hdfcbank',
payeeName: 'Swiggy',
payeeBank: 'HDFC Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 645,
valueDate: vd(0),
state: 'SUCCESS',
sentTimestamp: dt(0, 13, 22)
},
{
rrn: 'UPI52608004',
upiRef: 'BHIM5202608044',
payerVpa: 'sushma.i@upi',
payerName: 'Sushma Iyer',
payerBranch: 'Delhi — Connaught',
payerApp: 'BHIM',
payeeVpa: 'uber@okhdfcbank',
payeeName: 'Uber India',
payeeBank: 'HDFC Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 248,
valueDate: vd(1),
state: 'FAILED',
failureCode: 'U28',
failureReason: 'Beneficiary Account is Frozen',
sentTimestamp: dt(1, 18, 5)
},
{
rrn: 'UPI52608005',
upiRef: 'OKICICI520260855',
payerVpa: 'ramesh.k@oklicici',
payerName: 'Ramesh Krishnan',
payerBranch: 'Chennai — T. Nagar',
payerApp: 'Paytm',
payeeVpa: 'padma.r@upi',
payeeName: 'Padma Rao',
payeeBank: 'Canara Bank',
payeeType: 'PERSON',
txnType: 'P2P',
amount: 1200,
valueDate: vd(1),
state: 'SUCCESS',
sentTimestamp: dt(1, 11, 27)
},
{
rrn: 'UPI52608006',
upiRef: 'OKAXIS520260866',
payerVpa: 'vikram.j@axl',
payerName: 'Vikram Joshi',
payerBranch: 'Pune — Koregaon Park',
payerApp: 'GPay',
payeeVpa: 'flipkart@apb',
payeeName: 'Flipkart',
payeeBank: 'Axis Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 4799,
valueDate: vd(2),
state: 'SUCCESS',
sentTimestamp: dt(2, 16, 48)
},
{
rrn: 'UPI52608007',
upiRef: 'BHIM5202608077',
payerVpa: 'lakshmi.n@upi',
payerName: 'Lakshmi Naidu',
payerBranch: 'Hyderabad — Banjara',
payerApp: 'BHIM',
payeeVpa: 'aarav.ent@hdfcbank',
payeeName: 'Aarav Enterprises',
payeeBank: 'Kotak Mahindra Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 11500,
valueDate: vd(2),
state: 'PENDING',
sentTimestamp: dt(2, 9, 11)
},
{
rrn: 'UPI52608008',
upiRef: 'OKWA5202608088',
payerVpa: 'arjun.b@oklicici',
payerName: 'Arjun Banerjee',
payerBranch: 'Kolkata — Park Street',
payerApp: 'WhatsApp',
payeeVpa: 'indrani.sen@axl',
payeeName: 'Indrani Sen',
payeeBank: 'Axis Bank',
payeeType: 'PERSON',
txnType: 'P2P',
amount: 800,
valueDate: vd(3),
state: 'SUCCESS',
sentTimestamp: dt(3, 14, 56)
},
{
rrn: 'UPI52608009',
upiRef: 'OKAMP520260899',
payerVpa: 'anita.desai@apl',
payerName: 'Anita Desai',
payerBranch: 'Mumbai — BKC',
payerApp: 'Amazon Pay',
payeeVpa: 'amazon@apb',
payeeName: 'Amazon Pay India',
payeeBank: 'Axis Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 2349,
valueDate: vd(3),
state: 'DECLINED',
failureCode: 'U30',
failureReason: 'Daily Limit Exceeded',
sentTimestamp: dt(3, 21, 32)
},
{
rrn: 'UPI52608010',
upiRef: 'OKPAYTM5202608010',
payerVpa: 'karthik@paytm',
payerName: 'Karthik Rao',
payerBranch: 'Bengaluru — MG Road',
payerApp: 'Paytm',
payeeVpa: 'zomato@paytm',
payeeName: 'Zomato',
payeeBank: 'Paytm Payments Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 420,
valueDate: vd(4),
state: 'SUCCESS',
sentTimestamp: dt(4, 20, 5)
},
{
rrn: 'UPI52608011',
upiRef: 'OKICICI520260811',
payerVpa: 'sushma.i@upi',
payerName: 'Sushma Iyer',
payerBranch: 'Delhi — Connaught',
payerApp: 'BHIM',
payeeVpa: 'madhavi.s@upi',
payeeName: 'Madhavi Singh',
payeeBank: 'Punjab National Bank',
payeeType: 'PERSON',
txnType: 'P2P',
amount: 6500,
valueDate: vd(4),
state: 'SUCCESS',
sentTimestamp: dt(4, 12, 14)
},
{
rrn: 'UPI52608012',
upiRef: 'OKHDFC520260812',
payerVpa: 'ramesh.k@okhdfcbank',
payerName: 'Ramesh Krishnan',
payerBranch: 'Chennai — T. Nagar',
payerApp: 'GPay',
payeeVpa: 'bbasket@axl',
payeeName: 'Big Basket',
payeeBank: 'Axis Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 1380,
valueDate: vd(5),
state: 'SUCCESS',
sentTimestamp: dt(5, 8, 49)
},
{
rrn: 'UPI52608013',
upiRef: 'OKAXIS520260813',
payerVpa: 'lakshmi.n@upi',
payerName: 'Lakshmi Naidu',
payerBranch: 'Hyderabad — Banjara',
payerApp: 'PhonePe',
payeeVpa: 'spectra.cc@axl',
payeeName: 'Spectra Components',
payeeBank: 'State Bank of India',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 28999,
valueDate: vd(5),
state: 'EXPIRED',
failureCode: 'U23',
failureReason: 'Collect Request Expired',
sentTimestamp: dt(5, 15, 18)
},
{
rrn: 'UPI52608014',
upiRef: 'OKICICI520260814',
payerVpa: 'arjun.b@oklicici',
payerName: 'Arjun Banerjee',
payerBranch: 'Kolkata — Park Street',
payerApp: 'GPay',
payeeVpa: 'aircel@okhdfcbank',
payeeName: 'Airtel Recharge',
payeeBank: 'HDFC Bank',
payeeType: 'MERCHANT',
txnType: 'P2M',
amount: 349,
valueDate: vd(6),
state: 'SUCCESS',
sentTimestamp: dt(6, 10, 2)
},
{
rrn: 'UPI52608015',
upiRef: 'OKAXIS520260815',
payerVpa: 'vikram.j@axl',
payerName: 'Vikram Joshi',
payerBranch: 'Pune — Koregaon Park',
payerApp: 'WhatsApp',
payeeVpa: 'neha.k@upi',
payeeName: 'Neha Kapoor',
payeeBank: 'HDFC Bank',
payeeType: 'PERSON',
txnType: 'P2P',
amount: 1750,
valueDate: vd(6),
state: 'SUCCESS',
sentTimestamp: dt(6, 17, 41)
}
];
}