feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report

This commit is contained in:
2026-09-18 10:32:47 +05:30
parent 0cef1b5a50
commit f9b73a4930
14 changed files with 863 additions and 234 deletions
@@ -17,15 +17,21 @@ 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.
@@ -43,6 +49,7 @@ import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component
standalone: true,
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
@@ -58,6 +65,10 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
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. */
@@ -146,7 +157,15 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
/** 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 = this.expandedId === alert.id ? null : alert.id;
// Ticks belong to the alert they were made on, not to whichever pane is open next.
@@ -164,7 +183,9 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* 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 }[] {
matchedRules(
alert: any
): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
return alert.matchedRules;
}
@@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
{ key: 'UNDER_REVIEW', label: 'Under review' },
// 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
@@ -530,11 +764,16 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* it — doing that on the first pass left pageSize at 0 and emptied the table.
*/
private applyStatusFilter(resetPage = false): void {
const byStatus = this.activeStatus
? this.allAlerts.filter((a) => a.status === this.activeStatus)
: this.allAlerts;
this.alerts.data =
// 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) => a.status === 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;
}
@@ -573,15 +812,119 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* 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 {
return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length;
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length;
}
private recomputeStatusCounts(): void {
this.statusCounts = { '': this.allAlerts.length };
// 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] = this.allAlerts.filter((a) => a.status === s.key).length;
this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length;
}
}
}
@@ -633,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
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 {