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
+53 -86
View File
@@ -167,7 +167,9 @@
</div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<div class="dc-meta">
Due: <strong>{{ ctrDueDateLabel }}</strong>
</div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -220,9 +222,7 @@
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -432,87 +432,17 @@
}
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
their own register below. -->
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="ESCALATED">Escalated</option>
</select>
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
<div class="fiu-note">
<fa-icon icon="info-circle"></fa-icon>
<span>
Alerts are triaged in <strong>RBI &rsaquo; AML Rules Master &rsaquo; Flagged Transactions</strong>. What an
officer escalates there arrives here as an STR case.
</span>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
evidence recorded at review time. -->
@@ -590,7 +520,7 @@
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
@for (sc of liveStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
@@ -607,13 +537,50 @@
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Filed cases are finished work, not outstanding work, so they sit apart from the live queue — but they are
the bank's record of what went to FIU-IND and against which reference, so they stay visible. -->
@if (filedStrCases.length) {
<div class="fiu-form-meta" style="margin-top: 18px">
<div class="fiu-form-title">
<h3>Filed with FIU-IND</h3>
<p>Reported and closed — kept for the record, with the reference the filing was made under.</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Conclusion</th>
<th>Filed On</th>
<th>FIU Reference</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of filedStrCases; track sc.id) {
<tr>
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}</td>
<td class="mono">{{ sc.strReferenceNo || '—' }}</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">View</button></td>
</tr>
}
</tbody>
</table>
</div>
}
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
+26 -56
View File
@@ -220,13 +220,29 @@ export class FiuComponent implements OnInit {
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
/**
* The cases still carrying work, which is what "Live" means here.
*
* <p>
* A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
* is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
* one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
* the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
*/
get liveStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status !== 'FILED');
}
/** Cases already filed with FIU-IND, kept for the record rather than as work. */
get filedStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status === 'FILED');
}
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
@@ -633,10 +649,9 @@ export class FiuComponent implements OnInit {
}
/**
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
* another.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
@@ -679,7 +694,8 @@ export class FiuComponent implements OnInit {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
err?.error?.defaultUserMessage ||
'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
@@ -779,48 +795,6 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
});
}
escalateAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
},
error: this.apiError(`Escalating alert #${alert.id}`)
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
@@ -1039,12 +1013,6 @@ export class FiuComponent implements OnInit {
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
@@ -1707,7 +1675,9 @@ export class FiuComponent implements OnInit {
private writeLiveStrXml(cases: any[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(
`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`
);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);