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,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();
});
}
}