feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -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;
}
}