feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register

This commit is contained in:
2026-09-16 12:51:24 +05:30
parent 0fbf6ec58c
commit 0cef1b5a50
26 changed files with 3309 additions and 159 deletions
+45
View File
@@ -34,6 +34,17 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
/**
* Decides named rules within one alert, leaving its other rules open.
*
* <p>
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
* leaves the alert open on the sixth.
*/
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
}
// ═══ Recurring-alert review queue ═══
/**
@@ -141,4 +152,38 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
// ═══ Risk-category changes (maker-checker) ═══
//
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
// it moves only once a checker agrees rather than on one officer's say-so.
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
}
getRiskChangeRequests(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/risk-change-requests', { params });
}
approveRiskChange(requestId: number): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
}
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
}
/**
* The latest behaviour window for each customer, as the nightly job computed it.
*
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
* transactions that happened to trip a rule.
*/
getBehaviourWindows(): Observable<any[]> {
return this.http.get<any[]>('/risk-review/behaviour-windows');
}
}
@@ -0,0 +1,147 @@
<!--
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/.
-->
<div class="bor-root">
<div class="bor-head">
<div class="bor-head-left">
<fa-icon icon="gavel" class="bor-head-ic"></fa-icon>
<div>
<h2 class="bor-head-t">Board of Resolution Register — Share Capital</h2>
<p class="bor-head-s">
Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
record sort to the top.
</p>
</div>
</div>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
</div>
@if (loading) {
<div class="bor-note">Loading the register…</div>
} @else if (loadError) {
<div class="bor-note bor-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ loadError }}</div>
}
@if (!loading && !loadError) {
<!-- Counted over the whole register, not the filtered view: it is a standing figure the
secretary acts on, and hiding it behind a filter would hide the problem. -->
@if (missingResolutionCount > 0) {
<div class="bor-note bor-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{
missingResolutionCount === 1 ? 'ies' : 'y'
}}
no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar.
</div>
}
<div class="bor-kpis">
<div class="bor-kpi">
<div class="bor-kpi-n">{{ filtered.length | number }}</div>
<div class="bor-kpi-l">Share Accounts</div>
</div>
<div class="bor-kpi bor-kpi--blue">
<div class="bor-kpi-n">{{ distinctResolutions | number }}</div>
<div class="bor-kpi-l">Resolutions</div>
</div>
<div class="bor-kpi bor-kpi--green">
<div class="bor-kpi-n">{{ totalShares | number }}</div>
<div class="bor-kpi-l">Shares Allotted</div>
</div>
<div class="bor-kpi bor-kpi--amber">
<div class="bor-kpi-n">&#8377;{{ totalValue | number: '1.0-0' }}</div>
<div class="bor-kpi-l">Share Value</div>
</div>
</div>
<div class="bor-filters">
<div class="bor-filter">
<label>Resolution No.</label>
<input class="bor-inp" type="text" [(ngModel)]="filterResolution" placeholder="e.g. BOR/2026-27/001" />
</div>
<div class="bor-filter">
<label>Branch</label>
<select class="bor-inp" [(ngModel)]="filterBranch">
<option value="">All branches</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="bor-filter">
<label>Status</label>
<select class="bor-inp" [(ngModel)]="filterStatus">
<option [ngValue]="''">Any status</option>
@for (s of statuses; track s) {
<option [ngValue]="s">{{ statusText(s) }}</option>
}
</select>
</div>
<div class="bor-filter bor-filter--grow">
<label>Search</label>
<input class="bor-inp" type="text" [(ngModel)]="search" placeholder="Member name, share A/c or CIF…" />
</div>
<label class="bor-check">
<input type="checkbox" [(ngModel)]="missingOnly" />
No resolution only
</label>
<button class="bor-btn" type="button" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
<div class="bor-table-wrap">
<table class="bor-table">
<thead>
<tr>
<th>Resolution No.</th>
<th>Resolution Date</th>
<th>Share A/c No.</th>
<th>CIF No.</th>
<th>Member Name</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Shares</th>
<th class="num">Share Value</th>
<th class="num">Tranches</th>
<th>Status</th>
<th>Activated On</th>
</tr>
</thead>
<tbody>
@for (r of filtered; track r.shareAccountId) {
<tr [class.bor-row--missing]="!r.resolutionNumber">
<td>
@if (r.resolutionNumber) {
<strong class="bor-res">{{ r.resolutionNumber }}</strong>
} @else {
<span class="bor-missing">Not recorded</span>
}
</td>
<td class="mono">{{ r.resolutionDate || '—' }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="mono">{{ r.cifNumber || '—' }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.officeName || '—' }}</td>
<td>{{ r.productName || '—' }}</td>
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="bor-empty">No share account matches the filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,313 @@
/**
* 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/.
*/
.bor-root {
padding: 20px 24px 40px;
background: #f8fafc;
min-height: 100%;
}
.bor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.bor-head-left {
display: flex;
align-items: flex-start;
gap: 12px;
}
.bor-head-ic {
font-size: 26px;
color: #0f4f3c;
margin-top: 2px;
}
.bor-head-t {
margin: 0 0 3px;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.bor-head-s {
margin: 0;
max-width: 780px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.bor-note {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 11px 14px;
margin-bottom: 14px;
border: 1px solid #bfdbfe;
border-radius: 9px;
background: #eff6ff;
color: #1e3a8a;
font-size: 12.5px;
line-height: 1.55;
fa-icon {
flex: 0 0 auto;
margin-top: 1px;
}
/* An allotment with no resolution behind it is the finding, not a detail. */
&--warn {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
fa-icon {
color: #d97706;
}
}
}
.bor-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.bor-kpi {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #0f4f3c;
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: #2563eb;
}
&--green {
border-left-color: #15803d;
}
&--amber {
border-left-color: #d97706;
}
}
.bor-kpi-n {
font-size: 22px;
font-weight: 700;
color: #1e293b;
line-height: 1.2;
}
.bor-kpi-l {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.bor-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
}
.bor-filter {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&--grow {
flex: 1 1 240px;
}
}
.bor-inp {
height: 34px;
min-width: 170px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 7px;
font: inherit;
font-size: 13px;
color: #1e293b;
background: #fff;
&:focus {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
}
.bor-check {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
font-size: 12.5px;
color: #334155;
cursor: pointer;
}
.bor-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 7px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover:not(:disabled) {
background: #f1f5f9;
}
&:disabled {
opacity: 0.55;
cursor: default;
}
&--xls {
border-color: #15803d;
background: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
}
}
}
.bor-table-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow-x: auto;
}
.bor-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 11px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: #334155;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.mono {
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover {
background: #f8fafc;
}
}
/* The row an auditor is looking for, marked in the table as well as counted above it. */
.bor-row--missing {
background: #fffbeb;
}
.bor-res {
color: #0f4f3c;
font-family: 'DM Mono', monospace;
}
.bor-missing {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 700;
}
.bor-status {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--active {
background: #dcfce7;
color: #15803d;
}
&--submitted,
&--approved {
background: #fef3c7;
color: #b45309;
}
&--rejected,
&--closed {
background: #fee2e2;
color: #b91c1c;
}
}
.bor-empty {
padding: 30px;
text-align: center;
color: #94a3b8;
}
@@ -0,0 +1,221 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { exportWorkbook } from '../cashier/cashier-export';
import { BorRegisterRow, BorReportService } from './bor-report.service';
/** m_share_account.status_enum, as the register labels it. */
const STATUS_LABEL: Record<number, string> = {
100: 'Submitted',
200: 'Approved',
300: 'Active',
400: 'Rejected',
500: 'Closed',
600: 'Inactive'
};
/**
* The Board of Resolution register for share capital.
*
* A co-operative allots shares only under a board resolution, and this is the list the Registrar or
* an auditor asks for: every member's holding against the resolution that authorised it. The
* account with no resolution recorded is the point of the register, so it sorts to the top and is
* counted on its own rather than being left to be noticed.
*/
@Component({
selector: 'mifosx-bor-report',
templateUrl: './bor-report.component.html',
styleUrls: ['./bor-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class BorReportComponent implements OnInit {
private borService = inject(BorReportService);
readonly statusLabel = STATUS_LABEL;
rows: BorRegisterRow[] = [];
loading = false;
loadError = '';
/** '' = every status. */
filterStatus: number | '' = '';
filterBranch = '';
filterResolution = '';
search = '';
/** Only the accounts with no resolution on file — the ones that need chasing. */
missingOnly = false;
exporting = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.borService.getRegister().subscribe({
next: (rows) => {
this.loading = false;
this.rows = rows;
},
error: (err: any) => {
this.loading = false;
this.rows = [];
this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
}
});
}
/** Branches present in the data, so the filter offers only what can actually be selected. */
get branches(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get statuses(): number[] {
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
}
get filtered(): BorRegisterRow[] {
const needle = this.search.trim().toLowerCase();
const resolutionNeedle = this.filterResolution.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
return false;
}
if (this.filterBranch && r.officeName !== this.filterBranch) {
return false;
}
if (this.missingOnly && r.resolutionNumber) {
return false;
}
if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
return false;
}
if (
needle &&
!(
r.clientName.toLowerCase().includes(needle) ||
r.accountNo.toLowerCase().includes(needle) ||
r.cifNumber.toLowerCase().includes(needle)
)
) {
return false;
}
return true;
});
}
get totalShares(): number {
return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
}
get totalValue(): number {
return this.filtered.reduce((s, r) => s + r.shareValue, 0);
}
/** How many resolutions the filtered holdings were authorised under. */
get distinctResolutions(): number {
return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
}
get missingResolutionCount(): number {
return this.rows.filter((r) => !r.resolutionNumber).length;
}
statusText(statusEnum: number): string {
return STATUS_LABEL[statusEnum] || '—';
}
statusClass(statusEnum: number): string {
return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
}
resetFilters(): void {
this.filterStatus = '';
this.filterBranch = '';
this.filterResolution = '';
this.search = '';
this.missingOnly = false;
}
async exportExcel(): Promise<void> {
this.exporting = true;
try {
const rows = this.filtered;
await exportWorkbook(
[
{
sheetName: 'BOR Register',
title: 'Board of Resolution Register — Share Capital',
subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
headers: [
'Resolution No.',
'Resolution Date',
'Share A/c No.',
'CIF No.',
'Member Name',
'Branch',
'Product',
'Shares',
'Unit Price',
'Share Value',
'Tranches',
'Status',
'Activated On'
],
widths: [
20,
16,
18,
16,
30,
18,
18,
10,
12,
14,
10,
12,
14
],
rows: rows.map((r) => [
// Named rather than left blank, because an empty cell in a filing reads as an
// oversight in the export rather than a gap in the register.
r.resolutionNumber || 'NOT RECORDED',
r.resolutionDate || '—',
r.accountNo,
r.cifNumber,
r.clientName,
r.officeName,
r.productName,
r.approvedShares,
r.unitPrice,
r.shareValue,
r.trancheCount,
this.statusText(r.statusEnum),
r.activatedDate || '—'
])
}
],
'BOR-Register'
);
} finally {
this.exporting = false;
}
}
}
@@ -0,0 +1,94 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
/** One share account and the board resolution that authorised it. */
export interface BorRegisterRow {
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeId: number | null;
officeName: string;
productName: string;
currencyCode: string;
statusEnum: number;
approvedShares: number;
pendingShares: number;
unitPrice: number;
shareValue: number;
resolutionNumber: string;
/** ISO date, or '' where no meeting date was ever recorded against the allotment. */
resolutionDate: string;
/** How many purchase tranches make up the holding. */
trancheCount: number;
submittedDate: string;
approvedDate: string;
activatedDate: string;
closedDate: string;
}
/**
* The BOR register.
*
* One call returns every account with its resolution. The share allotment report next door fetches
* each account separately and stops at two hundred, which is a limit on a register rather than on a
* page of one.
*/
@Injectable({ providedIn: 'root' })
export class BorReportService {
private http = inject(HttpClient);
getRegister(status?: number, officeId?: number): Observable<BorRegisterRow[]> {
let params = new HttpParams();
if (status != null) {
params = params.set('status', String(status));
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http
.get<any[]>('/shareaccounts/bor-register', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
}
/** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
private toRow(r: any): BorRegisterRow {
return {
shareAccountId: r.shareAccountId,
accountNo: r.accountNo || '',
clientId: r.clientId,
cifNumber: r.cifNumber || '',
clientName: r.clientName || '',
officeId: r.officeId ?? null,
officeName: r.officeName || '',
productName: r.productName || '',
currencyCode: r.currencyCode || 'INR',
statusEnum: Number(r.statusEnum) || 0,
approvedShares: Number(r.approvedShares) || 0,
pendingShares: Number(r.pendingShares) || 0,
unitPrice: Number(r.unitPrice) || 0,
shareValue: Number(r.shareValue) || 0,
resolutionNumber: (r.resolutionNumber || '').trim(),
resolutionDate: toIsoDate(r.resolutionDate),
trancheCount: Number(r.trancheCount) || 0,
submittedDate: toIsoDate(r.submittedDate),
approvedDate: toIsoDate(r.approvedDate),
activatedDate: toIsoDate(r.activatedDate),
closedDate: toIsoDate(r.closedDate)
};
}
}
@@ -1142,6 +1142,46 @@
</mat-select>
</mat-form-field>
<!-- Risk profile. These are determinations about the customer rather than about anything they
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
rating at High however ordinary the period's activity looks, and the declared income is what
turnover is measured against. Until they are recorded the policy has nothing to test. -->
<div class="full-span kyc-doc-fields">
<div class="kyc-doc-fields__head">
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
</div>
<div class="kyc-risk-flags">
<mat-checkbox formControlName="isPep">
Politically Exposed Person
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
</mat-checkbox>
<mat-checkbox formControlName="sanctionsMatch">
Sanctions / watch-list match
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
</mat-checkbox>
<mat-checkbox formControlName="adverseMediaMatch">
Adverse media / regulatory action
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
</mat-checkbox>
</div>
<div class="kyc-doc-fields__grid">
<mat-form-field appearance="outline">
<mat-label>Declared annual income (₹)</mat-label>
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
<mat-hint>As stated by the customer</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Declared annual turnover (₹)</mat-label>
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
<mat-hint>For a business customer</mat-hint>
</mat-form-field>
</div>
</div>
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
it, so re-collecting an expired document is done on this form rather than
sending the officer somewhere else. -->
@@ -1807,3 +1807,28 @@
color: #15803d;
}
}
/* ── Risk profile ────────────────────────────────────────────────────────────
Determinations about the customer, which the periodic risk review reads directly. Grouped with
the document block so the form reads as "what we know about them" rather than scattering a PEP
flag among the identifier fields. */
.kyc-doc-fields__hint {
font-size: 12px;
color: #64748b;
}
.kyc-risk-flags {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0 14px;
}
/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
.kyc-risk-hint {
display: block;
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
1,
[Validators.required]
],
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
// sanctions match holds the rating at High whatever the period's activity looks like.
isPep: [false],
sanctionsMatch: [false],
adverseMediaMatch: [false],
declaredAnnualIncome: [null],
declaredAnnualTurnover: [null],
// The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
@@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.kycForm.patchValue({
kycType: data.kycType || 1,
riskCategory: data.riskCategory || 1,
isPep: !!data.isPep,
sanctionsMatch: !!data.sanctionsMatch,
adverseMediaMatch: !!data.adverseMediaMatch,
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
panNumber: data.panNumber || '',
// Aadhaar is stored masked on the server; user must re-enter
// the full 12-digit number to re-verify, so don't pre-fill.
@@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
return new Date();
}
/**
* The customer's risk profile, as every save has to carry it.
*
* <p>
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
* these and silently clear a PEP flag the officer had just ticked.
*/
private buildRiskProfileFields() {
const v = this.kycForm.value;
return {
isPep: !!v.isPep,
sanctionsMatch: !!v.sanctionsMatch,
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
}
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildExceptionFields()
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
@@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildExceptionFields()
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
this.clientsService
@@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildExceptionFields()
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
this.clientsService
+8 -47
View File
@@ -310,41 +310,6 @@
</nav>
}
}
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
<ng-template #kpiTickerItems>
<span class="ti"><span class="ti-k">CASH</span><span class="ti-v amber">₹14,82,500</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">TXN TODAY</span>
<span class="ti-v">1,247</span>
<span class="ti-v up">↑ 8.2%</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CLEARING IN</span><span class="ti-v">42</span></span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CLEARING OUT</span><span class="ti-v">38</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">NPA·GROSS</span>
<span class="ti-v amber">3.47%</span>
<span class="ti-v down">↑ 0.12</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CASA</span><span class="ti-v">58.2%</span></span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CD RATIO</span><span class="ti-v">71.8%</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">RBI CIRC</span>
<span class="ti-v">DBR.AML.NO.4/14.01.001</span>
<span class="ti-v amber">NEW</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">REPO</span><span class="ti-v">6.50%</span></span>
<span class="ti-d">│</span>
</ng-template>
<!-- ============================================================
Main Header — 2-Row Navigation
============================================================
@@ -623,18 +588,11 @@
</div>
</div>
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
<span class="kpi-live">
<span class="kpi-live-dot"></span>
LIVE
</span>
<div class="kpi-track">
<!-- Render the item block twice so the marquee loops seamlessly. -->
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
</div>
</div>
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
one or must not be shown. The band returns when there is a source behind it. -->
}
<!-- end @if (!hideShellLayout) -->
@@ -713,6 +671,9 @@
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
+21 -3
View File
@@ -7,6 +7,7 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -25,8 +26,7 @@ import {
KycIndividual,
PoFinding,
StrStatus,
StrTransaction,
STR_TXNS
StrTransaction
} from './fiu-data';
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
@@ -130,6 +130,7 @@ export class FiuComponent implements OnInit {
private amlService = inject(AmlService);
private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
private route = inject(ActivatedRoute);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -154,7 +155,16 @@ export class FiuComponent implements OnInit {
* is honestly empty rather than populated with rows that cannot be filed.
*/
tc1Txns: CardTransaction[] = [];
strTxns: StrTransaction[] = STR_TXNS;
/**
* The STR transaction file.
*
* Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank
* has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an
* officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false
* report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the
* file fills from those when the STR export is wired to them.
*/
strTxns: StrTransaction[] = [];
/**
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
*
@@ -398,6 +408,14 @@ export class FiuComponent implements OnInit {
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
// Opened on a particular tab when another screen sends the officer here for a reason - the Risk
// Review Report links a case that is with the Principal Officer straight to the STR queue, so
// landing on the dashboard would make them find it themselves.
const wantedTab = this.route.snapshot.queryParamMap.get('tab');
if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') {
this.activeTab = wantedTab;
}
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
// by whom. Both now come from the session and the clock.
@@ -0,0 +1,309 @@
/**
* 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/.
*/
import { Component, Inject, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormArray, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AmlService } from 'app/aml/aml.service';
export interface AmlBulkReviewData {
alerts: any[];
disposition: 'WHITELIST' | 'ESCALATE';
}
/**
* One disposition applied to several alerts at once.
*
* <p>
* Low and medium-severity alerts arrive as runs of the same thing - a customer whose ordinary cash
* deposits each cross a threshold - and clearing them one dialog at a time is what keeps an officer
* from reaching the alert that matters. The reason and evidence are asked once and written against
* every alert in the batch, so each still carries its own record of why it was disposed of.
*
* <p>
* High-severity alerts never reach here: the caller will not tick them. An alert in the high band is
* a judgement about that particular transaction and gets its own decision.
*/
@Component({
selector: 'mifosx-aml-bulk-review-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule
],
template: `
<h2 mat-dialog-title>
{{ isWhitelist ? 'Whitelist' : 'Escalate' }} {{ data.alerts.length }} Alert{{
data.alerts.length === 1 ? '' : 's'
}}
</h2>
<mat-dialog-content>
<p class="bk-intro">
The same decision, reason and evidence are recorded against each alert below. Every one stays
individually auditable.
</p>
<div class="bk-list">
<div class="bk-row" *ngFor="let a of data.alerts">
<span class="bk-txn">{{ a.transactionType }} #{{ a.transactionId }}</span>
<span class="bk-client">{{ a.clientDisplayName }}</span>
<span class="bk-rule">{{ a.ruleCode }}</span>
<span class="bk-amt">₹{{ a.amount | number: '1.0-0' }}</span>
</div>
</div>
<form [formGroup]="form">
<!-- Clearing an alert has to rest on something. Escalation does not: the officer is saying
it needs investigating, which is the opposite of claiming it is explained. -->
<div class="bk-evidence" *ngIf="isWhitelist">
<div class="bk-label">Evidence relied on</div>
<div formArrayName="evidence" class="bk-checks">
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i">{{ t }}</mat-checkbox>
</div>
<p class="bk-err" *ngIf="evidenceMissing">Tick at least one piece of evidence</p>
<mat-form-field appearance="outline" class="bk-full">
<mat-label>Reference (optional)</mat-label>
<input matInput formControlName="evidenceRef" maxlength="100" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="bk-full">
<mat-label>{{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }}</mat-label>
<textarea matInput formControlName="notes" rows="3" maxlength="800"></textarea>
<mat-error *ngIf="form.get('notes')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
<p class="bk-effect" [class.bk-effect--warn]="!isWhitelist">
{{
isWhitelist
? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.'
: 'Each alert opens its own STR case for the Principal Officer.'
}}
</p>
<p class="bk-err" *ngIf="errorMessage">{{ errorMessage }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button
mat-flat-button
[color]="isWhitelist ? 'primary' : 'warn'"
type="button"
[disabled]="form.invalid || submitting"
(click)="submit()"
>
{{ submitting ? 'Applying…' : isWhitelist ? 'Whitelist All' : 'Escalate All' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.bk-intro {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
.bk-list {
max-height: 190px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 16px;
background: #f8fafc;
}
.bk-row {
display: grid;
grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr;
gap: 10px;
padding: 8px 12px;
font-size: 12px;
border-bottom: 1px solid #eef2f7;
}
.bk-row:last-child {
border-bottom: none;
}
.bk-txn {
font-family: 'DM Mono', monospace;
color: #334155;
}
.bk-client {
color: #1e293b;
}
.bk-rule {
color: #7c3aed;
font-size: 11px;
}
.bk-amt {
text-align: right;
font-family: 'DM Mono', monospace;
color: #1e293b;
}
.bk-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 6px;
}
.bk-checks {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 10px;
}
.bk-full {
width: 100%;
}
.bk-effect {
font-size: 12.5px;
line-height: 1.5;
color: #15803d;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 10px 12px;
margin: 4px 0 0;
}
.bk-effect--warn {
color: #b91c1c;
background: #fef2f2;
border-color: #fecaca;
}
.bk-err {
margin: 10px 0 0;
color: #b91c1c;
font-size: 12.5px;
}
`
]
})
export class AmlBulkReviewDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
dialogRef = inject(MatDialogRef<AmlBulkReviewDialogComponent>);
readonly evidenceTypes = [
'Customer explanation obtained',
'Source of funds documented',
'KYC / identity re-verified',
'Consistent with declared income or turnover',
'Established business or salary pattern',
'Duplicate or system-generated alert',
'Other'
];
form: FormGroup;
submitting = false;
errorMessage = '';
constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) {
this.form = this.fb.group({
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
evidenceRef: [
'',
Validators.maxLength(100)
],
notes: [
'',
[
Validators.required,
Validators.maxLength(800)
]
]
});
}
get isWhitelist(): boolean {
return this.data.disposition === 'WHITELIST';
}
get evidenceArray(): FormArray {
return this.form.get('evidence') as FormArray;
}
get selectedEvidence(): string[] {
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
}
get evidenceMissing(): boolean {
return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0;
}
/** Exactly what is written to each alert, so the record reads the same as a single review. */
private composedNotes(): string {
const v = this.form.value;
const detail = (v.notes || '').trim();
if (!this.isWhitelist) {
return detail ? `Grounds: ${detail}` : '';
}
const parts: string[] = [];
if (this.selectedEvidence.length) {
parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`);
}
if (v.evidenceRef) {
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
}
const head = parts.join(' | ');
const composed = head && detail ? `${head} — ${detail}` : head || detail;
// Batch note, so each alert says it was disposed of alongside others rather than on its own.
return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000);
}
submit(): void {
this.evidenceArray.markAllAsTouched();
if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) {
this.form.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
const notes = this.composedNotes();
// Each alert is reviewed on its own call: one that fails - already disposed of by someone else,
// say - must not take the rest of the batch with it. Failures are counted and reported.
forkJoin(
this.data.alerts.map((a) =>
this.amlService
.reviewAlert(a.id, this.data.disposition, notes)
.pipe(catchError(() => of({ __failed: true, id: a.id })))
)
).subscribe((results: any[]) => {
this.submitting = false;
const failed = results.filter((r) => r?.__failed).length;
if (failed === results.length) {
this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.';
return;
}
if (failed > 0) {
this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`;
// Still closes as done: most of the batch landed, and the list behind must refresh.
setTimeout(() => this.dialogRef.close(true), 2200);
return;
}
this.dialogRef.close(true);
});
}
}
@@ -60,23 +60,113 @@
</button>
</div>
<!-- Severity mix of what the table is showing -->
<!-- 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. -->
<div class="risk-summary">
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
<button
*ngFor="let tier of riskSummary"
type="button"
class="risk-tile"
[ngClass]="tier.cls"
[class.tile-active]="isSeverityActive(tier.key)"
[disabled]="tier.count === 0 && !isSeverityActive(tier.key)"
[matTooltip]="isSeverityActive(tier.key) ? 'Show every severity again' : 'Show only ' + tier.label + ' alerts'"
(click)="setSeverity(tier.key)"
>
<span class="risk-tile-n">{{ tier.count }}</span>
<span class="risk-tile-l">{{ tier.label }}</span>
</div>
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
</button>
<button
*ngIf="unclassifiedCount > 0"
type="button"
class="risk-tile risk-none"
[class.tile-active]="isSeverityActive(0)"
matTooltip="Raised by a user, not by a catalog rule"
(click)="setSeverity(0)"
>
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
<span class="risk-tile-l">Unclassified</span>
</div>
<div class="risk-tile risk-total">
</button>
<button
type="button"
class="risk-tile risk-total"
[class.tile-active]="activeSeverity === null"
matTooltip="Show every severity"
(click)="clearSeverity()"
>
<span class="risk-tile-n">{{ summaryTotal }}</span>
<span class="risk-tile-l">Total Flagged</span>
</div>
</button>
</div>
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
<!-- Appears only once something is ticked, so the table does not carry a permanently empty bar. -->
@if (selectedCount > 0) {
<div class="bulk-bar">
<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-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
<button
mat-stroked-button
color="primary"
type="button"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
(click)="reviewSelected('WHITELIST')"
>
Whitelist {{ selectedCount }}
</button>
<button
mat-flat-button
color="warn"
type="button"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
(click)="reviewSelected('ESCALATE')"
>
Escalate {{ selectedCount }}
</button>
</div>
}
<!-- multiTemplateDataRows: each alert renders through both row definitions — the alert row and its
detail row beneath it. Without it MatTable renders one row per alert, the two definitions both
claim it, and the table throws rather than rendering anything. All three tabs share one tab
group, so that error blanked Repeat Customers and Rules Master as well. -->
<table mat-table [dataSource]="alerts" multiTemplateDataRows class="flagged-table mat-elevation-z1">
<!-- Ticked for a batch decision. Disabled where the transaction tripped a high-severity rule,
where nothing it matched carried a severity (the CTR rules), or where it is already decided. -->
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef class="select-cell">
<!-- Hidden outright when nothing here can be batched — filtered to high severity, say. -->
@if (selectableRows.length) {
<mat-checkbox
[checked]="allSelectableSelected"
(change)="toggleSelectAll()"
matTooltip="Select every low / medium alert on this page"
></mat-checkbox>
}
</th>
<td mat-cell *matCellDef="let alert" class="select-cell" (click)="$event.stopPropagation()">
<!-- No checkbox at all where the transaction cannot be part of a batch. A disabled box still
reads as "this could be ticked", and on a high-severity row that is the wrong thing to
suggest: the decision is never a batch one. A dash says the column does not apply. -->
@if (isSelectable(alert)) {
<mat-checkbox [checked]="isSelected(alert)" (change)="toggleSelected(alert)"></mat-checkbox>
} @else {
<span
class="select-na"
[matTooltip]="
isHighSeverity(alert)
? 'High severity — this transaction is decided on its own'
: !canReview(alert)
? 'Already decided'
: 'A CTR is a statutory return, not a graded suspicion — decided on its own'
"
>—</span
>
}
</td>
</ng-container>
<!-- When the engine raised the alert -->
<ng-container matColumnDef="raisedOn">
<th mat-header-cell *matHeaderCellDef>Raised On</th>
@@ -113,6 +203,142 @@
<div class="stacked-cell">
<span class="rule-code">{{ alert.ruleCode }}</span>
<span class="cell-sub">{{ alert.ruleDescription }}</span>
<!-- The transaction is one case under one rule, but an analyst still needs to see
everything it tripped — a deposit can be both reportable and on a new account. The
detail row carries each one with its own severity; this is only the count. -->
@if (matchedRuleCount(alert) > 1) {
<button type="button" class="rule-more" (click)="toggleExpanded(alert); $event.stopPropagation()">
<mat-icon>{{ isExpanded(alert) ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ matchedRuleCount(alert) }} rules matched
</button>
}
</div>
</td>
</ng-container>
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
opened, so the detail sits directly under the row it belongs to. -->
<ng-container matColumnDef="expandedDetail">
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
clicks from reaching it — otherwise ticking a rule closes the pane. -->
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
@if (isExpanded(alert)) {
<div class="detail-inner">
<div class="detail-head">
<span>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }}</span>
@if (selectableRulesOf(alert).length > 1) {
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
</button>
}
</div>
<!-- Governing rule first: it is the one the alert was raised under, and the one the
disposition is recorded against. -->
@for (rule of matchedRules(alert); track rule.ruleCode) {
<div
class="detail-rule"
[class.detail-rule-governing]="rule.governing"
[class.detail-rule-decided]="rule.disposition"
>
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
customer's business and the sixth is not, and those are different decisions. A rule already
decided shows its verdict instead. -->
@if (isRuleSelectable(rule)) {
<mat-checkbox
[checked]="isRuleSelected(rule)"
(change)="toggleRuleSelected(rule)"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
></mat-checkbox>
} @else if (!isGradedRule(rule)) {
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
about the transaction, so there is nothing here to tick. -->
<mat-icon
class="rule-decided-icon decided-statutory"
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
matTooltipClass="evidence-tooltip"
>
gavel
</mat-icon>
} @else {
<mat-icon
class="rule-decided-icon"
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
[class.decided-carried]="!rule.directlyDecided"
[matTooltip]="
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
"
matTooltipClass="evidence-tooltip"
>
{{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }}
</mat-icon>
}
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{ severityLabel(rule.riskCategory) }}</span>
<div class="detail-rule-text">
<span class="detail-rule-code">
{{ rule.ruleCode }}
@if (rule.governing) {
<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
transaction being unreported. -->
@if (!isGradedRule(rule)) {
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
→ CTR filing
</span>
}
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
because an audit of the finding turns on which one they put their name to. -->
@if (rule.disposition) {
<span
class="rule-verdict"
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
[class.verdict-carried]="!rule.directlyDecided"
[matTooltip]="
rule.directlyDecided
? ruleDispositionLabel(rule) +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
"
>
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
</span>
}
</span>
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
</div>
</div>
}
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
clearing five of six rules leaves the case open on the sixth. -->
@if (selectedRuleCount > 0) {
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<span class="rule-action-count">{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
Whitelist
</button>
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
Suspicious
</button>
</div>
}
<div class="detail-foot">
@if (isHighSeverity(alert)) {
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
} @else if (!alert.highestSeverity) {
A statutory return rather than a graded suspicion — decided on its own.
} @else {
Every rule here is low or medium, so this transaction can be decided together with others.
}
</div>
</div>
}
</div>
</td>
</ng-container>
@@ -132,7 +358,7 @@
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
<td mat-cell *matCellDef="let alert" class="col-center actions-cell" (click)="$event.stopPropagation()">
<ng-container *ngIf="canReview(alert); else reviewed">
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
so without this a user who cannot review still sees buttons that only fail on click. -->
@@ -180,15 +406,33 @@
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
<!-- Clicking anywhere on the row opens its detail. The checkbox and the action buttons stop the
event themselves, so ticking a row or disposing of it does not also expand it. -->
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="alert-row"
[class.row-new]="isNew(row)"
[class.row-expanded]="isExpanded(row)"
(click)="toggleExpanded(row)"
></tr>
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
paginator before the data source has bound to it leaves that 0 in force, which pages every row
out of the table. -->
<mat-paginator
[pageSize]="10"
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
class="flagged-paginator"
></mat-paginator>
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus"
<mat-icon>{{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus && activeSeverity === null"
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
>
<span *ngIf="activeStatus">No alerts in this status.</span>
<span *ngIf="activeStatus || activeSeverity !== null">No alerts match the filters in force.</span>
</div>
@@ -196,15 +196,43 @@
padding: 0 0 16px;
}
/* A button, not a div: each tile narrows the table to its own severity band. */
.risk-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left-width: 4px;
border-radius: 8px;
background: #fff;
font-family: inherit;
text-align: left;
cursor: pointer;
transition:
box-shadow 0.15s,
transform 0.1s;
&:hover:not(:disabled) {
box-shadow: 0 2px 8px rgb(15 23 42 / 12%);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
/* A band with nothing in it would filter to an empty table. */
&:disabled {
opacity: 0.5;
cursor: default;
}
}
/* The band currently narrowing the table. The ring reads on every tile colour, where a
background change would fight the severity tint each tile already carries. */
.risk-tile.tile-active {
box-shadow: 0 0 0 2px #1a237e;
}
.risk-tile-n {
@@ -293,8 +321,21 @@
}
}
.alert-row:hover {
.alert-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
/* The open row and its pane read as one block. */
.alert-row.row-expanded {
background: #f8fafc;
td {
border-bottom-color: transparent;
}
}
/* A newly arrived alert stays tinted until acknowledged. */
@@ -380,6 +421,285 @@
font-size: 12px;
}
/* Quieter than the governing rule: it is context on one case, not a second case. */
.rule-also {
margin-top: 3px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
align-self: flex-start;
}
/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in
this cell: the codes belong in the pane, where each can carry its own severity. */
.rule-more {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 4px;
padding: 2px 8px 2px 4px;
align-self: flex-start;
border: 1px solid #fde68a;
border-radius: 999px;
background: #fffbeb;
color: #92400e;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
&:hover {
background: #fef3c7;
}
}
/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */
.detail-row {
height: 0;
}
.detail-cell {
padding: 0 !important;
border-bottom: none !important;
}
/* Animating max-height rather than height: the pane's height depends on how many rules fired, and
a transition needs a figure to move between. */
.detail-pane {
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease;
background: #f8fafc;
/* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low
silently clips the last rule, which is the one an officer most needs to see. */
&.detail-open {
max-height: 1200px;
}
}
.detail-inner {
padding: 14px 16px 16px 60px;
border-bottom: 1px solid #e2e8f0;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 10px;
}
.rule-select-all {
border: none;
background: none;
padding: 0;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #1a237e;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* A rule that already carries a verdict is context, not a choice. */
.detail-rule-decided {
opacity: 0.75;
}
.rule-decided-icon {
flex: none;
font-size: 18px;
width: 18px;
height: 18px;
color: #15803d;
cursor: help;
&.decided-escalated {
color: #b91c1c;
}
/* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */
&.decided-carried {
color: #94a3b8;
}
/* A statutory return: not the officer's decision at all. */
&.decided-statutory {
color: #475569;
}
}
/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a
missing checkbox. */
.ctr-tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.rule-verdict {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #dcfce7;
color: #15803d;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
&.verdict-escalated {
background: #fee2e2;
color: #b91c1c;
}
/* Wins over the disposition colours above: this rule was not itself the finding, and colouring it
like one would claim the officer condemned it. */
&.verdict-carried {
background: #f1f5f9;
color: #64748b;
}
}
/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */
.rule-action-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.rule-action-count {
font-size: 12.5px;
font-weight: 700;
color: #1e3a8a;
white-space: nowrap;
}
.detail-rule {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 10px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
& + & {
margin-top: 6px;
}
}
/* The rule the alert was raised under, and the one the disposition is recorded against. */
.detail-rule-governing {
border-color: #cbd5e1;
box-shadow: inset 3px 0 0 #1a237e;
}
.detail-rule-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.detail-rule-code {
font-size: 12.5px;
font-weight: 700;
color: #1e293b;
}
.detail-rule-desc {
font-size: 12px;
color: #64748b;
}
.governing-tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #e0e7ff;
color: #3730a3;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the
same wherever it appears. */
.sev-chip {
flex: none;
min-width: 62px;
text-align: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&.sev-high {
background: #fee2e2;
color: #b91c1c;
}
&.sev-medium {
background: #fef3c7;
color: #b45309;
}
&.sev-low {
background: #dcfce7;
color: #15803d;
}
&.sev-none {
background: #e2e8f0;
color: #475569;
}
}
/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */
.detail-foot {
margin-top: 10px;
font-size: 12px;
color: #475569;
font-style: italic;
}
.rule-code {
font-weight: 600;
}
@@ -537,3 +857,48 @@
background: #fff;
border-top: 1px solid #e2e8f0;
}
/* Batch disposition bar — only rendered while rows are ticked. */
.bulk-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 10px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
}
.bulk-count {
font-size: 13px;
font-weight: 700;
color: #1e3a8a;
white-space: nowrap;
}
/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */
.bulk-note {
font-size: 12px;
color: #475569;
}
.bulk-spacer {
flex: 1;
}
.select-cell {
width: 44px;
padding-right: 0;
}
/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a
column rather than leaving a hole the eye has to account for. */
.select-na {
display: inline-block;
width: 18px;
text-align: center;
color: #cbd5e1;
cursor: help;
user-select: none;
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
@@ -15,6 +15,7 @@ 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 { Subject, timer, of } from 'rxjs';
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
@@ -24,6 +25,7 @@ import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.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';
/**
* Live feed of transactions the AML rule engine has flagged.
@@ -48,10 +50,11 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
MatTooltipModule,
MatProgressSpinnerModule,
MatDialogModule,
MatCheckboxModule,
HasPermissionDirective
]
})
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
@@ -62,6 +65,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
alerts = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'select',
'raisedOn',
'transaction',
'client',
@@ -71,6 +75,283 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
'actions'
];
/**
* Alerts ticked for a single disposition across several transactions.
*
* <p>
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
* a time is what stops an officer getting to the one alert that matters. High severity is
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
* transaction, and it gets its own decision.
*/
selectedIds = new Set<number>();
/** m_client_risk_rule.risk_category — 3 is the high band. */
private static readonly HIGH_SEVERITY = 3;
/**
* Whether any rule this transaction matched reached the high band.
*
* <p>
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
* transaction up for a batch decision.
*/
isHighSeverity(alert: any): boolean {
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
}
/**
* The band this alert counts as, for the summary tiles.
*
* <p>
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
*/
private severityBand(alert: any): number {
const worst = Number(alert?.highestSeverity);
if (worst === 1 || worst === 2 || worst === 3) {
return worst;
}
const governing = Number(alert?.riskCategory);
if (governing === 1 || governing === 2 || governing === 3) {
return governing;
}
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
// NaN band would leave that tile showing a count it could never select.
return 0;
}
/**
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
* others.
*
* <p>
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
* judgement about the transaction itself. And one where no matched rule carried a severity at
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
*/
isSelectable(alert: any): boolean {
const band = Number(alert?.highestSeverity);
const graded = band === 1 || band === 2;
return this.canReview(alert) && graded;
}
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
expandedId: number | null = null;
toggleExpanded(alert: any): void {
this.expandedId = this.expandedId === alert.id ? null : alert.id;
// Ticks belong to the alert they were made on, not to whichever pane is open next.
this.selectedRuleCodes.clear();
}
isExpanded(alert: any): boolean {
return this.expandedId === alert.id;
}
/**
* Every rule the transaction matched, governing one first.
*
* <p>
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
* it, so an older row still expands to something rather than appearing to have matched nothing.
*/
matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
return alert.matchedRules;
}
const rules = [
{
ruleCode: alert?.ruleCode,
ruleDescription: alert?.ruleDescription,
riskCategory: alert?.riskCategory ?? null,
governing: true
}
];
for (const code of String(alert?.alsoMatchedRules || '')
.split(',')
.map((c: string) => c.trim())
.filter((c: string) => c)) {
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
}
return rules;
}
/** How many rules the transaction matched, for the count shown on the collapsed row. */
matchedRuleCount(alert: any): number {
return this.matchedRules(alert).length;
}
severityLabel(band: number | null | undefined): string {
switch (Number(band)) {
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
default:
return 'Statutory';
}
}
/**
* Rule codes ticked inside the open detail pane.
*
* <p>
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
* and carrying them across would apply one transaction's selection to another's rules.
*/
selectedRuleCodes = new Set<string>();
isRuleSelected(rule: any): boolean {
return this.selectedRuleCodes.has(rule.ruleCode);
}
/**
* Whether this rule is the officer's to decide.
*
* <p>
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
* does not stop the CTR, and escalating it does not describe what the rule found.
*/
isRuleSelectable(rule: any): boolean {
return !rule?.disposition && this.isGradedRule(rule);
}
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
isGradedRule(rule: any): boolean {
const band = Number(rule?.riskCategory);
return band === 1 || band === 2 || band === 3;
}
toggleRuleSelected(rule: any): void {
if (!this.isRuleSelectable(rule)) {
return;
}
if (this.selectedRuleCodes.has(rule.ruleCode)) {
this.selectedRuleCodes.delete(rule.ruleCode);
} else {
this.selectedRuleCodes.add(rule.ruleCode);
}
}
selectableRulesOf(alert: any): any[] {
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
}
get selectedRuleCount(): number {
return this.selectedRuleCodes.size;
}
allRulesSelected(alert: any): boolean {
const open = this.selectableRulesOf(alert);
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
}
toggleAllRules(alert: any): void {
if (this.allRulesSelected(alert)) {
this.selectedRuleCodes.clear();
return;
}
for (const rule of this.selectableRulesOf(alert)) {
this.selectedRuleCodes.add(rule.ruleCode);
}
}
ruleDispositionLabel(rule: any): string {
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
}
severityClass(band: number | null | undefined): string {
switch (Number(band)) {
case 1:
return 'sev-low';
case 2:
return 'sev-medium';
case 3:
return 'sev-high';
default:
return 'sev-none';
}
}
isSelected(alert: any): boolean {
return this.selectedIds.has(alert.id);
}
toggleSelected(alert: any): void {
if (!this.isSelectable(alert)) {
return;
}
if (this.selectedIds.has(alert.id)) {
this.selectedIds.delete(alert.id);
} else {
this.selectedIds.add(alert.id);
}
}
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
get selectableRows(): any[] {
return this.alerts.data.filter((a) => this.isSelectable(a));
}
get allSelectableSelected(): boolean {
const rows = this.selectableRows;
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
}
toggleSelectAll(): void {
if (this.allSelectableSelected) {
this.selectedIds.clear();
return;
}
for (const a of this.selectableRows) {
this.selectedIds.add(a.id);
}
}
get selectedCount(): number {
return this.selectedIds.size;
}
private selectedAlerts(): any[] {
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
}
/**
* Drops ticks that no longer stand, after every poll.
*
* <p>
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
* screen, and the batch carries alerts nobody chose.
*/
private pruneSelection(): void {
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
for (const id of Array.from(this.selectedIds)) {
if (!stillSelectable.has(id)) {
this.selectedIds.delete(id);
}
}
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
// leave the table holding a pane for a row that is no longer on it.
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
this.expandedId = null;
}
}
/** Every alert as returned, before the status filter narrows the table. */
private allAlerts: any[] = [];
@@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
activeStatus = '';
private statusCounts: Record<string, number> = {};
/**
* 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: { label: string; count: number; cls: string }[] = [];
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;
@@ -106,6 +394,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator!: MatPaginator;
/**
* Binds the paginator as soon as the view exists.
*
* <p>
* The first poll can return before the view has initialised, and until this the data source was
* only ever wired up as a side effect of a later filter pass — so the table could render every
* alert unpaginated, then jump once the next poll arrived.
*/
ngAfterViewInit(): void {
this.alerts.paginator = this.paginator;
}
ngOnInit(): void {
this.fetch();
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
@@ -158,7 +458,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
setStatus(key: string): void {
this.activeStatus = key;
this.applyStatusFilter();
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 {
@@ -203,47 +518,63 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
this.allAlerts = list;
this.recomputeStatusCounts();
this.applyStatusFilter();
this.pruneSelection();
this.lastUpdated = new Date();
}
private applyStatusFilter(): void {
this.alerts.data = this.activeStatus
/**
* @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 {
const byStatus = this.activeStatus
? this.allAlerts.filter((a) => a.status === this.activeStatus)
: this.allAlerts;
this.alerts.data =
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
this.recomputeRiskSummary();
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(): void {
const pool = this.alerts.data;
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) => Number(a.riskCategory) === t.key).length
count: pool.filter((a) => this.severityBand(a) === t.key).length
}));
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
this.unclassifiedCount = pool.filter(
(a) => ![
1,
2,
3
].includes(Number(a.riskCategory))
).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.
*/
get summaryTotal(): number {
return this.alerts.data.length;
return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length;
}
private recomputeStatusCounts(): void {
@@ -330,4 +661,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
this.fetch();
});
}
/**
* Applies one disposition to every ticked alert.
*
* <p>
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
* point of the batch: the officer is making one decision about one kind of activity, not the same
* decision twenty times.
*/
/**
* Applies one disposition to the rules ticked inside the open pane.
*
* <p>
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
* leaves the case open on the sixth rather than closing it.
*/
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
const codes = Array.from(this.selectedRuleCodes);
if (!codes.length) {
return;
}
this.dialog
.open(AmlReviewAlertDialogComponent, {
width: '560px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alert, disposition, ruleCodes: codes }
})
.afterClosed()
.subscribe((result: string | undefined) => {
if (!result) {
return;
}
this.alertService.alert({
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
}.`
});
this.selectedRuleCodes.clear();
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
this.fetch();
});
}
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
const batch = this.selectedAlerts();
if (!batch.length) {
return;
}
this.dialog
.open(AmlBulkReviewDialogComponent, {
width: '620px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alerts: batch, disposition }
})
.afterClosed()
.subscribe((done: boolean | undefined) => {
if (!done) {
return;
}
this.selectedIds.clear();
this.fetch();
});
}
}
@@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service';
export interface AmlReviewDialogData {
alert: any;
disposition: 'WHITELIST' | 'ESCALATE';
/**
* The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
* transaction. Absent means the decision covers the alert and every rule under it.
*/
ruleCodes?: string[];
}
/**
@@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent {
}
get title(): string {
// Named differently when only some of the transaction's rules are being decided, so the officer is not told they
// are clearing a transaction when five of its six rules stay open.
const n = this.data.ruleCodes?.length ?? 0;
if (n > 0) {
const subject = n === 1 ? 'Rule' : `${n} Rules`;
return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
}
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
}
@@ -181,7 +193,12 @@ export class AmlReviewAlertDialogComponent {
this.submitting = true;
this.errorMessage = '';
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
// 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.
const request = this.data.ruleCodes?.length
? 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);
request.subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.data.disposition);
@@ -93,6 +93,11 @@ const routes: Routes = [
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
{
path: 'bor',
data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' },
loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
@@ -0,0 +1,390 @@
/**
* 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/.
*/
import { Component, Inject, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AmlService } from 'app/aml/aml.service';
const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
/**
* The approval queue for risk-category changes raised from the Risk Review Report.
*
* <p>
* Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
* shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
* how closely the bank watches that customer, not clearing a queue item.
*/
@Component({
selector: 'mifosx-risk-change-approvals',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule
],
template: `
<div class="rca-root">
<div class="rca-head">
<div>
<h2 class="rca-title">Risk Category Approvals</h2>
<p class="rca-sub">
Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
and the re-KYC due date together, and writes the reason into the customer's risk history.
</p>
</div>
</div>
<div class="rca-filters">
<button
*ngFor="let s of statuses"
type="button"
class="rca-chip"
[class.rca-chip--on]="selectedStatus === s"
(click)="filterBy(s)"
>
{{ s === '' ? 'All' : s }} <span class="rca-count">({{ countOf(s) }})</span>
</button>
</div>
<div class="rca-table-wrap">
<table class="rca-table">
<thead>
<tr>
<th>Req. ID</th>
<th>Customer</th>
<th>CIF</th>
<th>Branch</th>
<th>Rating change</th>
<th>Reason</th>
<th>Status</th>
<th>Requested By</th>
<th>Requested On</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of filtered">
<td class="mono">{{ r.id }}</td>
<td>
<strong>{{ r.clientName }}</strong>
</td>
<td class="mono">{{ r.cifNumber || '—' }}</td>
<td>{{ r.officeName || '—' }}</td>
<td>
<span class="rca-move">
{{ labelOf(r.currentCategory) }} <span class="rca-arrow">&rarr;</span>
<strong>{{ labelOf(r.requestedCategory) }}</strong>
</span>
</td>
<td class="rca-reason">{{ r.reason }}</td>
<td>
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{ r.status }}</span>
</td>
<td>{{ r.makerUsername }}</td>
<td class="mono">{{ r.createdDate }}</td>
<td>
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
</div>
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
by {{ r.checkerUsername }}, {{ r.checkerDate }}
</span>
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
</td>
</tr>
<tr *ngIf="!filtered.length">
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
</tr>
</tbody>
</table>
</div>
</div>
`,
styles: [
`
.rca-root {
padding: 20px 24px 40px;
background: #f8fafc;
min-height: 100%;
}
.rca-head {
margin-bottom: 16px;
}
.rca-title {
margin: 0 0 3px;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.rca-sub {
margin: 0;
max-width: 800px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.rca-filters {
display: flex;
gap: 8px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.rca-chip {
height: 32px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
}
.rca-chip--on {
border-color: #1e3a8a;
background: #eff6ff;
color: #1e3a8a;
}
.rca-count {
color: #94a3b8;
font-weight: 500;
}
.rca-table-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow-x: auto;
}
.rca-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.rca-table th {
text-align: left;
padding: 10px;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
.rca-table td {
padding: 11px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: #334155;
}
.mono {
font-family: 'DM Mono', monospace;
}
.rca-move {
white-space: nowrap;
}
.rca-arrow {
color: #94a3b8;
margin: 0 4px;
}
.rca-reason {
max-width: 280px;
}
.rca-status {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
}
.rca-status--pending {
background: #fef3c7;
color: #b45309;
}
.rca-status--approved {
background: #dcfce7;
color: #15803d;
}
.rca-status--rejected {
background: #fee2e2;
color: #b91c1c;
}
.rca-actions {
display: flex;
gap: 8px;
}
.rca-done {
font-size: 11.5px;
color: #64748b;
}
.rca-empty {
padding: 30px;
text-align: center;
color: #94a3b8;
}
`
]
})
export class RiskChangeApprovalsComponent implements OnInit {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
rows: any[] = [];
readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', ''];
selectedStatus = 'PENDING';
ngOnInit(): void {
this.load();
}
load(): void {
this.amlService.getRiskChangeRequests().subscribe({
next: (rows) => (this.rows = rows || []),
error: (err: any) =>
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
duration: 5000
})
});
}
get filtered(): any[] {
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
}
countOf(status: string): number {
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
}
filterBy(status: string): void {
this.selectedStatus = status;
}
labelOf(category: number | null): string {
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
}
approve(r: any): void {
this.amlService.approveRiskChange(r.id).subscribe({
next: () => {
this.snackBar.open(
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err: any) =>
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
});
}
reject(r: any): void {
this.dialog
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
.afterClosed()
.subscribe((done) => {
if (done) {
this.load();
}
});
}
}
// ── REJECT DIALOG ──
@Component({
selector: 'mifosx-reject-risk-change-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Reject Risk Category Change</h2>
<mat-dialog-content>
<p class="hint">
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
</p>
<form [formGroup]="form">
<mat-form-field appearance="outline" class="full">
<mat-label>Rejection reason</mat-label>
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
Reject
</button>
</mat-dialog-actions>
`,
styles: [
`
.full {
width: 100%;
}
.hint {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
`
]
})
export class RejectRiskChangeDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
form: FormGroup;
busy = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
this.form = this.fb.group({ rejectionReason: ['', Validators.required] });
}
submit(): void {
if (this.form.invalid) {
return;
}
this.busy = true;
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
next: () => {
this.busy = false;
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err: any) => {
this.busy = false;
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
}
});
}
}
@@ -0,0 +1,222 @@
/**
* 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/.
*/
import { Component, Inject, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AmlService } from 'app/aml/aml.service';
export interface RiskChangeDialogData {
clientId: number;
customerName: string;
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
currentCategory: number | null;
/** What the report's own computation says the rating should be, used as the default. */
recommendedCategory: number | null;
/** The one-line explanation the report already shows against the row. */
recommendationReason: string;
}
/**
* Raises a change to a customer's KYC risk category for approval.
*
* <p>
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
* against, so it is not something one officer moves on their own. The dialog collects what it
* should become and why; a checker applies it.
*/
@Component({
selector: 'mifosx-risk-change-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule
],
template: `
<h2 mat-dialog-title>Change Risk Category</h2>
<mat-dialog-content>
<div class="rc-summary">
<div class="rc-row">
<span class="rc-label">Customer</span>
<strong class="rc-value">{{ data.customerName }}</strong>
</div>
<div class="rc-row">
<span class="rc-label">Rating on file</span>
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
</div>
</div>
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
says plainly that the figure is a recommendation rather than a decision. -->
<p class="rc-reason" *ngIf="data.recommendationReason">
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
</p>
<form [formGroup]="form" class="rc-form">
<mat-form-field appearance="outline" class="rc-full">
<mat-label>New risk category</mat-label>
<mat-select formControlName="requestedCategory">
<mat-option [value]="1">Low</mat-option>
<mat-option [value]="2">Medium</mat-option>
<mat-option [value]="3">High</mat-option>
</mat-select>
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="rc-full">
<mat-label>Reason</mat-label>
<textarea
matInput
formControlName="reason"
rows="3"
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
></textarea>
<mat-hint>Recorded against the customer's risk history</mat-hint>
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
<p class="rc-effect">
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
date are updated together.
</p>
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
{{ submitting ? 'Sending…' : 'Send for Approval' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.rc-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 14px;
background: #f8fafc;
}
.rc-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 3px 0;
}
.rc-label {
color: #64748b;
font-size: 12.5px;
}
.rc-value {
color: #1e293b;
font-size: 13.5px;
}
.rc-reason {
font-size: 12.5px;
line-height: 1.5;
color: #334155;
margin: 0 0 14px;
}
.rc-form {
display: flex;
flex-direction: column;
}
.rc-full {
width: 100%;
}
.rc-effect {
font-size: 12.5px;
line-height: 1.55;
color: #1e3a8a;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
padding: 10px 12px;
margin: 4px 0 0;
}
.rc-error {
margin: 12px 0 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 12.5px;
}
`
]
})
export class RiskChangeDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
form: FormGroup;
submitting = false;
errorMessage = '';
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
this.form = this.fb.group({
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
// customer and the computation has not.
requestedCategory: [
data.recommendedCategory ?? null,
Validators.required
],
reason: [
'',
Validators.required
]
});
}
labelOf(category: number | null): string {
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
}
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
const v = this.form.value;
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
next: () => {
this.submitting = false;
this.snackBar.open(
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
'Close',
{ duration: 5000 }
);
this.dialogRef.close(true);
},
error: (err: any) => {
this.submitting = false;
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
}
});
}
}
+20 -4
View File
@@ -372,13 +372,29 @@ export const MATERIALITY = {
* Rules whose seriousness does not depend on the amount. A sanctions match or a
* deliberate structuring pattern is grave at any value, so activity being small
* is not a reason to bring the rating down.
*
* Matched as prefixes, not as whole codes. The rule set names these by variant —
* STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
* exact-match Set never matched any of them and the protection was dead: a
* customer caught structuring could be recommended for downgrade purely because
* the amounts involved were small, which is the one case structuring exists to
* catch.
*/
export const NON_MITIGABLE_RULES = new Set([
'SANCTIONS_HIT',
export const NON_MITIGABLE_RULE_PREFIXES = [
'SANCTIONS',
'PEP',
'STRUCTURING',
'HIGH_RISK_COUNTRY'
]);
'STRUCTURED',
'HIGH_RISK_COUNTRY',
'ADVERSE_MEDIA',
'WILFUL_DEFAULTER'
];
/** Whether a rule code names activity that stays grave however small the amounts are. */
export function isNonMitigableRule(ruleCode: string): boolean {
const code = (ruleCode || '').toUpperCase();
return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
}
/** Shape of one element of GET /aml/alerts. */
export interface LiveAmlAlert {
@@ -304,7 +304,9 @@
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
<td class="rr-move-cell">
<span class="rr-move" [class]="'rr-move--' + c.direction">
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
rating on file matches the behaviour when there is no rating on file. -->
<span class="rr-move" [class]="'rr-move--' + (c.currentRisk ? c.direction : 'unrated')">
<fa-icon [icon]="directionIcon(c)"></fa-icon>
{{ directionText(c) }}
</span>
@@ -323,15 +325,32 @@
<!-- Only the control. The verdict and its reason moved to their own column,
so this no longer wraps to two lines under the button. -->
<td class="rr-action-cell">
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)"
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
>
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button>
<!-- Raises the change for approval rather than sending the reviewer to the
KYC screen to make it themselves. A case with the Principal Officer is
held where it is until the STR concludes, so it only offers the record. -->
@if (canChangeRisk(c)) {
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same'"
(click)="changeRisk(c, $event)"
[attr.aria-label]="'Change risk category for ' + c.customerName"
>
<fa-icon icon="shield-alt"></fa-icon> Change Risk
</button>
} @else {
<!-- The rating cannot move while the STR is open, so the case itself is
what this row can usefully offer. -->
<button
type="button"
class="rr-kyc-link"
(click)="openStrCase(c, $event)"
title="The rating is held until the Principal Officer concludes the STR — open the case"
[attr.aria-label]="'Open the STR case for ' + c.customerName"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Open STR Case
</button>
}
</td>
</tr>
@@ -937,6 +937,13 @@
background: var(--r-g100);
color: var(--r-g500);
}
/* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
&--unrated {
background: var(--r-amber-l, #fef3c7);
color: var(--r-amber, #b45309);
}
}
.rr-move-why {
@@ -10,6 +10,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@@ -38,12 +39,14 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory
riskFromCategory,
riskRank
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
BehaviourWindowByClient,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
@@ -53,6 +56,7 @@ import {
monthRange,
weekRange
} from './risk-review.compute';
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
private dialog = inject(MatDialog);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
// ── Loading ────────────────────────────────────────────────────────────────
/**
* The nightly job's verdict per customer, keyed on client id.
*
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
* its own per-alert reckoning rather than showing nothing.
*/
private behaviourWindows: BehaviourWindowByClient = {};
load(): void {
this.loading = true;
this.aml
.getAlerts()
.pipe(catchError(() => of([] as LiveAmlAlert[])))
.subscribe((live) => {
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
// recommendation the screen then silently replaced.
forkJoin({
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
}).subscribe(({ alerts: live, windows }) => {
this.behaviourWindows = {};
for (const w of windows || []) {
if (w?.clientId != null) {
this.behaviourWindows[w.clientId] = w;
}
this.enrichCurrentRisk(rows);
});
}
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
this.enrichCurrentRisk(rows);
});
}
/**
@@ -336,9 +357,11 @@ export class RiskReviewReportComponent implements OnInit {
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
if (key !== this.memoKey) {
this.memo = buildRiskReview(this.alerts, this.filter);
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
this.memoKey = key;
}
return this.memo;
@@ -364,10 +387,63 @@ export class RiskReviewReportComponent implements OnInit {
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
* only a correction, and matching ratings need nothing at all.
*/
/**
* A report row for a customer the behaviour review named but no alert brought onto the report.
*
* <p>
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
* behaviourWindow, so the row can still explain itself.
*/
private rowFromWindow(w: any): RiskCustomerRow {
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
const current = RANK[w.riskCategoryAtReview] ?? null;
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
return {
clientId: w.clientId,
customerName: w.clientName || `Client ${w.clientId}`,
accountNo: w.accountNo || '',
branchCode: w.officeName || '',
currentRisk: current,
alerts: [],
alertCount: 0,
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
largestAmount: Number(w.largestTxn || 0),
proposedRisk: recommended,
recommendedRisk: recommended,
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
directionReason: w.suggestionReason || '',
ruleLabels: [],
firstAlertDate: '',
lastAlertDate: '',
openCount: 0,
escalated: false,
needsUpgrade: w.suggestedDirection === 'UP',
needsDowngrade: w.suggestedDirection === 'DOWN',
behaviourWindow: w
};
}
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
const escalated = rows.filter((c) => c.escalated);
const rest = rows.filter((c) => !c.escalated);
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
// the Principal Officer with nothing to do.
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
// Customers the nightly review says to move who raised no alert inside the filter window.
//
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
// would sit in the behaviour table with a verdict nobody ever sees.
const onReport = new Set(rows.map((r) => r.clientId));
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
.map((w: any) => this.rowFromWindow(w));
const groups: RiskCustomerGroup[] = [
{
@@ -378,25 +454,42 @@ export class RiskReviewReportComponent implements OnInit {
rows: escalated
},
{
key: 'up',
title: 'Rating needs raising',
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
key: 'change',
title: 'Change needed',
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
tone: 'up',
rows: rest.filter((c) => c.direction === 'up')
rows: [
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
...offReport.filter((c) => c.direction === 'up'),
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
...offReport.filter((c) => c.direction === 'down')
]
},
{
key: 'down',
title: 'Rating can be lowered',
hint: 'Activity no longer supports the rating on file.',
tone: 'down',
rows: rest.filter((c) => c.direction === 'down')
// Its own section, and above "no change needed", because it is the opposite of that. An unrated customer
// whose money is moving has no rating for the behaviour to be compared against, so nothing here can say
// whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the
// one action the report was asking for.
key: 'unrated',
title: 'Rating needed',
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',
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
// these customers were fine, which is not what it means: a High-rated customer with four open alerts is on
// this list, and the reason is that the conditions for lowering the rating are not met - not that there is
// nothing to look at. The reason on each row says which condition fell short.
key: 'same',
title: 'No change needed',
hint: 'The rating on file already matches the behaviour. Listed for the record.',
title: 'Rating holds — no change this period',
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.',
tone: 'hold',
rows: rest.filter((c) => c.direction === 'same')
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
}
];
@@ -575,6 +668,67 @@ export class RiskReviewReportComponent implements OnInit {
]);
}
/**
* Sends a case that is with the Principal Officer to the STR queue.
*
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
* where the one action they might take is the one thing they must not do yet.
*/
openStrCase(c: RiskCustomerRow, event: Event): void {
event.stopPropagation();
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
}
/**
* Whether this row's rating can be moved from here.
*
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
* until the STR concludes, which is what the section heading on that group says. Offering the
* button there would invite a change nobody is in a position to make yet.
*/
canChangeRisk(c: RiskCustomerRow): boolean {
return !c.escalated;
}
/**
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
* it themselves.
*
* The button used to navigate away: the reviewer left the report, found the customer, edited the
* record, and the change took effect on their say-so alone with no second pair of eyes and no
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
* checker like every other decision of that weight.
*/
changeRisk(c: RiskCustomerRow, event: Event): void {
event.stopPropagation();
if (!this.canChangeRisk(c)) {
return;
}
this.dialog
.open(RiskChangeDialogComponent, {
width: '520px',
maxWidth: '95vw',
disableClose: true,
data: {
clientId: c.clientId,
customerName: c.customerName,
currentCategory: riskRank(c.currentRisk) || null,
recommendedCategory: riskRank(c.recommendedRisk) || null,
recommendationReason: c.directionReason
} as RiskChangeDialogData
})
.afterClosed()
.subscribe((sent) => {
if (sent) {
// The rating has not moved yet - only a request exists - so the figures on screen are
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
// server would refuse.
this.load();
}
});
}
/**
* What the reviewer is going to the KYC screen to do.
*
@@ -589,25 +743,41 @@ export class RiskReviewReportComponent implements OnInit {
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
if (c.escalated) {
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
// needed doing.
if (c.escalated && c.direction === 'same') {
return 'Held — with Principal Officer';
}
if (c.direction === 'up') {
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
// one glance, and on an exported sheet it still says what happened without the column beside it.
if (c.direction === 'up' || c.direction === 'down') {
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
}
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
}
directionIcon(c: RiskCustomerRow): string {
if (c.escalated) {
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
// rating still has to be moved.
if (c.escalated && c.direction === 'same') {
return 'lock';
}
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
// unregistered icon throws at render and takes the whole row down with it.
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
if (c.direction === 'up') {
return 'arrow-up';
}
if (c.direction === 'down') {
return 'arrow-down';
}
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
return c.currentRisk ? 'check' : 'exclamation-circle';
}
/**
+87 -15
View File
@@ -10,7 +10,7 @@ import {
AmlRuleMeta,
CTR_THRESHOLD,
MATERIALITY,
NON_MITIGABLE_RULES,
isNonMitigableRule,
REVIEW_STATUS_META,
RISK_LABEL,
RiskAlertRow,
@@ -109,6 +109,8 @@ export interface RiskCustomerRow {
needsUpgrade: boolean;
/** True when the rating on record is higher than the activity warrants. */
needsDowngrade: boolean;
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
behaviourWindow?: BehaviourWindowByClient[number] | null;
}
export interface RiskReviewSummary {
@@ -158,7 +160,7 @@ function recommendRating(
ruleCodes: string[]
): { risk: RiskLevel; reason: string } {
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
@@ -271,7 +273,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
* escalated, then rated below what the rules point at, then most alerts —
* come to the top of the worklist.
*/
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
/**
* The behaviour window the nightly job computed for one customer, where there is one.
*
* Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
* run - falls back to the per-alert reckoning below, so the report renders either way.
*/
export interface BehaviourWindowByClient {
[clientId: number]: {
clientId: number;
clientName: string | null;
accountNo: string | null;
officeName: string | null;
riskCategoryAtReview: number | null;
txnCount: number | null;
distinctMonths: number | null;
windowFrom: any;
windowTo: any;
creditTotal: number | null;
debitTotal: number | null;
largestTxn: number | null;
cashTotal: number | null;
alertCount: number | null;
strCaseCount: number | null;
suggestedDirection: string | null;
suggestedCategory: number | null;
suggestionReason: string | null;
};
}
export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
const byClient = new Map<number, RiskAlertView[]>();
for (const v of views) {
const list = byClient.get(v.clientId);
@@ -297,19 +328,53 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
const largestAmount = Math.max(...alerts.map((a) => a.amount));
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
const recommendation = recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
);
// The server's own reckoning, where the nightly job has produced one.
//
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
// lowering it.
//
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
// reckoning below still stands, so the report never renders blank while the two are in step.
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
const raw = windows[clientId];
const staleWindow =
raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
const window = staleWindow ? undefined : raw;
const recommendation = window?.suggestedDirection
? {
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
reason: window.suggestionReason ?? ''
}
: recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
);
// Where the customer carries no rating there is nothing to move, so an
// unrated customer is never counted as an upgrade or a downgrade.
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
const direction: RatingDirection = window?.suggestedDirection
? window.suggestedDirection === 'UP'
? 'up'
: window.suggestedDirection === 'DOWN'
? 'down'
: 'same'
: delta > 0
? 'up'
: delta < 0
? 'down'
: 'same';
rows.push({
clientId,
@@ -332,7 +397,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
escalated,
needsUpgrade: direction === 'up',
needsDowngrade: direction === 'down'
needsDowngrade: direction === 'down',
// Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
// asked to approve has to show its working, and the alerts on this row are not it.
behaviourWindow: window ?? null
});
}
@@ -461,9 +529,13 @@ export interface RiskReviewModel {
* the summary all run once per filter change rather than once per template
* binding, which matters because every view reads from the same result.
*/
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
export function buildRiskReview(
alerts: RiskAlertRow[],
filter: RiskReviewFilter,
windows: BehaviourWindowByClient = {}
): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
return {
alerts: narrowed.views,
@@ -232,6 +232,12 @@
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span>
</a>
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
re-KYC due date together, so it belongs with the other maker-checker queues. -->
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
<span>Risk Category Changes</span>
</a>
</nav>
<div class="ac-tab-panel">
+6
View File
@@ -243,6 +243,12 @@ const routes: Routes = [
(m) => m.DdRevokeApprovalsComponent
),
data: { title: 'DD Revoke Approvals' }
},
{
path: 'risk-change-approvals',
loadComponent: () =>
import('../risk-review/risk-change-approvals.component').then((m) => m.RiskChangeApprovalsComponent),
data: { title: 'Risk Category Approvals' }
}
]
},
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260914',
'hash': 'ee5a430b'
'version': '260916',
'hash': 'c01615ee'
},
'allow_switching_backend_instance': true
};