feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report
This commit is contained in:
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
|||||||
import { GLAccount } from 'app/shared/models/general.model';
|
import { GLAccount } from 'app/shared/models/general.model';
|
||||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create financial activity mapping component.
|
* Create financial activity mapping component.
|
||||||
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGlAccountData() {
|
setGlAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
switch (financialActivityId) {
|
this.glAccountData = accountOptionsForActivity(
|
||||||
case 100:
|
this.financialActivityData,
|
||||||
case 101:
|
financialActivityId,
|
||||||
case 102:
|
this.glAccountOptions
|
||||||
case 103:
|
);
|
||||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
|
||||||
break;
|
|
||||||
case 200:
|
|
||||||
case 201:
|
|
||||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
|
||||||
break;
|
|
||||||
case 300:
|
|
||||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
|||||||
import { GLAccount } from 'app/shared/models/general.model';
|
import { GLAccount } from 'app/shared/models/general.model';
|
||||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit financial activity mapping component.
|
* Edit financial activity mapping component.
|
||||||
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGLAccountData() {
|
setGLAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
switch (financialActivityId) {
|
this.glAccountData = accountOptionsForActivity(
|
||||||
case 100:
|
this.financialActivityData,
|
||||||
case 101:
|
financialActivityId,
|
||||||
case 102:
|
this.glAccountOptions
|
||||||
case 103:
|
);
|
||||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
|
||||||
break;
|
|
||||||
case 200:
|
|
||||||
case 201:
|
|
||||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
|
||||||
break;
|
|
||||||
case 300:
|
|
||||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL accounts a financial activity may be mapped to.
|
||||||
|
*
|
||||||
|
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
|
||||||
|
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
|
||||||
|
* the id instead, listing the ones they knew:
|
||||||
|
*
|
||||||
|
* case 100, 101, 102, 103 → asset
|
||||||
|
* case 200, 201 → liability
|
||||||
|
* case 300 → equity
|
||||||
|
*
|
||||||
|
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
|
||||||
|
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
|
||||||
|
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
|
||||||
|
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
|
||||||
|
* of step with the backend.
|
||||||
|
*
|
||||||
|
* @param activities the template's financialActivityOptions
|
||||||
|
* @param activityId the activity the user picked
|
||||||
|
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
|
||||||
|
*/
|
||||||
|
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
|
||||||
|
const activity = (activities || []).find((a) => a?.id === activityId);
|
||||||
|
const accountType = activity?.mappedGLAccountType;
|
||||||
|
if (!accountType || !glAccountOptions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
|
||||||
|
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
|
||||||
|
}
|
||||||
@@ -167,7 +167,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
|
<!-- 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. -->
|
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>
|
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dc-card">
|
<div class="dc-card">
|
||||||
@@ -220,9 +222,7 @@
|
|||||||
@if (ctrLinesLoading) {
|
@if (ctrLinesLoading) {
|
||||||
<div class="fiu-note">Loading the generated CTR lines…</div>
|
<div class="fiu-note">Loading the generated CTR lines…</div>
|
||||||
} @else if (ctrLinesError) {
|
} @else if (ctrLinesError) {
|
||||||
<div class="fiu-note fiu-note--warn">
|
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
|
|
||||||
</div>
|
|
||||||
} @else if (!ctrTxns.length) {
|
} @else if (!ctrTxns.length) {
|
||||||
<div class="fiu-note fiu-note--warn">
|
<div class="fiu-note fiu-note--warn">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
@@ -432,87 +432,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
|
||||||
<div class="fiu-form-meta">
|
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
|
||||||
<div class="fiu-form-title">
|
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
|
||||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
|
||||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
<div class="fiu-note">
|
||||||
</div>
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
|
<span>
|
||||||
their own register below. -->
|
Alerts are triaged in <strong>RBI › AML Rules Master › Flagged Transactions</strong>. What an
|
||||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
|
officer escalates there arrives here as an STR case.
|
||||||
<option value="">All statuses</option>
|
</span>
|
||||||
<option value="RAISED">Raised</option>
|
|
||||||
<option value="UNDER_REVIEW">Under Review</option>
|
|
||||||
<option value="ESCALATED">Escalated</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</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 }} — {{ alert.ruleDescription }}</td>
|
|
||||||
<td class="ta-r mono">₹{{ 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 ·
|
|
||||||
<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
|
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
|
||||||
evidence recorded at review time. -->
|
evidence recorded at review time. -->
|
||||||
@@ -590,7 +520,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (sc of amlStrCases; track sc.id) {
|
@for (sc of liveStrCases; track sc.id) {
|
||||||
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
||||||
<td>{{ sc.id }}</td>
|
<td>{{ sc.id }}</td>
|
||||||
<td>{{ sc.clientDisplayName }}</td>
|
<td>{{ sc.clientDisplayName }}</td>
|
||||||
@@ -607,13 +537,50 @@
|
|||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
|
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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) {
|
@if (selectedStrCase) {
|
||||||
<div class="str-case-detail">
|
<div class="str-case-detail">
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
|
|||||||
@@ -220,13 +220,29 @@ export class FiuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
sanctionsStrCases: any[] = [];
|
sanctionsStrCases: any[] = [];
|
||||||
sanctionsStrFilter = '';
|
sanctionsStrFilter = '';
|
||||||
amlAlertStatusFilter = '';
|
|
||||||
amlReviewNotes: Record<number, string> = {};
|
|
||||||
|
|
||||||
amlCtrReports: any[] = [];
|
amlCtrReports: any[] = [];
|
||||||
ctrGenerateMonth = '';
|
ctrGenerateMonth = '';
|
||||||
|
|
||||||
amlStrCases: any[] = [];
|
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;
|
selectedStrCase: any = null;
|
||||||
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||||||
concludeInvestigationNotes = '';
|
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
|
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
|
||||||
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
|
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
|
||||||
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
|
* another.
|
||||||
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
|
|
||||||
*/
|
*/
|
||||||
loadAmlAlerts(): void {
|
loadAmlAlerts(): void {
|
||||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||||
@@ -679,7 +694,8 @@ export class FiuComponent implements OnInit {
|
|||||||
this.ctrLinesLoading = false;
|
this.ctrLinesLoading = false;
|
||||||
this.ctrTxns = [];
|
this.ctrTxns = [];
|
||||||
this.ctrLinesError =
|
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`);
|
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 {
|
generateCtrReport(): void {
|
||||||
if (!this.ctrGenerateMonth) {
|
if (!this.ctrGenerateMonth) {
|
||||||
this.toast('Pick a reporting month first');
|
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
|
* 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}.
|
* 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. */
|
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
|
||||||
get amlWhitelistedAlerts(): any[] {
|
get amlWhitelistedAlerts(): any[] {
|
||||||
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
||||||
@@ -1707,7 +1675,9 @@ export class FiuComponent implements OnInit {
|
|||||||
private writeLiveStrXml(cases: any[], filename: string): void {
|
private writeLiveStrXml(cases: any[], filename: string): void {
|
||||||
const xml: string[] = [];
|
const xml: string[] = [];
|
||||||
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
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)}" />`);
|
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
|
||||||
if (this.principalOfficerName) {
|
if (this.principalOfficerName) {
|
||||||
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
|
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
|
||||||
|
|||||||
+5
-3
@@ -57,8 +57,8 @@ export interface AmlBulkReviewData {
|
|||||||
</h2>
|
</h2>
|
||||||
<mat-dialog-content>
|
<mat-dialog-content>
|
||||||
<p class="bk-intro">
|
<p class="bk-intro">
|
||||||
The same decision, reason and evidence are recorded against each alert below. Every one stays
|
The same decision, reason and evidence are recorded against each alert below. Every one stays individually
|
||||||
individually auditable.
|
auditable.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="bk-list">
|
<div class="bk-list">
|
||||||
@@ -218,7 +218,9 @@ export class AmlBulkReviewDialogComponent {
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
||||||
constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) {
|
data: AmlBulkReviewData = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
|
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
|
||||||
evidenceRef: [
|
evidenceRef: [
|
||||||
|
|||||||
+149
-24
@@ -36,6 +36,17 @@
|
|||||||
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
||||||
<mat-icon>refresh</mat-icon>
|
<mat-icon>refresh</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Exports what is on screen, not the whole feed: the workbook has to reconcile with the report it came from. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="export-btn"
|
||||||
|
[disabled]="exporting || !alerts.data.length"
|
||||||
|
[matTooltip]="alerts.data.length ? 'Download the alerts currently in view as Excel' : 'Nothing in view to export'"
|
||||||
|
(click)="exportExcel()"
|
||||||
|
>
|
||||||
|
<mat-icon>file_download</mat-icon>
|
||||||
|
{{ exporting ? 'Preparing…' : 'Excel' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,6 +55,52 @@
|
|||||||
<span>Could not reach the alert feed. Retrying automatically.</span>
|
<span>Could not reach the alert feed. Retrying automatically.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Narrow the feed to one day. The feed accumulates, so without this an officer asking "what did we decide today"
|
||||||
|
reads past a week of older alerts to find out. Composes with the status chips below: Today + Escalated answers
|
||||||
|
"what did we mark suspicious today". -->
|
||||||
|
<div class="status-filters">
|
||||||
|
<span class="status-filters-label">Period</span>
|
||||||
|
@for (m of periodModes; track m.key) {
|
||||||
|
<button type="button" class="status-chip" [class.active]="periodMode === m.key" (click)="setPeriodMode(m.key)">
|
||||||
|
{{ m.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- One control per mode, so the picker always matches the granularity chosen. A week is picked by naming any day
|
||||||
|
inside it; the resolved Monday-to-Sunday span is printed beside it so the officer sees what was selected. -->
|
||||||
|
@if (periodMode === 'day') {
|
||||||
|
<input type="date" class="day-input" [(ngModel)]="filterDay" (ngModelChange)="onPeriodChange()" aria-label="Day" />
|
||||||
|
}
|
||||||
|
@if (periodMode === 'week') {
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="day-input"
|
||||||
|
[(ngModel)]="filterWeek"
|
||||||
|
(ngModelChange)="onPeriodChange()"
|
||||||
|
aria-label="Any day in the week"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (periodMode === 'month') {
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
class="day-input"
|
||||||
|
[(ngModel)]="filterMonth"
|
||||||
|
(ngModelChange)="onPeriodChange()"
|
||||||
|
aria-label="Month"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (periodMode === 'year') {
|
||||||
|
<select class="day-input" [(ngModel)]="filterYear" (ngModelChange)="onPeriodChange()" aria-label="Year">
|
||||||
|
@for (y of availableYears; track y) {
|
||||||
|
<option [value]="y">{{ y }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
@if (periodLabel) {
|
||||||
|
<span class="period-resolved">{{ periodLabel }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
||||||
<div class="status-filters">
|
<div class="status-filters">
|
||||||
<span class="status-filters-label">Status</span>
|
<span class="status-filters-label">Status</span>
|
||||||
@@ -60,6 +117,34 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
|
||||||
|
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
|
||||||
|
below it can wait. Ranked over the period and status already chosen. -->
|
||||||
|
<div class="status-filters">
|
||||||
|
<span class="status-filters-label">Top rules</span>
|
||||||
|
@for (n of topRuleOptions; track n) {
|
||||||
|
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
|
||||||
|
{{ n === null ? 'All rules' : 'Top ' + n }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (topRules !== null) {
|
||||||
|
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Which rules are driving the queue, and by how much. Ranking without the counts narrows the table and leaves the
|
||||||
|
officer to work out what it kept. -->
|
||||||
|
@if (ruleFrequency.length > 1) {
|
||||||
|
<div class="rule-freq">
|
||||||
|
@for (r of ruleFrequency; track r.code) {
|
||||||
|
<span class="rule-freq-item">
|
||||||
|
{{ r.code }}
|
||||||
|
<span class="rule-freq-n">{{ r.count }}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Severity mix of what the table is showing. Each tile narrows the table to its own band;
|
<!-- Severity mix of what the table is showing. Each tile narrows the table to its own band;
|
||||||
clicking the one already in force clears it, as does Total Flagged. -->
|
clicking the one already in force clears it, as does Total Flagged. -->
|
||||||
<div class="risk-summary">
|
<div class="risk-summary">
|
||||||
@@ -103,7 +188,9 @@
|
|||||||
@if (selectedCount > 0) {
|
@if (selectedCount > 0) {
|
||||||
<div class="bulk-bar">
|
<div class="bulk-bar">
|
||||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||||
<span class="bulk-note">Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span>
|
<span class="bulk-note"
|
||||||
|
>Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span
|
||||||
|
>
|
||||||
<span class="bulk-spacer"></span>
|
<span class="bulk-spacer"></span>
|
||||||
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
|
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
|
||||||
<button
|
<button
|
||||||
@@ -227,7 +314,10 @@
|
|||||||
@if (isExpanded(alert)) {
|
@if (isExpanded(alert)) {
|
||||||
<div class="detail-inner">
|
<div class="detail-inner">
|
||||||
<div class="detail-head">
|
<div class="detail-head">
|
||||||
<span>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }}</span>
|
<span
|
||||||
|
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
|
||||||
|
{{ amount(alert) }}</span
|
||||||
|
>
|
||||||
@if (selectableRulesOf(alert).length > 1) {
|
@if (selectableRulesOf(alert).length > 1) {
|
||||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||||
@@ -273,15 +363,25 @@
|
|||||||
"
|
"
|
||||||
matTooltipClass="evidence-tooltip"
|
matTooltipClass="evidence-tooltip"
|
||||||
>
|
>
|
||||||
{{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }}
|
{{
|
||||||
|
!rule.directlyDecided
|
||||||
|
? 'remove_circle_outline'
|
||||||
|
: rule.disposition === 'ESCALATE'
|
||||||
|
? 'report'
|
||||||
|
: 'verified'
|
||||||
|
}}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
}
|
}
|
||||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{ severityLabel(rule.riskCategory) }}</span>
|
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
|
||||||
|
severityLabel(rule.riskCategory)
|
||||||
|
}}</span>
|
||||||
<div class="detail-rule-text">
|
<div class="detail-rule-text">
|
||||||
<span class="detail-rule-code">
|
<span class="detail-rule-code">
|
||||||
{{ rule.ruleCode }}
|
{{ rule.ruleCode }}
|
||||||
@if (rule.governing) {
|
@if (rule.governing) {
|
||||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule">governing</span>
|
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
|
||||||
|
>governing</span
|
||||||
|
>
|
||||||
}
|
}
|
||||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||||
transaction being unreported. -->
|
transaction being unreported. -->
|
||||||
@@ -317,10 +417,17 @@
|
|||||||
clearing five of six rules leaves the case open on the sixth. -->
|
clearing five of six rules leaves the case open on the sixth. -->
|
||||||
@if (selectedRuleCount > 0) {
|
@if (selectedRuleCount > 0) {
|
||||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||||
<span class="rule-action-count">{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span>
|
<span class="rule-action-count"
|
||||||
|
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
|
||||||
|
>
|
||||||
<span class="bulk-spacer"></span>
|
<span class="bulk-spacer"></span>
|
||||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||||
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
(click)="reviewSelectedRules(alert, 'WHITELIST')"
|
||||||
|
>
|
||||||
Whitelist
|
Whitelist
|
||||||
</button>
|
</button>
|
||||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||||
@@ -384,23 +491,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #reviewed>
|
<ng-template #reviewed>
|
||||||
<div class="reviewed-cell">
|
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
|
||||||
<span class="reviewed-by">
|
from anyone but the officer who has it, and this says who to ask. -->
|
||||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
@if (heldByAnother(alert)) {
|
||||||
</span>
|
<div class="reviewed-cell">
|
||||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
<mat-icon
|
||||||
<mat-icon
|
class="held-icon"
|
||||||
*ngIf="alert.reviewNotes"
|
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
|
||||||
class="reviewed-evidence-icon"
|
>
|
||||||
[matTooltip]="alert.reviewNotes"
|
lock
|
||||||
matTooltipClass="evidence-tooltip"
|
</mat-icon>
|
||||||
>
|
<span class="held-by">with {{ heldBy(alert) }}</span>
|
||||||
description
|
</div>
|
||||||
</mat-icon>
|
} @else {
|
||||||
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
<div class="reviewed-cell">
|
||||||
→ STR
|
<span class="reviewed-by">
|
||||||
</span>
|
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||||
</div>
|
</span>
|
||||||
|
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||||
|
<mat-icon
|
||||||
|
*ngIf="alert.reviewNotes"
|
||||||
|
class="reviewed-evidence-icon"
|
||||||
|
[matTooltip]="alert.reviewNotes"
|
||||||
|
matTooltipClass="evidence-tooltip"
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</mat-icon>
|
||||||
|
<span
|
||||||
|
*ngIf="alert.status === 'ESCALATED'"
|
||||||
|
class="str-routed"
|
||||||
|
matTooltip="An STR case is open for this alert"
|
||||||
|
>
|
||||||
|
→ STR
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
+109
@@ -902,3 +902,112 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read
|
||||||
|
as one control strip rather than a form field dropped above a set of buttons. */
|
||||||
|
.day-input {
|
||||||
|
padding: 5px 11px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1a237e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a237e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer
|
||||||
|
has no way to tell which Monday-to-Sunday span they are looking at. */
|
||||||
|
.period-resolved {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather
|
||||||
|
than changing what is on screen. */
|
||||||
|
.export-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 13px;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #dcfce7;
|
||||||
|
border-color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */
|
||||||
|
.held-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.held-by {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth
|
||||||
|
filter competing with the three above it. */
|
||||||
|
.rule-freq {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-freq-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-freq-n {
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|||||||
+373
-11
@@ -17,15 +17,21 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Subject, timer, of } from 'rxjs';
|
import { Subject, timer, of } from 'rxjs';
|
||||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { AmlService } from '../../../aml/aml.service';
|
import { AmlService } from '../../../aml/aml.service';
|
||||||
import { AlertService } from '../../../core/alert/alert.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 { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
||||||
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-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.
|
* Live feed of transactions the AML rule engine has flagged.
|
||||||
@@ -43,6 +49,7 @@ import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
@@ -58,6 +65,10 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
private amlService = inject(AmlService);
|
private amlService = inject(AmlService);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private alertService = inject(AlertService);
|
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>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
|
/** 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. */
|
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
|
||||||
expandedId: number | null = null;
|
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 {
|
toggleExpanded(alert: any): void {
|
||||||
this.expandedId = this.expandedId === alert.id ? null : alert.id;
|
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.
|
// 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
|
* 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.
|
* 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) {
|
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
|
||||||
return alert.matchedRules;
|
return alert.matchedRules;
|
||||||
}
|
}
|
||||||
@@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
readonly statusFilters: { key: string; label: string }[] = [
|
readonly statusFilters: { key: string; label: string }[] = [
|
||||||
{ key: '', label: 'All alerts' },
|
{ key: '', label: 'All alerts' },
|
||||||
{ key: 'RAISED', label: 'Raised' },
|
{ 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: 'ESCALATED', label: 'Escalated' },
|
||||||
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
||||||
];
|
];
|
||||||
activeStatus = '';
|
activeStatus = '';
|
||||||
private statusCounts: Record<string, number> = {};
|
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
|
* 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
|
* 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.
|
* it — doing that on the first pass left pageSize at 0 and emptied the table.
|
||||||
*/
|
*/
|
||||||
private applyStatusFilter(resetPage = false): void {
|
private applyStatusFilter(resetPage = false): void {
|
||||||
const byStatus = this.activeStatus
|
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
|
||||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
|
||||||
: this.allAlerts;
|
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||||
this.alerts.data =
|
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);
|
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) {
|
if (!this.alerts.paginator && this.paginator) {
|
||||||
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
|
* 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.
|
* 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 {
|
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 {
|
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) {
|
for (const s of this.statusFilters) {
|
||||||
if (s.key) {
|
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.
|
* opening a second STR case — this gate is what prevents it.
|
||||||
*/
|
*/
|
||||||
canReview(alert: any): boolean {
|
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 {
|
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||||
|
|||||||
+6
-1
@@ -196,7 +196,12 @@ export class AmlReviewAlertDialogComponent {
|
|||||||
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
|
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
|
||||||
// requirements are the same either way, which is why this dialog serves both.
|
// requirements are the same either way, which is why this dialog serves both.
|
||||||
const request = this.data.ruleCodes?.length
|
const request = this.data.ruleCodes?.length
|
||||||
? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes)
|
? this.amlService.reviewAlertRules(
|
||||||
|
this.data.alert.id,
|
||||||
|
this.data.ruleCodes,
|
||||||
|
this.data.disposition,
|
||||||
|
this.composedNotes
|
||||||
|
)
|
||||||
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
|
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
|
||||||
request.subscribe({
|
request.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High
|
|||||||
</td>
|
</td>
|
||||||
<td class="rca-reason">{{ r.reason }}</td>
|
<td class="rca-reason">{{ r.reason }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{ r.status }}</span>
|
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{
|
||||||
|
r.status
|
||||||
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ r.makerUsername }}</td>
|
<td>{{ r.makerUsername }}</td>
|
||||||
<td class="mono">{{ r.createdDate }}</td>
|
<td class="mono">{{ r.createdDate }}</td>
|
||||||
@@ -250,7 +252,12 @@ export class RiskChangeApprovalsComponent implements OnInit {
|
|||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
rows: any[] = [];
|
rows: any[] = [];
|
||||||
readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', ''];
|
readonly statuses = [
|
||||||
|
'PENDING',
|
||||||
|
'APPROVED',
|
||||||
|
'REJECTED',
|
||||||
|
''
|
||||||
|
];
|
||||||
selectedStatus = 'PENDING';
|
selectedStatus = 'PENDING';
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -363,11 +370,15 @@ export class RejectRiskChangeDialogComponent {
|
|||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
|
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
|
||||||
|
|
||||||
|
data: { request: any } = inject(MAT_DIALOG_DATA);
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
busy = false;
|
busy = false;
|
||||||
|
|
||||||
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
|
constructor() {
|
||||||
this.form = this.fb.group({ rejectionReason: ['', Validators.required] });
|
this.form = this.fb.group({ rejectionReason: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
] });
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
|
|||||||
@@ -264,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).subscribe((results) => {
|
).subscribe((results) => {
|
||||||
const byClient = new Map(results.map((r) => [r.id, r]));
|
const byClient = new Map(
|
||||||
|
results.map((r) => [
|
||||||
|
r.id,
|
||||||
|
r
|
||||||
|
])
|
||||||
|
);
|
||||||
this.liveAlerts = rows.map((r) => {
|
this.liveAlerts = rows.map((r) => {
|
||||||
const info = byClient.get(r.clientId);
|
const info = byClient.get(r.clientId);
|
||||||
return {
|
return {
|
||||||
@@ -470,24 +475,24 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Its own section, and above "no change needed", because it is the opposite of that. An unrated customer
|
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
|
||||||
// whose money is moving has no rating for the behaviour to be compared against, so nothing here can say
|
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
|
||||||
// whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the
|
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
|
||||||
// one action the report was asking for.
|
// about buried the one action the report was asking for.
|
||||||
key: 'unrated',
|
key: 'unrated',
|
||||||
title: 'Rating needed',
|
title: 'Update risk',
|
||||||
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
|
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
|
||||||
tone: 'up',
|
tone: 'up',
|
||||||
rows: rest.filter((c) => !c.currentRisk)
|
rows: rest.filter((c) => !c.currentRisk)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Everything an alert put on the report that the policy will not move. "No change needed" read as though
|
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
|
||||||
// these customers were fine, which is not what it means: a High-rated customer with four open alerts is on
|
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
|
||||||
// this list, and the reason is that the conditions for lowering the rating are not met - not that there is
|
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
|
||||||
// nothing to look at. The reason on each row says which condition fell short.
|
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
|
||||||
key: 'same',
|
key: 'same',
|
||||||
title: 'Rating holds — no change this period',
|
title: 'Watching — not enough to act on',
|
||||||
hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.',
|
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
|
||||||
tone: 'hold',
|
tone: 'hold',
|
||||||
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
|
|||||||
|
|
||||||
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What has to have been seen before this screen's own reckoning will argue for lowering a rating.
|
||||||
|
*
|
||||||
|
* The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts,
|
||||||
|
* and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest,
|
||||||
|
* because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on
|
||||||
|
* amounts alone let three small deposits in one month argue for taking a customer off Medium.
|
||||||
|
*/
|
||||||
|
const MIN_ALERTS_TO_LOWER = 5;
|
||||||
|
const MIN_MONTHS_TO_LOWER = 3;
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -157,17 +168,26 @@ function recommendRating(
|
|||||||
largestAmount: number,
|
largestAmount: number,
|
||||||
totalAmount: number,
|
totalAmount: number,
|
||||||
escalated: boolean,
|
escalated: boolean,
|
||||||
ruleCodes: string[]
|
ruleCodes: string[],
|
||||||
|
alertCount: number,
|
||||||
|
distinctMonths: number
|
||||||
): { risk: RiskLevel; reason: string } {
|
): { risk: RiskLevel; reason: string } {
|
||||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||||
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
|
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
|
||||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||||
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
|
||||||
|
// Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so
|
||||||
|
// bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over
|
||||||
|
// enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single
|
||||||
|
// month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only
|
||||||
|
// three times over.
|
||||||
|
const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER;
|
||||||
|
const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||||
|
|
||||||
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
||||||
// only where nothing weightier holds it up.
|
// only where nothing weightier holds it up.
|
||||||
let risk: RiskLevel;
|
let risk: RiskLevel;
|
||||||
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
|
||||||
if (escalated) {
|
if (escalated) {
|
||||||
risk = 'HIGH';
|
risk = 'HIGH';
|
||||||
basis = 'escalated';
|
basis = 'escalated';
|
||||||
@@ -177,6 +197,12 @@ function recommendRating(
|
|||||||
} else if (material) {
|
} else if (material) {
|
||||||
risk = implied;
|
risk = implied;
|
||||||
basis = 'material';
|
basis = 'material';
|
||||||
|
} else if (!enoughActivity) {
|
||||||
|
// Too little observed to say anything about the rating either way, so it stands. Stating this rather than
|
||||||
|
// quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin,
|
||||||
|
// not that the customer was examined and found unremarkable.
|
||||||
|
risk = currentRisk ?? implied;
|
||||||
|
basis = 'thin';
|
||||||
} else {
|
} else {
|
||||||
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||||
basis = small ? 'small' : 'moderate';
|
basis = small ? 'small' : 'moderate';
|
||||||
@@ -220,6 +246,27 @@ function recommendRating(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (basis === 'thin') {
|
||||||
|
// A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling
|
||||||
|
// them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues
|
||||||
|
// against it in the same row. The thin-period test is about lowering a rating that exists.
|
||||||
|
if (!currentRisk) {
|
||||||
|
return {
|
||||||
|
risk,
|
||||||
|
reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${
|
||||||
|
alertCount === 1 ? '' : 's'
|
||||||
|
}. Rate the customer; behaviour can only be judged against a rating.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether
|
||||||
|
// the customer needs watching for another month or another five.
|
||||||
|
const shortOn =
|
||||||
|
alertCount < MIN_ALERTS_TO_LOWER
|
||||||
|
? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed`
|
||||||
|
: `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`;
|
||||||
|
return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` };
|
||||||
|
}
|
||||||
|
|
||||||
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
|
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
|
||||||
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
|
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
|
||||||
return {
|
return {
|
||||||
@@ -345,7 +392,9 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
|
|||||||
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
||||||
const raw = windows[clientId];
|
const raw = windows[clientId];
|
||||||
const staleWindow =
|
const staleWindow =
|
||||||
raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
raw?.riskCategoryAtReview != null &&
|
||||||
|
currentRisk != null &&
|
||||||
|
RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
||||||
const window = staleWindow ? undefined : raw;
|
const window = staleWindow ? undefined : raw;
|
||||||
const recommendation = window?.suggestedDirection
|
const recommendation = window?.suggestedDirection
|
||||||
? {
|
? {
|
||||||
@@ -358,7 +407,11 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
|
|||||||
largestAmount,
|
largestAmount,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
escalated,
|
escalated,
|
||||||
alerts.map((a) => a.ruleCode)
|
alerts.map((a) => a.ruleCode),
|
||||||
|
alerts.length,
|
||||||
|
// Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one
|
||||||
|
// week is a busy fortnight, not five months of steady conduct.
|
||||||
|
new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size
|
||||||
);
|
);
|
||||||
|
|
||||||
// Where the customer carries no rating there is nothing to move, so an
|
// Where the customer carries no rating there is nothing to move, so an
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260916',
|
'version': '260918',
|
||||||
'hash': 'c01615ee'
|
'hash': '0cef1b5a'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user