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,217 @@
<!--
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/.
-->
<div class="ra-intro">
<mat-icon class="ra-intro-icon">policy</mat-icon>
<span>
Customers the engine has flagged more than once. One alert is a question about a transaction — a pattern is a
question about the customer, which is what gets reviewed and recorded here.
</span>
</div>
<!-- What counts as "regularly" is a policy call, so it is set on screen rather than baked in -->
<div class="ra-controls">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
<mat-label>Flag after</mat-label>
<mat-select [(ngModel)]="minAlerts" (ngModelChange)="load()">
<mat-option *ngFor="let t of thresholdOptions" [value]="t">{{ t }}+ alerts</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
<mat-label>Within</mat-label>
<mat-select [(ngModel)]="windowDays" (ngModelChange)="load()">
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox [(ngModel)]="onlyNeedingReview" (ngModelChange)="load()" class="ra-check">
Only awaiting review
</mat-checkbox>
<span class="ra-spacer"></span>
<span *ngIf="needingReviewCount > 0" class="ra-pending-pill">
<mat-icon>pending_actions</mat-icon>
{{ needingReviewCount }} awaiting review
</span>
<mat-spinner *ngIf="loading" diameter="18"></mat-spinner>
<button mat-icon-button type="button" matTooltip="Refresh" [disabled]="loading" (click)="load()">
<mat-icon>refresh</mat-icon>
</button>
</div>
<div *ngIf="loadFailed" class="ra-error">
<mat-icon>cloud_off</mat-icon>
<span>Could not load the review queue.</span>
</div>
<!-- Named on screen, because sample rows must never be mistaken for engine output on an AML queue -->
<div *ngIf="usingDemoData && rows.data.length > 0" class="ra-demo">
<mat-icon>science</mat-icon>
<span>
Showing <strong>sample data</strong> — the AML engine returned no recurring alerts for this threshold and window.
These rows are for demonstration only and are not reportable.
</span>
</div>
<table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows>
<ng-container matColumnDef="customer">
<th mat-header-cell *matHeaderCellDef>Customer</th>
<td mat-cell *matCellDef="let row">
<div class="ra-stacked">
<span class="ra-name">{{ row.clientDisplayName }}</span>
<span class="ra-sub">
ID {{ row.clientId }}<span *ngIf="row.accountNo"> · {{ row.accountNo }}</span> · KYC risk
<span [ngClass]="riskClass(row.clientRiskCategory)">{{ riskLabel(row.clientRiskCategory) }}</span>
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="alertCount">
<th mat-header-cell *matHeaderCellDef class="col-center">Alerts</th>
<td mat-cell *matCellDef="let row" class="col-center">
<span class="ra-count">{{ row.alertCount }}</span>
</td>
</ng-container>
<ng-container matColumnDef="severity">
<th mat-header-cell *matHeaderCellDef class="col-center">Severity</th>
<td mat-cell *matCellDef="let row" class="col-center">
<div class="ra-stacked ra-stacked-center">
<span class="ra-high" [class.ra-high-zero]="row.highRiskCount === 0">{{ row.highRiskCount }} High</span>
<span class="ra-sub">{{ row.openCount }} open · {{ row.escalatedCount }} escalated</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="rules">
<th mat-header-cell *matHeaderCellDef>Rules Tripped</th>
<td mat-cell *matCellDef="let row" class="ra-rules-cell">
<div class="ra-stacked">
<span class="ra-name">{{ row.distinctRules }} distinct</span>
<span class="ra-sub" [matTooltip]="row.ruleCodes?.join(', ')">{{ row.ruleCodes?.join(', ') }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="totalAmount">
<th mat-header-cell *matHeaderCellDef class="col-right">Total Value</th>
<td mat-cell *matCellDef="let row" class="col-right ra-amount">{{ amount(row.totalAmount) }}</td>
</ng-container>
<ng-container matColumnDef="lastAlert">
<th mat-header-cell *matHeaderCellDef>Activity</th>
<td mat-cell *matCellDef="let row">
<div class="ra-stacked">
<span>{{ row.lastAlertOnDate ? (row.lastAlertOnDate | date: 'dd MMM yyyy') : '—' }}</span>
<span class="ra-sub">
first {{ row.firstAlertOnDate ? (row.firstAlertOnDate | date: 'dd MMM yyyy') : '—' }}
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="reviewState">
<th mat-header-cell *matHeaderCellDef class="col-center">Review</th>
<td mat-cell *matCellDef="let row" class="col-center">
<span *ngIf="row.needsReview && !row.lastReviewedOn" class="ra-state ra-state-new">Never reviewed</span>
<span
*ngIf="row.needsReview && row.lastReviewedOn"
class="ra-state ra-state-again"
[matTooltip]="
'Reviewed at ' + row.alertCountAtLastReview + ' alert(s); ' + row.alertCount + ' now — new activity since.'
"
>
New activity
</span>
<div *ngIf="!row.needsReview" class="ra-stacked ra-stacked-center">
<span class="ra-state ra-state-done">{{ row.lastReviewOutcome }}</span>
<span class="ra-sub" [matTooltip]="row.lastReviewNotes">
{{ row.lastReviewedByUsername }} · {{ row.lastReviewedOnDate | date: 'dd MMM' }}
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let row" class="col-center ra-actions">
<button
type="button"
class="ra-btn ra-btn-detail"
[matTooltip]="isExpanded(row) ? 'Hide transactions' : 'Show the triggering transactions'"
(click)="toggleExpanded(row)"
>
<mat-icon>{{ isExpanded(row) ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ row.alertCount }} txns
</button>
<button
type="button"
class="ra-btn ra-btn-review"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
matTooltip="Record why this customer keeps surfacing"
(click)="review(row)"
>
<mat-icon>fact_check</mat-icon>
Review
</button>
</td>
</ng-container>
<!-- Inline detail: the transactions behind the count, so the reviewer never has to guess -->
<ng-container matColumnDef="detail">
<td mat-cell *matCellDef="let row" [attr.colspan]="displayedColumns.length" class="ra-detail-cell">
<div class="ra-detail" *ngIf="isExpanded(row)">
<table class="ra-detail-tbl">
<thead>
<tr>
<th>Raised On</th>
<th>Transaction</th>
<th>Rule</th>
<th class="col-right">Amount</th>
<th>Status</th>
<th>Review Notes</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let a of row.alerts">
<td>{{ a.raisedOnDate ? (a.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—' }}</td>
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
<td>
{{ a.ruleCode }}
<span class="ra-sub">{{ a.ruleDescription }}</span>
</td>
<td class="col-right ra-amount">{{ amount(a.amount) }}</td>
<td>{{ a.status }}</td>
<td class="ra-sub">{{ a.reviewNotes || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="ra-row"
[class.ra-row-pending]="row.needsReview"
></tr>
<tr mat-row *matRowDef="let row; columns: ['detail']" class="ra-detail-row"></tr>
</table>
<div *ngIf="rows.data.length === 0 && !loading" class="ra-empty">
<mat-icon>verified_user</mat-icon>
<span>
No customer has reached {{ minAlerts }} alerts in the selected window. Lower the threshold or widen the window to
see borderline cases.
</span>
</div>
@@ -0,0 +1,358 @@
/**
* 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/.
*/
.ra-intro {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 14px;
margin-bottom: 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 12.5px;
line-height: 1.5;
}
.ra-intro-icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
}
.ra-controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding-bottom: 14px;
}
.ra-control {
width: 170px;
}
.ra-check {
font-size: 13px;
}
.ra-spacer {
flex: 1 1 auto;
}
.ra-pending-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid #fdba74;
border-radius: 999px;
background: #fff7ed;
color: #c2410c;
font-size: 12.5px;
font-weight: 700;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ra-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.ra-demo {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
color: #92400e;
font-size: 12.5px;
line-height: 1.5;
mat-icon {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.ra-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
th {
background-color: #f1f5f9;
color: #475569;
font-weight: 600;
font-size: 12.5px;
padding: 14px 16px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
td {
padding: 12px 16px;
font-size: 13px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.ra-row:hover {
background: #f8fafc;
}
/* Anything still owed a decision is tinted, so the queue reads at a glance. */
.ra-row-pending {
background: #fffbeb;
&:hover {
background: #fef3c7;
}
}
.col-center {
text-align: center;
}
.col-right {
text-align: right;
}
.ra-stacked {
display: flex;
flex-direction: column;
gap: 2px;
}
.ra-stacked-center {
align-items: center;
}
.ra-name {
font-weight: 600;
}
.ra-sub {
color: #64748b;
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
}
.ra-rules-cell {
max-width: 260px;
.ra-sub {
display: block;
white-space: nowrap;
}
}
.ra-count {
display: inline-block;
min-width: 30px;
padding: 3px 9px;
border-radius: 999px;
background: #1a237e;
color: #fff;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ra-high {
font-weight: 700;
color: #b91c1c;
font-size: 12.5px;
&.ra-high-zero {
color: #64748b;
font-weight: 500;
}
}
.ra-amount {
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ra-risk {
font-weight: 700;
&.ra-risk-low {
color: #15803d;
}
&.ra-risk-medium {
color: #b45309;
}
&.ra-risk-high {
color: #b91c1c;
}
&.ra-risk-none {
color: #94a3b8;
}
}
.ra-state {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
&.ra-state-new {
background: #fee2e2;
color: #b91c1c;
}
&.ra-state-again {
background: #fef3c7;
color: #92400e;
}
&.ra-state-done {
background: #dcfce7;
color: #15803d;
}
}
.ra-actions {
white-space: nowrap;
}
.ra-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
margin: 2px;
border-radius: 6px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
.ra-btn-detail {
border-color: #cbd5e1;
background: #fff;
color: #334155;
&:hover {
border-color: #1a237e;
color: #1a237e;
}
}
.ra-btn-review {
border-color: #c7d2fe;
background: #eef2ff;
color: #3730a3;
&:hover {
background: #e0e7ff;
border-color: #a5b4fc;
}
}
/* Detail row is always rendered by multiTemplateDataRows; the padding collapses when empty so a
collapsed row leaves no gap. */
.ra-detail-cell {
padding: 0 !important;
border-bottom: 0 !important;
}
.ra-detail {
padding: 12px 16px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.ra-detail-tbl {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
th {
background: #f1f5f9;
color: #475569;
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
text-align: left;
}
td {
padding: 8px 10px;
border-top: 1px solid #f1f5f9;
color: #1e293b;
vertical-align: top;
}
}
.ra-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #64748b;
text-align: center;
max-width: 520px;
margin: 0 auto;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
@@ -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;
}
}
@@ -0,0 +1,248 @@
/**
* 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/.
*/
/**
* Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing
* for the selected window so the screen can be demonstrated and reviewed.
*
* Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested —
* so the rows travel through the component's normal mapping rather than a second code path.
*
* Dates are generated relative to today rather than hard-coded, otherwise every row would fall
* outside the "last 90 days" window as soon as the file aged.
*/
/** ISO timestamp `days` before now, at the given hour and minute. */
function daysAgo(days: number, hour = 11, minute = 20): string {
const d = new Date();
d.setDate(d.getDate() - days);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
}
/**
* Three customers covering the three review states the table renders: never reviewed, reviewed but
* flagged again since, and reviewed with an outcome recorded.
*/
export function amlRecurringAlertsDemoData(): any[] {
return [
// Never reviewed — the row the queue exists to surface.
{
clientId: 10078,
clientDisplayName: 'Ravi Kumar Sharma',
accountNo: '000000010078',
clientRiskCategory: 3,
alertCount: 5,
highRiskCount: 3,
openCount: 4,
escalatedCount: 1,
distinctRules: 3,
ruleCodes: [
'CASH_TXN_10L',
'STRUCTURING',
'RAPID_MOVEMENT'
],
totalAmount: 4785000,
firstAlertOn: daysAgo(62, 10, 5),
lastAlertOn: daysAgo(3, 15, 42),
needsReview: true,
lastReviewedOn: null,
alertCountAtLastReview: null,
lastReviewOutcome: null,
lastReviewedByUsername: null,
lastReviewNotes: null,
alerts: [
{
alertId: 90121,
raisedOn: daysAgo(62, 10, 5),
transactionType: 'Savings Deposit',
transactionId: 552301,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1200000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90144,
raisedOn: daysAgo(48, 12, 18),
transactionType: 'Savings Deposit',
transactionId: 553980,
ruleCode: 'STRUCTURING',
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
amount: 890000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90190,
raisedOn: daysAgo(29, 9, 55),
transactionType: 'Savings Withdrawal',
transactionId: 556114,
ruleCode: 'RAPID_MOVEMENT',
ruleDescription: 'Funds withdrawn within 24 hours of a large credit',
amount: 1150000,
status: 'ESCALATED',
reviewNotes: 'Escalated to Principal Officer — source of funds not established.'
},
{
alertId: 90233,
raisedOn: daysAgo(14, 16, 30),
transactionType: 'Savings Deposit',
transactionId: 558702,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1045000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90275,
raisedOn: daysAgo(3, 15, 42),
transactionType: 'Savings Withdrawal',
transactionId: 560488,
ruleCode: 'STRUCTURING',
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
amount: 500000,
status: 'OPEN',
reviewNotes: null
}
]
},
// Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity".
{
clientId: 10079,
clientDisplayName: 'Priya Patel',
accountNo: '000000010079',
clientRiskCategory: 2,
alertCount: 4,
highRiskCount: 1,
openCount: 2,
escalatedCount: 0,
distinctRules: 2,
ruleCodes: [
'HIGH_VALUE_TRANSFER',
'ROUND_TRIPPING'
],
totalAmount: 2130000,
firstAlertOn: daysAgo(71, 14, 10),
lastAlertOn: daysAgo(6, 11, 5),
needsReview: true,
lastReviewedOn: daysAgo(34, 17, 0),
alertCountAtLastReview: 2,
lastReviewOutcome: 'MONITOR',
lastReviewedByUsername: 'anjali.mehta',
lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.',
alerts: [
{
alertId: 90102,
raisedOn: daysAgo(71, 14, 10),
transactionType: 'Account Transfer',
transactionId: 551002,
ruleCode: 'HIGH_VALUE_TRANSFER',
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
amount: 620000,
status: 'WHITELISTED',
reviewNotes: 'Supplier payment, invoice verified.'
},
{
alertId: 90158,
raisedOn: daysAgo(41, 10, 45),
transactionType: 'Account Transfer',
transactionId: 554330,
ruleCode: 'ROUND_TRIPPING',
ruleDescription: 'Funds returned to the originating account within seven days',
amount: 450000,
status: 'WHITELISTED',
reviewNotes: 'Advance refunded after order cancellation.'
},
{
alertId: 90241,
raisedOn: daysAgo(19, 13, 25),
transactionType: 'Account Transfer',
transactionId: 559011,
ruleCode: 'HIGH_VALUE_TRANSFER',
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
amount: 540000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90268,
raisedOn: daysAgo(6, 11, 5),
transactionType: 'Account Transfer',
transactionId: 560190,
ruleCode: 'ROUND_TRIPPING',
ruleDescription: 'Funds returned to the originating account within seven days',
amount: 520000,
status: 'OPEN',
reviewNotes: null
}
]
},
// Reviewed with an outcome and no activity since — shows the settled state.
{
clientId: 10080,
clientDisplayName: 'Arjun Deshmukh',
accountNo: '000000010080',
clientRiskCategory: 1,
alertCount: 3,
highRiskCount: 0,
openCount: 0,
escalatedCount: 0,
distinctRules: 1,
ruleCodes: ['CASH_TXN_10L'],
totalAmount: 3420000,
firstAlertOn: daysAgo(80, 9, 30),
lastAlertOn: daysAgo(23, 12, 15),
needsReview: false,
lastReviewedOn: daysAgo(9, 15, 20),
alertCountAtLastReview: 3,
lastReviewOutcome: 'CLEARED',
lastReviewedByUsername: 'vikram.singh',
lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.',
alerts: [
{
alertId: 90088,
raisedOn: daysAgo(80, 9, 30),
transactionType: 'Savings Deposit',
transactionId: 550210,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1180000,
status: 'WHITELISTED',
reviewNotes: 'Mill settlement for the 2025-26 crushing season.'
},
{
alertId: 90165,
raisedOn: daysAgo(52, 11, 0),
transactionType: 'Savings Deposit',
transactionId: 554901,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1090000,
status: 'WHITELISTED',
reviewNotes: 'Second instalment, statement on record.'
},
{
alertId: 90212,
raisedOn: daysAgo(23, 12, 15),
transactionType: 'Savings Deposit',
transactionId: 557640,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1150000,
status: 'WHITELISTED',
reviewNotes: 'Final instalment for the season.'
}
]
}
];
}
@@ -0,0 +1,127 @@
<!--
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/.
-->
<h2 mat-dialog-title class="rr-dialog-title">
<mat-icon>fact_check</mat-icon>
Review Repeat Activity — {{ data.row.clientDisplayName }}
</h2>
<mat-dialog-content class="rr-dialog-content">
<div class="rr-summary">
<div class="rr-summary-row">
<span class="rr-label">Customer</span>
<span class="rr-value">
{{ data.row.clientDisplayName }}
<span class="rr-muted"
>(ID {{ data.row.clientId }}{{ data.row.accountNo ? ' · ' + data.row.accountNo : '' }})</span
>
</span>
</div>
<div class="rr-summary-row">
<span class="rr-label">Alerts</span>
<span class="rr-value">
<strong>{{ data.row.alertCount }}</strong> in the last {{ data.windowDays }} days ·
{{ data.row.highRiskCount }} High risk · {{ data.row.distinctRules }} distinct rule(s)
</span>
</div>
<div class="rr-summary-row">
<span class="rr-label">Value</span>
<span class="rr-value rr-mono">{{ amount(data.row.totalAmount) }}</span>
</div>
<div class="rr-summary-row">
<span class="rr-label">Rules</span>
<span class="rr-value">{{ data.row.ruleCodes?.join(', ') || '—' }}</span>
</div>
</div>
<!-- The triggering transactions, so the decision is made against the evidence -->
<div class="rr-alerts">
<span class="rr-alerts-legend">Triggering transactions</span>
<div class="rr-alerts-scroll">
<table class="rr-alerts-tbl">
<thead>
<tr>
<th>Raised On</th>
<th>Transaction</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let a of data.row.alerts">
<td>{{ a.raisedOnDate | date: 'dd MMM yyyy, HH:mm' }}</td>
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
<td>{{ a.ruleCode }}</td>
<td class="ta-r rr-mono">{{ amount(a.amount) }}</td>
<td>{{ a.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div *ngIf="data.row.lastReviewedOn" class="rr-prior">
<mat-icon>history</mat-icon>
<span>
Last reviewed by <strong>{{ data.row.lastReviewedByUsername }}</strong> on
{{ data.row.lastReviewedOnDate | date: 'dd MMM yyyy' }} at {{ data.row.alertCountAtLastReview }} alert(s) —
{{ data.row.lastReviewOutcome }}. {{ data.row.lastReviewNotes }}
</span>
</div>
<div *ngIf="errorMessage" class="rr-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
<form [formGroup]="reviewForm" autocomplete="off">
<div class="rr-outcome">
<span class="rr-outcome-legend">Conclusion</span>
<mat-radio-group formControlName="outcome" class="rr-outcome-options">
<mat-radio-button *ngFor="let o of outcomes" [value]="o.value" class="rr-outcome-option">
{{ o.label }}
<span class="rr-muted">— {{ o.hint }}</span>
</mat-radio-button>
</mat-radio-group>
</div>
<mat-form-field appearance="outline" class="rr-notes-field">
<mat-label>Why does this customer keep surfacing?</mat-label>
<textarea
matInput
rows="4"
formControlName="notes"
placeholder="What the pattern is, what was checked, and what was decided"
></textarea>
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
Required — this is the audit record for the decision
</mat-error>
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">Keep this under 1000 characters</mat-error>
</mat-form-field>
<p class="rr-note">
Recorded against the current count of {{ data.row.alertCount }} alert(s). The customer returns to this queue if
the engine flags them again.
</p>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end" class="rr-dialog-actions">
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="reviewForm.invalid || submitting"
(click)="submit()"
>
<mat-spinner *ngIf="submitting" diameter="18" class="rr-spinner"></mat-spinner>
{{ submitting ? 'Saving…' : 'Record Review' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,191 @@
/**
* 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/.
*/
.rr-dialog-title {
display: flex;
align-items: center;
gap: 10px;
color: #1a237e;
font-weight: 600;
}
.rr-dialog-content {
padding-top: 8px;
min-width: 620px;
max-width: 780px;
}
.rr-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 14px 16px;
margin-bottom: 16px;
}
.rr-summary-row {
display: flex;
gap: 12px;
padding: 4px 0;
font-size: 13px;
align-items: baseline;
}
.rr-label {
flex: 0 0 84px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.rr-value {
flex: 1 1 auto;
color: #1e293b;
}
.rr-muted {
color: #64748b;
font-weight: 400;
}
.rr-mono {
font-variant-numeric: tabular-nums;
}
.rr-alerts {
margin-bottom: 16px;
}
.rr-alerts-legend,
.rr-outcome-legend {
display: block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 6px;
}
/* Capped so a customer with twenty alerts cannot push the buttons off screen. */
.rr-alerts-scroll {
max-height: 190px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.rr-alerts-tbl {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
position: sticky;
top: 0;
background: #f1f5f9;
color: #475569;
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
text-align: left;
}
td {
padding: 8px 10px;
border-top: 1px solid #f1f5f9;
color: #1e293b;
white-space: nowrap;
}
}
.ta-r {
text-align: right;
}
.rr-prior,
.rr-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border-radius: 6px;
font-size: 12.5px;
line-height: 1.45;
mat-icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.rr-prior {
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #475569;
}
.rr-error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
font-weight: 500;
}
.rr-outcome {
margin-bottom: 12px;
}
.rr-outcome-options {
display: flex;
flex-direction: column;
gap: 2px;
}
.rr-outcome-option {
font-size: 13px;
}
.rr-notes-field {
width: 100%;
}
.rr-note {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
.rr-dialog-actions {
padding: 8px 24px 20px;
gap: 8px;
}
.rr-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@media (width <= 700px) {
.rr-dialog-content {
min-width: unset;
}
.rr-summary-row {
flex-direction: column;
gap: 2px;
}
}
@@ -0,0 +1,124 @@
/**
* 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, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
export interface AmlRecurringReviewDialogData {
row: any;
windowDays: number;
}
/**
* Records why a customer keeps tripping the AML engine.
*
* This is a judgement on the customer's pattern, not on any single transaction — the individual
* alerts are still disposed of one by one on the Flagged Transactions tab. Choosing ESCALATED here
* documents the reviewer's conclusion; it does not itself open an STR case, because a case is
* opened against a specific alert.
*/
@Component({
selector: 'mifosx-aml-recurring-review-dialog',
templateUrl: './aml-recurring-review-dialog.component.html',
styleUrls: ['./aml-recurring-review-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class AmlRecurringReviewDialogComponent {
private formBuilder = inject(FormBuilder);
private amlService = inject(AmlService);
private dialogRef = inject(MatDialogRef<AmlRecurringReviewDialogComponent>);
readonly data = inject<AmlRecurringReviewDialogData>(MAT_DIALOG_DATA);
submitting = false;
errorMessage = '';
readonly outcomes = [
{ value: 'CLEARED', label: 'Cleared', hint: 'Behaviour explained and acceptable' },
{ value: 'MONITOR', label: 'Keep monitoring', hint: 'Not conclusive — watch this customer more closely' },
{ value: 'ESCALATED', label: 'Escalate', hint: 'Warrants an STR case; escalate the alerts individually' }
];
reviewForm = this.formBuilder.group({
outcome: [
'MONITOR',
Validators.required
],
notes: [
'',
[
Validators.required,
Validators.maxLength(1000)
]
]
});
submit(): void {
if (this.reviewForm.invalid) {
this.reviewForm.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
this.amlService
.reviewRecurringAlert(
this.data.row.clientId,
this.reviewForm.value.outcome as 'CLEARED' | 'MONITOR' | 'ESCALATED',
(this.reviewForm.value.notes || '').trim(),
this.data.windowDays
)
.subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.reviewForm.value.outcome);
},
error: (err) => {
this.submitting = false;
this.errorMessage = this.readError(err);
}
});
}
cancel(): void {
this.dialogRef.close();
}
amount(value: any): string {
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
}
private readError(err: any): string {
const body = err?.error;
const first = body?.errors?.[0];
return (
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
);
}
}