/** * 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(); /** 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([]); displayedColumns: string[] = [ 'select', 'raisedOn', 'transaction', 'client', 'rule', 'amount', 'status', 'actions' ]; /** * Alerts ticked for a single disposition across several transactions. * *

* 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(); /** 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. * *

* 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. * *

* 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. * *

* 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. * *

* 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; /** 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. * *

* 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. * *

* 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(); isRuleSelected(rule: any): boolean { return this.selectedRuleCodes.has(rule.ruleCode); } /** * Whether this rule is the officer's to decide. * *

* 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. * *

* 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 = {}; /** * 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. * *

* 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. * *

* 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. * *

* 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 | null { if (this.topRules === null) { return null; } const counts = new Map(); 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. * *

* 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(); 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(); 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(); /** Alerts that arrived after the officer started watching — highlighted until acknowledged. */ newAlertIds = new Set(); @ViewChild(MatPaginator) paginator!: MatPaginator; /** * Binds the paginator as soon as the view exists. * *

* 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. * *

* 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. * *

* 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 { 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. * *

* 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. * *

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