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,194 @@
<!--
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="flagged-toolbar">
<div class="flagged-live">
<span class="live-dot" [class.paused]="!live"></span>
<span class="live-label">{{ live ? 'Live' : 'Paused' }}</span>
<span *ngIf="lastUpdated" class="live-updated">Updated {{ lastUpdated | date: 'HH:mm:ss' }}</span>
<mat-spinner *ngIf="loading" diameter="16" class="live-spinner"></mat-spinner>
</div>
<div class="flagged-actions">
<button
*ngIf="newAlertCount > 0"
type="button"
class="new-alert-pill"
matTooltip="Clear the new-alert highlight"
(click)="acknowledgeNew()"
>
<mat-icon>notifications_active</mat-icon>
{{ newAlertCount }} new
</button>
<button
mat-icon-button
type="button"
[matTooltip]="live ? 'Pause live updates' : 'Resume live updates'"
(click)="toggleLive()"
>
<mat-icon>{{ live ? 'pause_circle' : 'play_circle' }}</mat-icon>
</button>
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
<mat-icon>refresh</mat-icon>
</button>
</div>
</div>
<div *ngIf="loadFailed" class="flagged-error">
<mat-icon>cloud_off</mat-icon>
<span>Could not reach the alert feed. Retrying automatically.</span>
</div>
<!-- Narrow the feed by where each alert sits in the review workflow -->
<div class="status-filters">
<span class="status-filters-label">Status</span>
<button
*ngFor="let s of statusFilters"
type="button"
class="status-chip"
[class.active]="activeStatus === s.key"
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
(click)="setStatus(s.key)"
>
{{ s.label }}
<span class="status-chip-n">{{ statusCount(s.key) }}</span>
</button>
</div>
<!-- Severity mix of what the table is showing -->
<div class="risk-summary">
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
<span class="risk-tile-n">{{ tier.count }}</span>
<span class="risk-tile-l">{{ tier.label }}</span>
</div>
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
<span class="risk-tile-l">Unclassified</span>
</div>
<div class="risk-tile risk-total">
<span class="risk-tile-n">{{ summaryTotal }}</span>
<span class="risk-tile-l">Total Flagged</span>
</div>
</div>
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
<!-- When the engine raised the alert -->
<ng-container matColumnDef="raisedOn">
<th mat-header-cell *matHeaderCellDef>Raised On</th>
<td mat-cell *matCellDef="let alert" class="raised-cell">
<span class="raised-time">{{
alert.raisedOnDate ? (alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—'
}}</span>
<span *ngIf="isNew(alert)" class="new-badge">NEW</span>
</td>
</ng-container>
<!-- Which transaction tripped the rule -->
<ng-container matColumnDef="transaction">
<th mat-header-cell *matHeaderCellDef>Transaction</th>
<td mat-cell *matCellDef="let alert">
<span [ngClass]="txnTypeClass(alert.transactionType)">{{ alert.transactionType }}</span>
<span class="txn-id">#{{ alert.transactionId }}</span>
</td>
</ng-container>
<ng-container matColumnDef="client">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let alert">
<div class="stacked-cell">
<span>{{ alert.clientDisplayName || '—' }}</span>
<span *ngIf="alert.clientId" class="cell-sub">ID {{ alert.clientId }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="rule">
<th mat-header-cell *matHeaderCellDef>Rule Triggered</th>
<td mat-cell *matCellDef="let alert" class="rule-cell">
<div class="stacked-cell">
<span class="rule-code">{{ alert.ruleCode }}</span>
<span class="cell-sub">{{ alert.ruleDescription }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="col-center">Status</th>
<td mat-cell *matCellDef="let alert" class="col-center">
<span [ngClass]="statusClass(alert.status)">{{ alert.status }}</span>
</td>
</ng-container>
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
<ng-container *ngIf="canReview(alert); else reviewed">
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
so without this a user who cannot review still sees buttons that only fail on click. -->
<div class="action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button
type="button"
class="action-btn action-whitelist"
matTooltip="Clear this transaction with no report"
(click)="review(alert, 'WHITELIST')"
>
<mat-icon>verified</mat-icon>
Whitelist
</button>
<button
type="button"
class="action-btn action-suspicious"
matTooltip="Raise as suspicious — opens an STR case"
(click)="review(alert, 'ESCALATE')"
>
<mat-icon>report</mat-icon>
Suspicious
</button>
</div>
</ng-container>
<ng-template #reviewed>
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
→ STR
</span>
</div>
</ng-template>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus"
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
>
<span *ngIf="activeStatus">No alerts in this status.</span>
</div>
@@ -0,0 +1,539 @@
/**
* 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/.
*/
.flagged-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 4px 0 14px;
}
.flagged-live {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #334155;
}
/* Pulses while the feed is polling; goes flat and grey when paused. */
.live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
animation: live-pulse 2s infinite;
&.paused {
background: #94a3b8;
animation: none;
box-shadow: none;
}
}
@keyframes live-pulse {
0% {
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
}
70% {
box-shadow: 0 0 0 8px rgb(22 163 74 / 0%);
}
100% {
box-shadow: 0 0 0 0 rgb(22 163 74 / 0%);
}
}
.live-label {
font-weight: 700;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.05em;
color: #16a34a;
.live-dot.paused + & {
color: #64748b;
}
}
.live-updated {
color: #64748b;
font-size: 12px;
}
.live-spinner {
display: inline-block;
}
.flagged-actions {
display: flex;
align-items: center;
gap: 4px;
}
.new-alert-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid #fdba74;
border-radius: 999px;
background: #fff7ed;
color: #c2410c;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: #ffedd5;
}
}
.flagged-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;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
/* Chips that narrow the feed by review status. */
.status-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 0 0 14px;
}
.status-filters-label {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: #1a237e;
color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
color: #fff;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.status-chip-n {
font-size: 11px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
.status-chip.active & {
background: rgb(255 255 255 / 22%);
color: #fff;
}
}
/* Severity mix of the visible rows. Colours match the Rules Master risk badges so the same
category reads the same across both tabs. */
.risk-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
padding: 0 0 16px;
}
.risk-tile {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left-width: 4px;
border-radius: 8px;
background: #fff;
}
.risk-tile-n {
font-size: 22px;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.risk-tile-l {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.risk-tile.risk-high {
border-left-color: #dc2626;
background: #fef2f2;
.risk-tile-n {
color: #b91c1c;
}
}
.risk-tile.risk-medium {
border-left-color: #f59e0b;
background: #fffbeb;
.risk-tile-n {
color: #b45309;
}
}
.risk-tile.risk-low {
border-left-color: #16a34a;
background: #f0fdf4;
.risk-tile-n {
color: #15803d;
}
}
.risk-tile.risk-none {
border-left-color: #94a3b8;
background: #f8fafc;
.risk-tile-n {
color: #475569;
}
}
.risk-tile.risk-total {
border-left-color: #1a237e;
background: #f8fafc;
.risk-tile-n {
color: #1a237e;
}
}
.flagged-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: 13px;
padding: 16px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
td {
padding: 14px 16px;
font-size: 13px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.alert-row:hover {
background: #f8fafc;
}
/* A newly arrived alert stays tinted until acknowledged. */
.row-new {
background: #fffbeb;
&:hover {
background: #fef3c7;
}
}
.col-center {
text-align: center;
}
.col-right {
text-align: right;
}
.raised-cell {
white-space: nowrap;
}
.raised-time {
font-variant-numeric: tabular-nums;
}
.new-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
border-radius: 4px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
}
.txn-type {
display: inline-block;
padding: 3px 9px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
background: #e2e8f0;
color: #475569;
&.type-savings {
background: #dbeafe;
color: #1d4ed8;
}
&.type-loan {
background: #ede9fe;
color: #6d28d9;
}
&.type-transfer {
background: #ccfbf1;
color: #0f766e;
}
}
.txn-id {
margin-left: 8px;
color: #64748b;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
/* Two-line cells stack inside a wrapper div. Making the <td> itself a flex container drops its
display:table-cell, which collapses the row and pushes later columns onto their own line. */
.stacked-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.cell-sub {
color: #64748b;
font-size: 12px;
}
.rule-code {
font-weight: 600;
}
.rule-cell {
max-width: 300px;
}
.amount-cell {
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.alert-status {
display: inline-block;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
&.status-raised {
background: #fef3c7;
color: #92400e;
}
&.status-under_review {
background: #dbeafe;
color: #1d4ed8;
}
&.status-escalated {
background: #fee2e2;
color: #b91c1c;
}
&.status-whitelisted {
background: #dcfce7;
color: #15803d;
}
}
.actions-cell {
white-space: nowrap;
}
.action-group {
display: inline-flex;
align-items: center;
gap: 2px;
}
.action-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;
}
}
.action-whitelist {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
&:hover {
background: #dcfce7;
border-color: #86efac;
}
}
.action-suspicious {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
&:hover {
background: #fee2e2;
border-color: #fca5a5;
}
}
.reviewed-cell {
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
}
.reviewed-by {
color: #94a3b8;
font-size: 12px;
font-style: italic;
}
.reviewed-evidence-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #64748b;
cursor: help;
}
/* Mirrors the FIU-IND STR register badge, so the same state reads the same in both screens. */
.str-routed {
padding: 2px 7px;
border-radius: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
::ng-deep .evidence-tooltip {
max-width: 320px;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.5;
}
.no-alerts-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #64748b;
gap: 12px;
text-align: center;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
.flagged-paginator {
background: #fff;
border-top: 1px solid #e2e8f0;
}
@@ -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();
});
}
}
@@ -0,0 +1,135 @@
<!--
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="review-dialog-title" [class.escalate]="!isWhitelist">
<mat-icon>{{ isWhitelist ? 'verified' : 'report' }}</mat-icon>
{{ title }}
</h2>
<mat-dialog-content class="review-dialog-content">
<!-- Restate exactly which transaction is being disposed of -->
<div class="review-summary">
<div class="review-summary-row">
<span class="review-label">Transaction</span>
<span class="review-value">
{{ data.alert.transactionType }} <span class="review-mono">#{{ data.alert.transactionId }}</span>
</span>
</div>
<div class="review-summary-row">
<span class="review-label">Client</span>
<span class="review-value">
{{ data.alert.clientDisplayName || '—' }}
<span *ngIf="data.alert.clientId" class="review-muted">(ID {{ data.alert.clientId }})</span>
</span>
</div>
<div class="review-summary-row">
<span class="review-label">Amount</span>
<span class="review-value review-mono">{{ amount() }}</span>
</div>
<div class="review-summary-row">
<span class="review-label">Rule</span>
<span class="review-value">
{{ data.alert.ruleCode }}
<span class="review-muted">— {{ data.alert.ruleDescription }}</span>
</span>
</div>
<div class="review-summary-row">
<span class="review-label">Raised On</span>
<span class="review-value">{{ data.alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm:ss' }}</span>
</div>
</div>
<!-- Escalation is not a reversible bookkeeping change; say what it sets in motion -->
<div *ngIf="!isWhitelist" class="review-warning">
<mat-icon>warning</mat-icon>
<span>
This opens an STR case for the Principal Officer and re-rates the client's risk category. The case carries a
7-working-day FIU filing deadline.
</span>
</div>
<div *ngIf="isWhitelist" class="review-note">
<mat-icon>info_outline</mat-icon>
<span>The alert is closed with no report filed. The reason below is retained as the audit trail.</span>
</div>
<div *ngIf="errorMessage" class="review-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
<form [formGroup]="reviewForm" autocomplete="off">
<!-- Evidence classification only applies to clearing an alert -->
<ng-container *ngIf="isWhitelist">
<div class="evidence-block" [class.evidence-block-invalid]="evidenceMissing">
<span class="evidence-legend">Evidence relied on — tick all that apply</span>
<div formArrayName="evidence" class="evidence-options">
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i" class="evidence-option">
{{ t }}
</mat-checkbox>
</div>
<span *ngIf="evidenceMissing" class="evidence-error">Tick at least one form of evidence</span>
</div>
<mat-form-field appearance="outline" class="review-notes-field">
<mat-label>Document / Reference No.</mat-label>
<input matInput formControlName="evidenceRef" placeholder="e.g. Invoice INV-4471, Sale deed 12/2026" />
<mat-hint>Optional — identifies the documents held on file</mat-hint>
</mat-form-field>
</ng-container>
<mat-form-field appearance="outline" class="review-notes-field">
<mat-label>{{ notesLabel }}</mat-label>
<textarea
matInput
rows="4"
formControlName="notes"
[placeholder]="
isWhitelist
? 'What the evidence shows and how it accounts for this transaction'
: 'The pattern or behaviour that makes this transaction suspicious'
"
></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 {{ detailMax }} characters
</mat-error>
</mat-form-field>
<!-- No guessing about what is retained: show the exact string that will be stored. -->
<div *ngIf="composedNotes" class="review-stored">
<span class="review-stored-label">Stored on the alert record</span>
<code class="review-stored-value">{{ composedNotes }}</code>
<span class="review-stored-hint">
Saved to <code>m_aml_alert.review_notes</code> against alert #{{ data.alert.id }}, and shown in the NOTES column
of the FIU&#8209;IND STR register.
</span>
</div>
<div *ngIf="composedTooLong" class="review-error">
<mat-icon>error_outline</mat-icon>
<span>Too long to store — untick some evidence, or shorten the detail or reference.</span>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end" class="review-dialog-actions">
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
<button
mat-flat-button
type="button"
[color]="isWhitelist ? 'primary' : 'warn'"
[disabled]="reviewForm.invalid || composedTooLong || submitting"
(click)="submit()"
>
<mat-spinner *ngIf="submitting" diameter="18" class="review-spinner"></mat-spinner>
{{ submitting ? 'Saving…' : confirmLabel }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,215 @@
/**
* 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/.
*/
.review-dialog-title {
display: flex;
align-items: center;
gap: 10px;
color: #15803d;
font-weight: 600;
&.escalate {
color: #b91c1c;
}
}
.review-dialog-content {
padding-top: 8px;
min-width: 520px;
max-width: 640px;
}
.review-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 14px 16px;
margin-bottom: 16px;
}
.review-summary-row {
display: flex;
gap: 12px;
padding: 4px 0;
font-size: 13px;
align-items: baseline;
}
.review-label {
flex: 0 0 96px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.review-value {
flex: 1 1 auto;
color: #1e293b;
font-weight: 500;
}
.review-muted {
color: #64748b;
font-weight: 400;
}
.review-mono {
font-variant-numeric: tabular-nums;
}
.review-warning,
.review-note,
.review-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
border-radius: 6px;
font-size: 13px;
line-height: 1.45;
mat-icon {
flex: 0 0 auto;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.review-warning {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.review-note {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.review-error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
font-weight: 500;
}
/* Multi-select evidence. Bordered so the group reads as one required field rather than a
loose row of checkboxes. */
.evidence-block {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 16px;
&.evidence-block-invalid {
border-color: #fca5a5;
background: #fef2f2;
}
}
.evidence-legend {
display: block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 8px;
}
.evidence-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 12px;
}
.evidence-option {
font-size: 13px;
}
.evidence-error {
display: block;
margin-top: 8px;
font-size: 12px;
font-weight: 500;
color: #b91c1c;
}
.review-notes-field {
width: 100%;
}
/* Shows the officer the exact string the review will persist. */
.review-stored {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #f8fafc;
}
.review-stored-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.review-stored-value {
font-size: 12.5px;
color: #1e293b;
word-break: break-word;
line-height: 1.45;
}
.review-stored-hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.45;
code {
background: #e2e8f0;
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
}
.review-dialog-actions {
padding: 8px 24px 20px;
gap: 8px;
}
.review-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
@media (width <= 600px) {
.review-dialog-content {
min-width: unset;
}
.review-summary-row {
flex-direction: column;
gap: 2px;
}
.evidence-options {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,212 @@
/**
* 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 {
AbstractControl,
FormArray,
FormBuilder,
ValidationErrors,
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 { MatCheckboxModule } from '@angular/material/checkbox';
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 AmlReviewDialogData {
alert: any;
disposition: 'WHITELIST' | 'ESCALATE';
}
/**
* Disposes of one engine alert: clear it (WHITELIST) or raise it as suspicious (ESCALATE).
*
* The alert being actioned is restated in full here rather than relying on the officer having
* read the right table row — the two outcomes are not symmetric. WHITELIST closes the alert with
* no report; ESCALATE opens an STR case and re-rates the client's risk, which is not something to
* trigger from a stray click on a table. Notes are mandatory because they are the audit trail for
* why a flagged transaction was cleared.
*/
@Component({
selector: 'mifosx-aml-review-alert-dialog',
templateUrl: './aml-review-alert-dialog.component.html',
styleUrls: ['./aml-review-alert-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class AmlReviewAlertDialogComponent {
private formBuilder = inject(FormBuilder);
private amlService = inject(AmlService);
private dialogRef = inject(MatDialogRef<AmlReviewAlertDialogComponent>);
readonly data = inject<AmlReviewDialogData>(MAT_DIALOG_DATA);
submitting = false;
errorMessage = '';
/**
* What supports clearing the alert. The platform has no evidence table — m_aml_alert.review_notes
* (VARCHAR(1000)) is the only field the review writes — so the classification is folded into that
* one string rather than invented as a column the API cannot persist.
*/
readonly evidenceTypes = [
'Customer explanation obtained',
'Source of funds documented',
'KYC / identity re-verified',
'Consistent with declared income or turnover',
'Established business or salary pattern',
'Duplicate or system-generated alert',
'Other'
];
/** Leaves headroom for the composed prefix inside the 1000-char column. */
private static readonly DETAIL_MAX = 800;
private static readonly NOTES_COLUMN_MAX = 1000;
/** One checkbox per evidence type; a clearing decision can rest on several at once. */
reviewForm = this.formBuilder.group({
evidence: this.formBuilder.array(this.evidenceTypes.map(() => false)),
evidenceRef: [
'',
Validators.maxLength(100)
],
notes: [
'',
[
Validators.required,
Validators.maxLength(AmlReviewAlertDialogComponent.DETAIL_MAX)
]
]
});
constructor() {
// Evidence classification is meaningless on an escalation — that path records grounds for
// suspicion, not proof of innocence — so it is only required when clearing an alert.
if (this.isWhitelist) {
this.evidenceArray.addValidators(AmlReviewAlertDialogComponent.atLeastOneChecked);
this.evidenceArray.updateValueAndValidity();
}
}
/** FormArray of booleans, so "none ticked" has to be checked across the whole array. */
private static atLeastOneChecked(control: AbstractControl): ValidationErrors | null {
const boxes = control as FormArray;
return boxes.controls.some((c) => c.value === true) ? null : { required: true };
}
get evidenceArray(): FormArray {
return this.reviewForm.get('evidence') as FormArray;
}
get selectedEvidence(): string[] {
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
}
get evidenceMissing(): boolean {
return this.isWhitelist && this.evidenceArray.invalid && this.evidenceArray.touched;
}
get isWhitelist(): boolean {
return this.data.disposition === 'WHITELIST';
}
get detailMax(): number {
return AmlReviewAlertDialogComponent.DETAIL_MAX;
}
/** Exactly what will be written to the alert record, shown before the officer commits. */
get composedNotes(): string {
const v = this.reviewForm.value;
const detail = (v.notes || '').trim();
if (!this.isWhitelist) {
return detail ? `Grounds: ${detail}` : '';
}
const parts: string[] = [];
const selected = this.selectedEvidence;
if (selected.length) {
parts.push(`Evidence: ${selected.join('; ')}`);
}
if (v.evidenceRef) {
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
}
const head = parts.join(' | ');
return head && detail ? `${head} — ${detail}` : head || detail;
}
get composedTooLong(): boolean {
return this.composedNotes.length > AmlReviewAlertDialogComponent.NOTES_COLUMN_MAX;
}
get title(): string {
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
}
get confirmLabel(): string {
return this.isWhitelist ? 'Whitelist' : 'Escalate to STR';
}
get notesLabel(): string {
return this.isWhitelist ? 'Evidence detail' : 'What makes this transaction suspicious?';
}
submit(): void {
if (this.reviewForm.invalid || this.composedTooLong) {
this.reviewForm.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.data.disposition);
},
error: (err) => {
this.submitting = false;
this.errorMessage = this.readError(err);
}
});
}
cancel(): void {
this.dialogRef.close();
}
amount(): string {
const value = Number(this.data.alert?.amount) || 0;
return '₹' + value.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.'
);
}
}