1130 lines
40 KiB
TypeScript
1130 lines
40 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild, TemplateRef } 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 { MatCheckboxModule } from '@angular/material/checkbox';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
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 { AuthenticationService } from '../../../core/authentication/authentication.service';
|
|
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
|
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
|
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
|
|
import { exportWorkbook } from '../../../cashier/cashier-export';
|
|
|
|
/** How the feed is narrowed by time. */
|
|
type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
|
|
|
|
/**
|
|
* 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,
|
|
FormsModule,
|
|
MatTableModule,
|
|
MatPaginatorModule,
|
|
MatIconModule,
|
|
MatButtonModule,
|
|
MatTooltipModule,
|
|
MatProgressSpinnerModule,
|
|
MatDialogModule,
|
|
MatCheckboxModule,
|
|
HasPermissionDirective
|
|
]
|
|
})
|
|
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
private amlService = inject(AmlService);
|
|
private dialog = inject(MatDialog);
|
|
private alertService = inject(AlertService);
|
|
private auth = inject(AuthenticationService);
|
|
|
|
/** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */
|
|
private readonly currentUsername = this.auth.getCredentials()?.username ?? '';
|
|
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[] = [
|
|
'select',
|
|
'raisedOn',
|
|
'transaction',
|
|
'client',
|
|
'rule',
|
|
'amount',
|
|
'status',
|
|
'actions'
|
|
];
|
|
|
|
/**
|
|
* Alerts ticked for a single disposition across several transactions.
|
|
*
|
|
* <p>
|
|
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
|
|
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
|
|
* a time is what stops an officer getting to the one alert that matters. High severity is
|
|
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
|
|
* transaction, and it gets its own decision.
|
|
*/
|
|
selectedIds = new Set<number>();
|
|
|
|
/** m_client_risk_rule.risk_category — 3 is the high band. */
|
|
private static readonly HIGH_SEVERITY = 3;
|
|
|
|
/**
|
|
* Whether any rule this transaction matched reached the high band.
|
|
*
|
|
* <p>
|
|
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
|
|
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
|
|
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
|
|
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
|
|
* transaction up for a batch decision.
|
|
*/
|
|
isHighSeverity(alert: any): boolean {
|
|
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
|
|
}
|
|
|
|
/**
|
|
* The band this alert counts as, for the summary tiles.
|
|
*
|
|
* <p>
|
|
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
|
|
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
|
|
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
|
|
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
|
|
*/
|
|
private severityBand(alert: any): number {
|
|
const worst = Number(alert?.highestSeverity);
|
|
if (worst === 1 || worst === 2 || worst === 3) {
|
|
return worst;
|
|
}
|
|
const governing = Number(alert?.riskCategory);
|
|
if (governing === 1 || governing === 2 || governing === 3) {
|
|
return governing;
|
|
}
|
|
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
|
|
// NaN band would leave that tile showing a count it could never select.
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
|
|
* others.
|
|
*
|
|
* <p>
|
|
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
|
|
* judgement about the transaction itself. And one where no matched rule carried a severity at
|
|
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
|
|
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
|
|
*/
|
|
isSelectable(alert: any): boolean {
|
|
const band = Number(alert?.highestSeverity);
|
|
const graded = band === 1 || band === 2;
|
|
return this.canReview(alert) && graded;
|
|
}
|
|
|
|
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
|
|
expandedId: number | null = null;
|
|
|
|
/**
|
|
* Opens or closes the detail pane. Nothing is recorded by reading.
|
|
*
|
|
* <p>
|
|
* Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
|
|
* starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
|
|
* on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
|
|
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
|
|
*/
|
|
toggleExpanded(alert: any): void {
|
|
this.expandedId = alert.id;
|
|
// Ticks belong to the alert they were made on, not to whichever popup is open next.
|
|
this.selectedRuleCodes.clear();
|
|
this.dialog
|
|
.open(this.rulesDialogTpl, { width: '780px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false })
|
|
.afterClosed()
|
|
.subscribe(() => {
|
|
this.expandedId = null;
|
|
this.selectedRuleCodes.clear();
|
|
});
|
|
}
|
|
|
|
/** The popup that lists every rule the open alert tripped. */
|
|
@ViewChild('rulesDialog') rulesDialogTpl!: TemplateRef<unknown>;
|
|
|
|
/** The alert the popup is showing, looked up fresh so a feed refresh behind it does not leave it stale. */
|
|
get expandedAlert(): any {
|
|
return this.alerts.data.find((a: any) => a.id === this.expandedId) ?? null;
|
|
}
|
|
|
|
isExpanded(alert: any): boolean {
|
|
return this.expandedId === alert.id;
|
|
}
|
|
|
|
/**
|
|
* Every rule the transaction matched, governing one first.
|
|
*
|
|
* <p>
|
|
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
|
|
* it, so an older row still expands to something rather than appearing to have matched nothing.
|
|
*/
|
|
matchedRules(
|
|
alert: any
|
|
): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
|
|
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
|
|
return alert.matchedRules;
|
|
}
|
|
const rules = [
|
|
{
|
|
ruleCode: alert?.ruleCode,
|
|
ruleDescription: alert?.ruleDescription,
|
|
riskCategory: alert?.riskCategory ?? null,
|
|
governing: true
|
|
}
|
|
];
|
|
for (const code of String(alert?.alsoMatchedRules || '')
|
|
.split(',')
|
|
.map((c: string) => c.trim())
|
|
.filter((c: string) => c)) {
|
|
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
/** How many rules the transaction matched, for the count shown on the collapsed row. */
|
|
matchedRuleCount(alert: any): number {
|
|
return this.matchedRules(alert).length;
|
|
}
|
|
|
|
severityLabel(band: number | null | undefined): string {
|
|
switch (Number(band)) {
|
|
case 1:
|
|
return 'Low';
|
|
case 2:
|
|
return 'Medium';
|
|
case 3:
|
|
return 'High';
|
|
default:
|
|
return 'Statutory';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rule codes ticked inside the open detail pane.
|
|
*
|
|
* <p>
|
|
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
|
|
* and carrying them across would apply one transaction's selection to another's rules.
|
|
*/
|
|
selectedRuleCodes = new Set<string>();
|
|
|
|
isRuleSelected(rule: any): boolean {
|
|
return this.selectedRuleCodes.has(rule.ruleCode);
|
|
}
|
|
|
|
/**
|
|
* Whether this rule is the officer's to decide.
|
|
*
|
|
* <p>
|
|
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
|
|
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
|
|
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
|
|
* does not stop the CTR, and escalating it does not describe what the rule found.
|
|
*/
|
|
isRuleSelectable(rule: any): boolean {
|
|
return !rule?.disposition && this.isGradedRule(rule);
|
|
}
|
|
|
|
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
|
|
isGradedRule(rule: any): boolean {
|
|
const band = Number(rule?.riskCategory);
|
|
return band === 1 || band === 2 || band === 3;
|
|
}
|
|
|
|
toggleRuleSelected(rule: any): void {
|
|
if (!this.isRuleSelectable(rule)) {
|
|
return;
|
|
}
|
|
if (this.selectedRuleCodes.has(rule.ruleCode)) {
|
|
this.selectedRuleCodes.delete(rule.ruleCode);
|
|
} else {
|
|
this.selectedRuleCodes.add(rule.ruleCode);
|
|
}
|
|
}
|
|
|
|
selectableRulesOf(alert: any): any[] {
|
|
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
|
|
}
|
|
|
|
get selectedRuleCount(): number {
|
|
return this.selectedRuleCodes.size;
|
|
}
|
|
|
|
allRulesSelected(alert: any): boolean {
|
|
const open = this.selectableRulesOf(alert);
|
|
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
|
|
}
|
|
|
|
toggleAllRules(alert: any): void {
|
|
if (this.allRulesSelected(alert)) {
|
|
this.selectedRuleCodes.clear();
|
|
return;
|
|
}
|
|
for (const rule of this.selectableRulesOf(alert)) {
|
|
this.selectedRuleCodes.add(rule.ruleCode);
|
|
}
|
|
}
|
|
|
|
ruleDispositionLabel(rule: any): string {
|
|
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
|
|
}
|
|
|
|
severityClass(band: number | null | undefined): string {
|
|
switch (Number(band)) {
|
|
case 1:
|
|
return 'sev-low';
|
|
case 2:
|
|
return 'sev-medium';
|
|
case 3:
|
|
return 'sev-high';
|
|
default:
|
|
return 'sev-none';
|
|
}
|
|
}
|
|
|
|
isSelected(alert: any): boolean {
|
|
return this.selectedIds.has(alert.id);
|
|
}
|
|
|
|
toggleSelected(alert: any): void {
|
|
if (!this.isSelectable(alert)) {
|
|
return;
|
|
}
|
|
if (this.selectedIds.has(alert.id)) {
|
|
this.selectedIds.delete(alert.id);
|
|
} else {
|
|
this.selectedIds.add(alert.id);
|
|
}
|
|
}
|
|
|
|
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
|
|
get selectableRows(): any[] {
|
|
return this.alerts.data.filter((a) => this.isSelectable(a));
|
|
}
|
|
|
|
get allSelectableSelected(): boolean {
|
|
const rows = this.selectableRows;
|
|
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
|
|
}
|
|
|
|
toggleSelectAll(): void {
|
|
if (this.allSelectableSelected) {
|
|
this.selectedIds.clear();
|
|
return;
|
|
}
|
|
for (const a of this.selectableRows) {
|
|
this.selectedIds.add(a.id);
|
|
}
|
|
}
|
|
|
|
get selectedCount(): number {
|
|
return this.selectedIds.size;
|
|
}
|
|
|
|
private selectedAlerts(): any[] {
|
|
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
|
|
}
|
|
|
|
/**
|
|
* Drops ticks that no longer stand, after every poll.
|
|
*
|
|
* <p>
|
|
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
|
|
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
|
|
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
|
|
* screen, and the batch carries alerts nobody chose.
|
|
*/
|
|
private pruneSelection(): void {
|
|
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
|
|
for (const id of Array.from(this.selectedIds)) {
|
|
if (!stillSelectable.has(id)) {
|
|
this.selectedIds.delete(id);
|
|
}
|
|
}
|
|
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
|
|
// leave the table holding a pane for a row that is no longer on it.
|
|
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
|
|
this.expandedId = null;
|
|
}
|
|
}
|
|
|
|
/** 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' },
|
|
// No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
|
|
// chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
|
|
// canReview still treats them as open — but it is not offered as a filter nobody can populate.
|
|
{ key: 'ESCALATED', label: 'Escalated' },
|
|
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
|
];
|
|
activeStatus = '';
|
|
private statusCounts: Record<string, number> = {};
|
|
|
|
/**
|
|
* The period being worked.
|
|
*
|
|
* A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
|
|
* today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
|
|
* — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
|
|
*/
|
|
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
|
{ key: 'all', label: 'All time' },
|
|
{ key: 'day', label: 'Day wise' },
|
|
{ key: 'week', label: 'Week wise' },
|
|
{ key: 'month', label: 'Month wise' },
|
|
{ key: 'year', label: 'Year wise' }
|
|
];
|
|
periodMode: PeriodMode = 'all';
|
|
|
|
/** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
|
|
filterDay = '';
|
|
filterWeek = '';
|
|
filterMonth = '';
|
|
filterYear = '';
|
|
|
|
/**
|
|
* Narrows the table to the rules firing most often in the chosen period.
|
|
*
|
|
* <p>
|
|
* A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
|
|
* pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
|
|
* the count are the ones worth an officer's attention first, and the long tail below them can wait.
|
|
*
|
|
* <p>
|
|
* Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
|
|
*/
|
|
readonly topRuleOptions: (number | null)[] = [
|
|
null,
|
|
10,
|
|
20,
|
|
50
|
|
];
|
|
topRules: number | null = null;
|
|
|
|
setTopRules(n: number | null): void {
|
|
this.topRules = n;
|
|
this.applyStatusFilter(true);
|
|
}
|
|
|
|
/**
|
|
* The rule codes to keep, most-triggered first, or null when every rule is in view.
|
|
*
|
|
* <p>
|
|
* Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
|
|
* the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
|
|
* instead of shifting between polls.
|
|
*/
|
|
private topRuleCodes(pool: any[]): Set<string> | null {
|
|
if (this.topRules === null) {
|
|
return null;
|
|
}
|
|
const counts = new Map<string, number>();
|
|
for (const a of pool) {
|
|
const code = a?.ruleCode;
|
|
if (code) {
|
|
counts.set(code, (counts.get(code) ?? 0) + 1);
|
|
}
|
|
}
|
|
if (counts.size <= this.topRules) {
|
|
return null;
|
|
}
|
|
const ranked = Array.from(counts.entries())
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.slice(0, this.topRules)
|
|
.map(([code]) => code);
|
|
return new Set(ranked);
|
|
}
|
|
|
|
/** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
|
|
get distinctRuleCount(): number {
|
|
return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
|
|
}
|
|
|
|
/**
|
|
* The rules in view with how often each fired, busiest first.
|
|
*
|
|
* <p>
|
|
* The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
|
|
* Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
|
|
*/
|
|
get ruleFrequency(): { code: string; count: number }[] {
|
|
const counts = new Map<string, number>();
|
|
for (const a of this.alerts.data) {
|
|
if (a?.ruleCode) {
|
|
counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
|
|
}
|
|
}
|
|
return Array.from(counts.entries())
|
|
.map(
|
|
([
|
|
code,
|
|
count
|
|
]) => ({ code, count })
|
|
)
|
|
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
|
|
}
|
|
|
|
setPeriodMode(mode: PeriodMode): void {
|
|
this.periodMode = mode;
|
|
// Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
|
|
// empty field the officer has to fill before anything happens.
|
|
const now = new Date();
|
|
const iso = this.isoOf(now);
|
|
if (mode === 'day' && !this.filterDay) {
|
|
this.filterDay = iso;
|
|
}
|
|
if (mode === 'week' && !this.filterWeek) {
|
|
this.filterWeek = iso;
|
|
}
|
|
if (mode === 'month' && !this.filterMonth) {
|
|
this.filterMonth = iso.slice(0, 7);
|
|
}
|
|
if (mode === 'year' && !this.filterYear) {
|
|
this.filterYear = String(now.getFullYear());
|
|
}
|
|
this.applyStatusFilter(true);
|
|
}
|
|
|
|
onPeriodChange(): void {
|
|
this.applyStatusFilter(true);
|
|
}
|
|
|
|
/** The years the feed actually covers, newest first, so the picker offers nothing empty. */
|
|
get availableYears(): string[] {
|
|
const years = new Set<string>();
|
|
for (const a of this.allAlerts) {
|
|
if (a.raisedOnDate) {
|
|
years.add(String(a.raisedOnDate.getFullYear()));
|
|
}
|
|
}
|
|
const list = Array.from(years).sort().reverse();
|
|
return list.length ? list : [String(new Date().getFullYear())];
|
|
}
|
|
|
|
/** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
|
|
get periodLabel(): string {
|
|
const range = this.periodRange();
|
|
if (!range) {
|
|
return '';
|
|
}
|
|
return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
|
|
}
|
|
|
|
private isoOf(d: Date): string {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* The chosen period as an inclusive from/to, or null for all time.
|
|
*
|
|
* Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
|
|
* the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
|
|
*/
|
|
private periodRange(): { from: string; to: string } | null {
|
|
switch (this.periodMode) {
|
|
case 'day':
|
|
return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
|
|
case 'week': {
|
|
if (!this.filterWeek) {
|
|
return null;
|
|
}
|
|
const [
|
|
y,
|
|
m,
|
|
d
|
|
] = this.filterWeek.split('-').map(Number);
|
|
const picked = new Date(y, m - 1, d);
|
|
const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
|
|
const monday = new Date(y, m - 1, d - backToMonday);
|
|
const sunday = new Date(y, m - 1, d - backToMonday + 6);
|
|
return { from: this.isoOf(monday), to: this.isoOf(sunday) };
|
|
}
|
|
case 'month': {
|
|
if (!this.filterMonth) {
|
|
return null;
|
|
}
|
|
const [
|
|
y,
|
|
m
|
|
] = this.filterMonth.split('-').map(Number);
|
|
// Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
|
|
return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
|
|
}
|
|
case 'year':
|
|
return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Whether an alert was raised inside the chosen period. */
|
|
private inActivePeriod(alert: any): boolean {
|
|
const range = this.periodRange();
|
|
if (!range) {
|
|
return true;
|
|
}
|
|
const d: Date | null = alert?.raisedOnDate;
|
|
if (!d) {
|
|
return false;
|
|
}
|
|
const iso = this.isoOf(d);
|
|
return iso >= range.from && iso <= range.to;
|
|
}
|
|
|
|
/**
|
|
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
|
|
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
|
|
* the high-severity alerts that are still open.
|
|
*/
|
|
activeSeverity: number | null = null;
|
|
|
|
/**
|
|
* 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: { key: number; 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;
|
|
|
|
/**
|
|
* Binds the paginator as soon as the view exists.
|
|
*
|
|
* <p>
|
|
* The first poll can return before the view has initialised, and until this the data source was
|
|
* only ever wired up as a side effect of a later filter pass — so the table could render every
|
|
* alert unpaginated, then jump once the next poll arrived.
|
|
*/
|
|
ngAfterViewInit(): void {
|
|
this.alerts.paginator = this.paginator;
|
|
}
|
|
|
|
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(true);
|
|
}
|
|
|
|
/** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
|
|
setSeverity(band: number): void {
|
|
this.activeSeverity = this.activeSeverity === band ? null : band;
|
|
this.applyStatusFilter(true);
|
|
}
|
|
|
|
isSeverityActive(band: number): boolean {
|
|
return this.activeSeverity === band;
|
|
}
|
|
|
|
clearSeverity(): void {
|
|
this.activeSeverity = null;
|
|
this.applyStatusFilter(true);
|
|
}
|
|
|
|
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.pruneSelection();
|
|
this.lastUpdated = new Date();
|
|
}
|
|
|
|
/**
|
|
* @param resetPage
|
|
* true only when the user changed a filter. A poll must not reset the page: the feed
|
|
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
|
|
* page 1 each time. Nor may it touch the paginator before the data source has bound to
|
|
* it — doing that on the first pass left pageSize at 0 and emptied the table.
|
|
*/
|
|
private applyStatusFilter(resetPage = false): void {
|
|
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
|
|
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
|
|
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
|
const byStatus = this.activeStatus ? byDay.filter((a) => this.matchesStatus(a, this.activeStatus)) : byDay;
|
|
const bySeverity =
|
|
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
|
|
// Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
|
|
// the ten busiest overall then filtered down to a handful.
|
|
const keep = this.topRuleCodes(bySeverity);
|
|
this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity;
|
|
if (!this.alerts.paginator && this.paginator) {
|
|
this.alerts.paginator = this.paginator;
|
|
}
|
|
if (resetPage && this.alerts.paginator) {
|
|
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
|
|
this.alerts.paginator.firstPage();
|
|
}
|
|
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
|
|
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
|
|
// way back to them.
|
|
this.recomputeRiskSummary(byStatus);
|
|
}
|
|
|
|
/**
|
|
* 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(pool: any[]): void {
|
|
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) => ({
|
|
key: t.key,
|
|
label: t.label,
|
|
cls: t.cls,
|
|
count: pool.filter((a) => this.severityBand(a) === t.key).length
|
|
}));
|
|
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
|
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
|
}
|
|
|
|
/**
|
|
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
|
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
|
* back to all of them.
|
|
*/
|
|
exporting = false;
|
|
|
|
/**
|
|
* Exports exactly what the table is showing.
|
|
*
|
|
* <p>
|
|
* Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
|
|
* the period, status and severity in the file. A workbook that quietly held more than the screen did would be
|
|
* worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
|
|
* on has no way to tell.
|
|
*
|
|
* <p>
|
|
* The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
|
|
* with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
|
|
*/
|
|
async exportExcel(): Promise<void> {
|
|
const rows = this.alerts.data;
|
|
if (!rows.length || this.exporting) {
|
|
return;
|
|
}
|
|
this.exporting = true;
|
|
try {
|
|
const scope = [
|
|
this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
|
|
`Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
|
|
`Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
|
|
`Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
|
|
].join(' · ');
|
|
|
|
await exportWorkbook(
|
|
[
|
|
{
|
|
sheetName: 'Flagged Transactions',
|
|
title: 'AML — Flagged Transactions',
|
|
subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
|
|
headers: [
|
|
'Alert ID',
|
|
'Raised On',
|
|
'Customer',
|
|
'Customer ID',
|
|
'Transaction',
|
|
'Txn ID',
|
|
'Governing Rule',
|
|
'Rule Description',
|
|
'All Rules Matched',
|
|
'Severity',
|
|
'Amount',
|
|
'Status',
|
|
'Reviewed By',
|
|
'Review Notes'
|
|
],
|
|
rows: rows.map((a) => [
|
|
a.id ?? null,
|
|
a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
|
|
a.clientDisplayName || '',
|
|
a.clientId ?? null,
|
|
a.transactionType || '',
|
|
a.transactionId ?? null,
|
|
a.ruleCode || '',
|
|
a.ruleDescription || '',
|
|
// Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
|
|
// hide a high-severity finding that a reader of the sheet would need.
|
|
this.matchedRules(a)
|
|
.map((r) => r.ruleCode)
|
|
.join(', '),
|
|
this.severityLabel(this.severityBand(a)),
|
|
Number(a.amount) || 0,
|
|
a.status || '',
|
|
a.reviewedByUsername || '',
|
|
a.reviewNotes || ''
|
|
]),
|
|
widths: [
|
|
9,
|
|
20,
|
|
24,
|
|
12,
|
|
12,
|
|
10,
|
|
30,
|
|
38,
|
|
44,
|
|
11,
|
|
15,
|
|
14,
|
|
14,
|
|
50
|
|
]
|
|
}
|
|
],
|
|
`AML-Flagged-${this.periodLabel || 'all-time'}`
|
|
);
|
|
} finally {
|
|
this.exporting = false;
|
|
}
|
|
}
|
|
|
|
private statusLabel(key: string): string {
|
|
return this.statusFilters.find((s) => s.key === key)?.label ?? key;
|
|
}
|
|
|
|
get summaryTotal(): number {
|
|
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
|
return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length;
|
|
}
|
|
|
|
private recomputeStatusCounts(): void {
|
|
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
|
|
// A chip whose figure describes a different set from the table under it is worse than no figure.
|
|
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
|
this.statusCounts = { '': pool.length };
|
|
for (const s of this.statusFilters) {
|
|
if (s.key) {
|
|
this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether an alert belongs under a status chip.
|
|
*
|
|
* Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name
|
|
* belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a
|
|
* decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided
|
|
* alerts, the largest ₹18,00,000, behind a pending figure that read 1.
|
|
*
|
|
* Pending is therefore defined by what has not been decided rather than by one status name. Any status that is
|
|
* neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an
|
|
* alert shown as work that turns out to be finished costs a glance; one hidden costs a filing.
|
|
*/
|
|
private matchesStatus(alert: any, key: string): boolean {
|
|
if (key === 'RAISED') {
|
|
return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED';
|
|
}
|
|
return alert?.status === key;
|
|
}
|
|
|
|
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 {
|
|
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
|
|
return false;
|
|
}
|
|
// An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
|
|
// anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
|
|
return !this.heldByAnother(alert);
|
|
}
|
|
|
|
/** True when this alert is under review by somebody other than the signed-in officer. */
|
|
heldByAnother(alert: any): boolean {
|
|
return (
|
|
alert?.status === 'UNDER_REVIEW' &&
|
|
!!alert?.reviewedByUsername &&
|
|
alert.reviewedByUsername !== this.currentUsername
|
|
);
|
|
}
|
|
|
|
/** Who has the alert, for the message shown in place of the action buttons. */
|
|
heldBy(alert: any): string {
|
|
return alert?.reviewedByUsername || 'another officer';
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Applies one disposition to every ticked alert.
|
|
*
|
|
* <p>
|
|
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
|
|
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
|
|
* point of the batch: the officer is making one decision about one kind of activity, not the same
|
|
* decision twenty times.
|
|
*/
|
|
/**
|
|
* Applies one disposition to the rules ticked inside the open pane.
|
|
*
|
|
* <p>
|
|
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
|
|
* leaves the case open on the sixth rather than closing it.
|
|
*/
|
|
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
|
const codes = Array.from(this.selectedRuleCodes);
|
|
if (!codes.length) {
|
|
return;
|
|
}
|
|
this.dialog
|
|
.open(AmlReviewAlertDialogComponent, {
|
|
width: '560px',
|
|
maxWidth: '95vw',
|
|
autoFocus: false,
|
|
disableClose: true,
|
|
data: { alert, disposition, ruleCodes: codes }
|
|
})
|
|
.afterClosed()
|
|
.subscribe((result: string | undefined) => {
|
|
if (!result) {
|
|
return;
|
|
}
|
|
this.alertService.alert({
|
|
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
|
|
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
|
|
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
|
|
}.`
|
|
});
|
|
this.selectedRuleCodes.clear();
|
|
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
|
|
this.fetch();
|
|
});
|
|
}
|
|
|
|
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
|
|
const batch = this.selectedAlerts();
|
|
if (!batch.length) {
|
|
return;
|
|
}
|
|
this.dialog
|
|
.open(AmlBulkReviewDialogComponent, {
|
|
width: '620px',
|
|
maxWidth: '95vw',
|
|
autoFocus: false,
|
|
disableClose: true,
|
|
data: { alerts: batch, disposition }
|
|
})
|
|
.afterClosed()
|
|
.subscribe((done: boolean | undefined) => {
|
|
if (!done) {
|
|
return;
|
|
}
|
|
this.selectedIds.clear();
|
|
this.fetch();
|
|
});
|
|
}
|
|
}
|