feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** 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 { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component';
|
||||
import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data';
|
||||
|
||||
/**
|
||||
* Customers whose transactions keep tripping the AML engine, queued for an employee to review why.
|
||||
*
|
||||
* A single alert is a transaction question and is handled on the Flagged Transactions tab. This tab
|
||||
* asks a different question — is this *customer's* pattern of behaviour acceptable — which no single
|
||||
* alert can answer. Reviews are recorded against the alert count seen, so a customer leaves the
|
||||
* queue once reviewed and only returns when the engine flags them again.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-recurring-alerts',
|
||||
templateUrl: './aml-recurring-alerts.component.html',
|
||||
styleUrls: ['./aml-recurring-alerts.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlRecurringAlertsComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
rows = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'customer',
|
||||
'alertCount',
|
||||
'severity',
|
||||
'rules',
|
||||
'totalAmount',
|
||||
'lastAlert',
|
||||
'reviewState',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD, which already treats 3 cumulative
|
||||
* AML events as the point a customer's risk rating is reconsidered.
|
||||
*/
|
||||
minAlerts = 3;
|
||||
windowDays = 90;
|
||||
onlyNeedingReview = false;
|
||||
|
||||
readonly thresholdOptions = [
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
10
|
||||
];
|
||||
readonly windowOptions = [
|
||||
{ value: 30, label: 'Last 30 days' },
|
||||
{ value: 90, label: 'Last 90 days' },
|
||||
{ value: 180, label: 'Last 6 months' },
|
||||
{ value: 365, label: 'Last 12 months' }
|
||||
];
|
||||
|
||||
loading = false;
|
||||
loadFailed = false;
|
||||
/** Set when the rows on screen are the sample set rather than engine output. */
|
||||
usingDemoData = false;
|
||||
/** Which customer's alert detail is expanded inline. */
|
||||
expandedClientId: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadFailed = false;
|
||||
this.expandedClientId = null;
|
||||
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
|
||||
next: (data) => {
|
||||
this.loading = false;
|
||||
const rows = data || [];
|
||||
this.usingDemoData = rows.length === 0;
|
||||
this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.loadFailed = true;
|
||||
this.usingDemoData = true;
|
||||
this.rows.data = this.mapRows(this.demoRows());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Parses the date fields the template renders. Shared by engine rows and the sample set. */
|
||||
private mapRows(rows: any[]): any[] {
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
firstAlertOnDate: this.toDate(r.firstAlertOn),
|
||||
lastAlertOnDate: this.toDate(r.lastAlertOn),
|
||||
lastReviewedOnDate: this.toDate(r.lastReviewedOn),
|
||||
alerts: (r.alerts || []).map((a: any) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample set narrowed by the on-screen controls, so the threshold and window selectors behave
|
||||
* the way they would against the engine instead of returning the same three rows regardless.
|
||||
*/
|
||||
private demoRows(): any[] {
|
||||
const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000;
|
||||
return amlRecurringAlertsDemoData()
|
||||
.map((row: any) => {
|
||||
const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff);
|
||||
const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode)));
|
||||
return {
|
||||
...row,
|
||||
alerts,
|
||||
alertCount: alerts.length,
|
||||
openCount: alerts.filter((a: any) => a.status === 'OPEN').length,
|
||||
escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length,
|
||||
// Severity sits on the alert server-side; the sample rows carry a row total, so it is
|
||||
// capped at what survived the window rather than recomputed.
|
||||
highRiskCount: Math.min(row.highRiskCount, alerts.length),
|
||||
distinctRules: ruleCodes.length,
|
||||
ruleCodes,
|
||||
totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0),
|
||||
firstAlertOn: alerts.length ? alerts[0].raisedOn : null,
|
||||
lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null
|
||||
};
|
||||
})
|
||||
.filter((row: any) => row.alertCount >= this.minAlerts)
|
||||
.filter((row: any) => !this.onlyNeedingReview || row.needsReview);
|
||||
}
|
||||
|
||||
toggleExpanded(row: any): void {
|
||||
this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId;
|
||||
}
|
||||
|
||||
isExpanded(row: any): boolean {
|
||||
return this.expandedClientId === row.clientId;
|
||||
}
|
||||
|
||||
get needingReviewCount(): number {
|
||||
return this.rows.data.filter((r) => r.needsReview).length;
|
||||
}
|
||||
|
||||
review(row: any): void {
|
||||
const dialogRef = this.dialog.open(AmlRecurringReviewDialogComponent, {
|
||||
width: '780px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { row, windowDays: this.windowDays }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((outcome: string | undefined) => {
|
||||
if (!outcome) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: 'Review Recorded',
|
||||
message: `${row.clientDisplayName} reviewed as ${outcome}.`
|
||||
});
|
||||
// Re-read: the review changes needsReview, and the count recorded server-side is what the
|
||||
// row must now reflect.
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
amount(value: any): string {
|
||||
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
riskLabel(category: any): string {
|
||||
switch (Number(category)) {
|
||||
case 1:
|
||||
return 'Low';
|
||||
case 2:
|
||||
return 'Medium';
|
||||
case 3:
|
||||
return 'High';
|
||||
default:
|
||||
return 'Unrated';
|
||||
}
|
||||
}
|
||||
|
||||
riskClass(category: any): string {
|
||||
switch (Number(category)) {
|
||||
case 1:
|
||||
return 'ra-risk ra-risk-low';
|
||||
case 2:
|
||||
return 'ra-risk ra-risk-medium';
|
||||
case 3:
|
||||
return 'ra-risk ra-risk-high';
|
||||
default:
|
||||
return 'ra-risk ra-risk-none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fineract's Gson adapter emits LocalDateTime as [y, M, d, H, m, s] rather than an ISO string, so
|
||||
* the date pipe renders nothing unless it is converted first. Month is 1-based there, 0-based in Date.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user