226 lines
6.7 KiB
TypeScript
226 lines
6.7 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
/**
|
|
* 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;
|
|
/** 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;
|
|
this.rows.data = this.mapRows(data || []);
|
|
},
|
|
error: () => {
|
|
this.loading = false;
|
|
this.loadFailed = true;
|
|
// An AML review queue shows what the engine found or nothing at all. Standing in sample
|
|
// customers when the call fails invents suspicious activity against names that are not
|
|
// the bank's, which is worse than an empty table saying the load failed.
|
|
this.rows.data = [];
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Parses the date fields the template renders. */
|
|
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) }))
|
|
}));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|