feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
+333
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, timer, of } from 'rxjs';
|
||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
import { AlertService } from '../../../core/alert/alert.service';
|
||||
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
||||
|
||||
/**
|
||||
* Live feed of transactions the AML rule engine has flagged.
|
||||
*
|
||||
* The engine writes an m_aml_alert row synchronously as each savings/loan/transfer transaction
|
||||
* posts (AmlDomainServiceImpl -> AmlRuleEvaluationService), but there is no push channel out of
|
||||
* the platform — no websocket or SSE endpoint exists. So "real time" here is a short poll of
|
||||
* GET /aml/alerts. Alerts therefore surface within one poll interval of the transaction, not on
|
||||
* the same instant it posts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-flagged-transactions',
|
||||
templateUrl: './aml-flagged-transactions.component.html',
|
||||
styleUrls: ['./aml-flagged-transactions.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
|
||||
private static readonly POLL_INTERVAL_MS = 15000;
|
||||
|
||||
alerts = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'raisedOn',
|
||||
'transaction',
|
||||
'client',
|
||||
'rule',
|
||||
'amount',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/** Every alert as returned, before the status filter narrows the table. */
|
||||
private allAlerts: any[] = [];
|
||||
|
||||
readonly statusFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All alerts' },
|
||||
{ key: 'RAISED', label: 'Raised' },
|
||||
{ key: 'UNDER_REVIEW', label: 'Under review' },
|
||||
{ key: 'ESCALATED', label: 'Escalated' },
|
||||
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
||||
];
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
|
||||
* of the rule that fired, resolved server-side from the originating catalog entry — the same
|
||||
* field the Rules Master tab shows in its Risk Category column.
|
||||
*/
|
||||
riskSummary: { label: string; count: number; cls: string }[] = [];
|
||||
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||
unclassifiedCount = 0;
|
||||
|
||||
live = true;
|
||||
loading = false;
|
||||
/** Null until the first successful poll, so the header does not claim a stale time. */
|
||||
lastUpdated: Date | null = null;
|
||||
loadFailed = false;
|
||||
|
||||
/** Ids already on screen, so a later poll can tell genuinely new alerts from repeats. */
|
||||
private seenIds = new Set<number>();
|
||||
/** Alerts that arrived after the officer started watching — highlighted until acknowledged. */
|
||||
newAlertIds = new Set<number>();
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
// Skip while paused, and while the browser tab is in the background — a hidden tab
|
||||
// polling every 15s is pure waste, and the next visible tick catches up anyway.
|
||||
filter(() => this.live && !document.hidden),
|
||||
// Always fetch unfiltered: the status chips need counts for every bucket, and filtering
|
||||
// client-side keeps a chip switch instant instead of a round trip.
|
||||
switchMap(() => this.amlService.getAlerts().pipe(catchError(() => of(null)))),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((data) => {
|
||||
if (data) {
|
||||
this.applyAlerts(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/** Manual refresh, and the initial load. */
|
||||
fetch(): void {
|
||||
this.loading = true;
|
||||
this.amlService
|
||||
.getAlerts()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.loading = false;
|
||||
this.applyAlerts(data || []);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.loadFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleLive(): void {
|
||||
this.live = !this.live;
|
||||
if (this.live) {
|
||||
// Resuming should show current state immediately rather than after a full interval.
|
||||
this.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyStatusFilter();
|
||||
}
|
||||
|
||||
statusCount(key: string): number {
|
||||
return this.statusCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
get newAlertCount(): number {
|
||||
return this.newAlertIds.size;
|
||||
}
|
||||
|
||||
/** Clears the NEW highlight once the officer has looked at them. */
|
||||
acknowledgeNew(): void {
|
||||
this.newAlertIds.clear();
|
||||
}
|
||||
|
||||
isNew(alert: any): boolean {
|
||||
return this.newAlertIds.has(alert.id);
|
||||
}
|
||||
|
||||
private applyAlerts(data: any[]): void {
|
||||
this.loadFailed = false;
|
||||
// A live feed reads newest-first. The API returns ascending when a status filter is applied
|
||||
// and unordered otherwise, so ordering here is what makes the newest alert visible on page 1.
|
||||
// Sorting needs the parsed date: raisedOn itself is an array, and new Date([...]) is NaN,
|
||||
// which would silently make the comparator a no-op.
|
||||
const list = (data || [])
|
||||
.map((a) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
|
||||
.sort((a, b) => (b.raisedOnDate?.getTime() ?? 0) - (a.raisedOnDate?.getTime() ?? 0));
|
||||
|
||||
const firstLoad = this.lastUpdated === null;
|
||||
if (!firstLoad) {
|
||||
// Accumulate rather than replace: two quiet polls in a row must not clear a highlight the
|
||||
// officer has not looked at yet.
|
||||
for (const alert of list) {
|
||||
if (!this.seenIds.has(alert.id)) {
|
||||
this.newAlertIds.add(alert.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.seenIds = new Set(list.map((a) => a.id));
|
||||
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
|
||||
* disagreed with the table underneath it would be worse than no summary.
|
||||
*/
|
||||
private recomputeRiskSummary(): void {
|
||||
const pool = this.alerts.data;
|
||||
const tiers = [
|
||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||
];
|
||||
this.riskSummary = tiers.map((t) => ({
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
}));
|
||||
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter(
|
||||
(a) => ![
|
||||
1,
|
||||
2,
|
||||
3
|
||||
].includes(Number(a.riskCategory))
|
||||
).length;
|
||||
}
|
||||
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
this.statusCounts = { '': this.allAlerts.length };
|
||||
for (const s of this.statusFilters) {
|
||||
if (s.key) {
|
||||
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return 'alert-status status-' + (status || 'raised').toLowerCase();
|
||||
}
|
||||
|
||||
txnTypeClass(type: string): string {
|
||||
return 'txn-type type-' + (type || 'unknown').toLowerCase();
|
||||
}
|
||||
|
||||
amount(alert: any): string {
|
||||
const value = Number(alert.amount) || 0;
|
||||
return '₹' + value.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fineract's Gson LocalDateTimeAdapter emits [year, month, day, hour, minute, second] rather
|
||||
* than an ISO string, so binding raisedOn straight into the date pipe renders nothing. Month is
|
||||
* 1-based in that array and 0-based in Date. The string branch is a fallback in case the
|
||||
* serializer is ever configured to emit ISO.
|
||||
*/
|
||||
private toDate(value: any): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
M,
|
||||
d,
|
||||
H = 0,
|
||||
m = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
if (y == null || M == null || d == null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(y, M - 1, d, H, m, s);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an open alert can be disposed of. The backend's whitelist/escalate set state
|
||||
* unconditionally, so nothing there stops an already-escalated alert being escalated again and
|
||||
* opening a second STR case — this gate is what prevents it.
|
||||
*/
|
||||
canReview(alert: any): boolean {
|
||||
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
|
||||
}
|
||||
|
||||
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const dialogRef = this.dialog.open(AmlReviewAlertDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: 'WHITELIST' | 'ESCALATE' | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: result === 'WHITELIST' ? 'Alert Whitelisted' : 'Alert Escalated',
|
||||
message:
|
||||
result === 'WHITELIST'
|
||||
? `${alert.transactionType} #${alert.transactionId} cleared with no report.`
|
||||
: `${alert.transactionType} #${alert.transactionId} escalated — an STR case is now open.`
|
||||
});
|
||||
// The status changed server side, and escalation also re-rates the client, so re-read
|
||||
// rather than patching the row locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user