diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts
index 76bbc1905..047fed18c 100644
--- a/src/app/aml/aml.service.ts
+++ b/src/app/aml/aml.service.ts
@@ -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.
+ *
+ *
+ * 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 {
+ 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 {
return this.http.get(`/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 {
+ return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
+ }
+
+ getRiskChangeRequests(status?: string): Observable {
+ const params = status ? new HttpParams().set('status', status) : undefined;
+ return this.http.get('/aml/risk-change-requests', { params });
+ }
+
+ approveRiskChange(requestId: number): Observable {
+ return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
+ }
+
+ rejectRiskChange(requestId: number, rejectionReason: string): Observable {
+ 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 {
+ return this.http.get('/risk-review/behaviour-windows');
+ }
}
diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html
new file mode 100644
index 000000000..ec7b8bf32
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
Board of Resolution Register — Share Capital
+
+ Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
+ record sort to the top.
+
+ @if (r.resolutionNumber) {
+ {{ r.resolutionNumber }}
+ } @else {
+ Not recorded
+ }
+
+
{{ r.resolutionDate || '—' }}
+
{{ r.accountNo }}
+
{{ r.cifNumber || '—' }}
+
{{ r.clientName }}
+
{{ r.officeName || '—' }}
+
{{ r.productName || '—' }}
+
{{ r.approvedShares | number }}
+
{{ r.shareValue | number: '1.2-2' }}
+
{{ r.trancheCount }}
+
{{ statusText(r.statusEnum) }}
+
{{ r.activatedDate || '—' }}
+
+ } @empty {
+
+
No share account matches the filters.
+
+ }
+
+
+
+ }
+
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss
new file mode 100644
index 000000000..a21ca4608
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.scss
@@ -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;
+}
diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts
new file mode 100644
index 000000000..776a4ad40
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.ts
@@ -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 = {
+ 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 {
+ 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;
+ }
+ }
+}
diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts
new file mode 100644
index 000000000..b75370e8c
--- /dev/null
+++ b/src/app/board-resolution/bor-report.service.ts
@@ -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 {
+ 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('/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)
+ };
+ }
+}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
index dd1e065c3..44b97a309 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
@@ -1142,6 +1142,46 @@
+
+
+
+
Risk Profile
+ Used by the periodic risk review
+
+
+
+
+ Politically Exposed Person
+ Holds the rating at High; never lowered on ordinary activity
+
+
+ Sanctions / watch-list match
+ UNSC, MHA or internal watch list
+
+
+ Adverse media / regulatory action
+ FIR, court proceedings or published action
+
+
+
+
+
+ Declared annual income (₹)
+
+ As stated by the customer
+
+
+
+ Declared annual turnover (₹)
+
+ For a business customer
+
+
+
+
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
index 0f4520920..032060e25 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
@@ -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;
+}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
index ecfdd0210..84743bb5b 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
@@ -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.
+ *
+ *
+ * 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
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index cf61082bd..7d8c63ad2 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -310,41 +310,6 @@
}
}
-
-
-
- CASH₹14,82,500
- │
-
- TXN TODAY
- 1,247
- ↑ 8.2%
-
- │
- CLEARING IN42
- │
- CLEARING OUT38
- │
-
- NPA·GROSS
- 3.47%
- ↑ 0.12
-
- │
- CASA58.2%
- │
- CD RATIO71.8%
- │
-
- RBI CIRC
- DBR.AML.NO.4/14.01.001
- NEW
-
- │
- REPO6.50%
- │
-
-
-
-
-
- LIVE
-
-
-
-
-
-
-
+
}
@@ -713,6 +671,9 @@
+
+
+
Evidence relied on
+
+ {{ t }}
+
+
Tick at least one piece of evidence
+
+
+ Reference (optional)
+
+
+
+
+
+ {{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }}
+
+ A reason is required
+
+
+
+
+ {{
+ 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.'
+ }}
+
+
+
{{ errorMessage }}
+
+
+
+
+
+ `,
+ 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);
+
+ 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);
+ });
+ }
+}
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html
index 4470f0f0d..d566f2142 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html
@@ -60,23 +60,113 @@
-
+
-
+
-
0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
+
+
-
+
+
+
-
+
+@if (selectedCount > 0) {
+
+ {{ selectedCount }} selected
+ Low / medium only — a transaction that tripped a high-severity rule is decided on its own.
+
+
+
+
+
+ @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.
+ }
+
+
+ }
@@ -132,7 +358,7 @@
Action
-
+
@@ -180,15 +406,33 @@
-
+
+
+
-
+
+
- {{ activeStatus ? 'filter_alt_off' : 'verified_user' }}
- {{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}
+ No transactions flagged. The feed updates automatically as the engine raises alerts.
- No alerts in this status.
+ No alerts match the filters in force.
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss
index 67a98fce5..2070f6a36 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss
@@ -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;
+}
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
index 87cec7b0b..df87f805a 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
@@ -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([]);
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.
+ *
+ *
+ * Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
+ * a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
+ * a time is what stops an officer getting to the one alert that matters. High severity is
+ * deliberately excluded: an alert that reached the high band is a judgement about that customer's
+ * transaction, and it gets its own decision.
+ */
+ selectedIds = new Set();
+
+ /** m_client_risk_rule.risk_category — 3 is the high band. */
+ private static readonly HIGH_SEVERITY = 3;
+
+ /**
+ * Whether any rule this transaction matched reached the high band.
+ *
+ *
+ * Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
+ * raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
+ * everything — so a high-severity rule can sit in "also matched" while the row itself reports the
+ * CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
+ * transaction up for a batch decision.
+ */
+ isHighSeverity(alert: any): boolean {
+ return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
+ }
+
+ /**
+ * The band this alert counts as, for the summary tiles.
+ *
+ *
+ * The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
+ * riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
+ * "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
+ * its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
+ */
+ private severityBand(alert: any): number {
+ const worst = Number(alert?.highestSeverity);
+ if (worst === 1 || worst === 2 || worst === 3) {
+ return worst;
+ }
+ const governing = Number(alert?.riskCategory);
+ if (governing === 1 || governing === 2 || governing === 3) {
+ return governing;
+ }
+ // 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
+ // NaN band would leave that tile showing a count it could never select.
+ return 0;
+ }
+
+ /**
+ * Only an open alert whose every matched rule sits in the low or medium band can be swept up with
+ * others.
+ *
+ *
+ * Two things are held back. A transaction that tripped any high-severity rule, because that is a
+ * judgement about the transaction itself. And one where no matched rule carried a severity at
+ * all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
+ * rather than suspicions. Neither belongs in a batch disposed of on a single reason.
+ */
+ isSelectable(alert: any): boolean {
+ const band = Number(alert?.highestSeverity);
+ const graded = band === 1 || band === 2;
+ return this.canReview(alert) && graded;
+ }
+
+ /** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
+ expandedId: number | null = null;
+
+
+ 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.
+ *
+ *
+ * Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
+ * it, so an older row still expands to something rather than appearing to have matched nothing.
+ */
+ matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
+ if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
+ return alert.matchedRules;
+ }
+ const rules = [
+ {
+ ruleCode: alert?.ruleCode,
+ ruleDescription: alert?.ruleDescription,
+ riskCategory: alert?.riskCategory ?? null,
+ governing: true
+ }
+ ];
+ for (const code of String(alert?.alsoMatchedRules || '')
+ .split(',')
+ .map((c: string) => c.trim())
+ .filter((c: string) => c)) {
+ rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
+ }
+ return rules;
+ }
+
+ /** How many rules the transaction matched, for the count shown on the collapsed row. */
+ matchedRuleCount(alert: any): number {
+ return this.matchedRules(alert).length;
+ }
+
+ severityLabel(band: number | null | undefined): string {
+ switch (Number(band)) {
+ case 1:
+ return 'Low';
+ case 2:
+ return 'Medium';
+ case 3:
+ return 'High';
+ default:
+ return 'Statutory';
+ }
+ }
+
+ /**
+ * Rule codes ticked inside the open detail pane.
+ *
+ *
+ * Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
+ * and carrying them across would apply one transaction's selection to another's rules.
+ */
+ selectedRuleCodes = new Set();
+
+ isRuleSelected(rule: any): boolean {
+ return this.selectedRuleCodes.has(rule.ruleCode);
+ }
+
+ /**
+ * Whether this rule is the officer's to decide.
+ *
+ *
+ * A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
+ * rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
+ * amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
+ * does not stop the CTR, and escalating it does not describe what the rule found.
+ */
+ isRuleSelectable(rule: any): boolean {
+ return !rule?.disposition && this.isGradedRule(rule);
+ }
+
+ /** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
+ isGradedRule(rule: any): boolean {
+ const band = Number(rule?.riskCategory);
+ return band === 1 || band === 2 || band === 3;
+ }
+
+ toggleRuleSelected(rule: any): void {
+ if (!this.isRuleSelectable(rule)) {
+ return;
+ }
+ if (this.selectedRuleCodes.has(rule.ruleCode)) {
+ this.selectedRuleCodes.delete(rule.ruleCode);
+ } else {
+ this.selectedRuleCodes.add(rule.ruleCode);
+ }
+ }
+
+ selectableRulesOf(alert: any): any[] {
+ return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
+ }
+
+ get selectedRuleCount(): number {
+ return this.selectedRuleCodes.size;
+ }
+
+ allRulesSelected(alert: any): boolean {
+ const open = this.selectableRulesOf(alert);
+ return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
+ }
+
+ toggleAllRules(alert: any): void {
+ if (this.allRulesSelected(alert)) {
+ this.selectedRuleCodes.clear();
+ return;
+ }
+ for (const rule of this.selectableRulesOf(alert)) {
+ this.selectedRuleCodes.add(rule.ruleCode);
+ }
+ }
+
+ ruleDispositionLabel(rule: any): string {
+ return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
+ }
+
+ severityClass(band: number | null | undefined): string {
+ switch (Number(band)) {
+ case 1:
+ return 'sev-low';
+ case 2:
+ return 'sev-medium';
+ case 3:
+ return 'sev-high';
+ default:
+ return 'sev-none';
+ }
+ }
+
+ isSelected(alert: any): boolean {
+ return this.selectedIds.has(alert.id);
+ }
+
+ toggleSelected(alert: any): void {
+ if (!this.isSelectable(alert)) {
+ return;
+ }
+ if (this.selectedIds.has(alert.id)) {
+ this.selectedIds.delete(alert.id);
+ } else {
+ this.selectedIds.add(alert.id);
+ }
+ }
+
+ /** The rows a "select all" would act on: what is on screen, open, and below the high band. */
+ get selectableRows(): any[] {
+ return this.alerts.data.filter((a) => this.isSelectable(a));
+ }
+
+ get allSelectableSelected(): boolean {
+ const rows = this.selectableRows;
+ return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
+ }
+
+ toggleSelectAll(): void {
+ if (this.allSelectableSelected) {
+ this.selectedIds.clear();
+ return;
+ }
+ for (const a of this.selectableRows) {
+ this.selectedIds.add(a.id);
+ }
+ }
+
+ get selectedCount(): number {
+ return this.selectedIds.size;
+ }
+
+ private selectedAlerts(): any[] {
+ return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
+ }
+
+ /**
+ * Drops ticks that no longer stand, after every poll.
+ *
+ *
+ * The feed refreshes itself every few seconds while the officer works. Without this, an alert
+ * another officer disposed of in the meantime — or one the status filter has since hidden — stays
+ * in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
+ * screen, and the batch carries alerts nobody chose.
+ */
+ private pruneSelection(): void {
+ const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
+ for (const id of Array.from(this.selectedIds)) {
+ if (!stillSelectable.has(id)) {
+ this.selectedIds.delete(id);
+ }
+ }
+ // Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
+ // leave the table holding a pane for a row that is no longer on it.
+ if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
+ this.expandedId = null;
+ }
+ }
+
/** Every alert as returned, before the status filter narrows the table. */
private allAlerts: any[] = [];
@@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
activeStatus = '';
private statusCounts: Record = {};
+ /**
+ * 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.
+ *
+ *
+ * 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.
+ *
+ *
+ * Each still goes through the review dialog's own requirements - a reason, and for a whitelist
+ * the evidence behind it - asked once and recorded against every alert in the batch. That is the
+ * point of the batch: the officer is making one decision about one kind of activity, not the same
+ * decision twenty times.
+ */
+ /**
+ * Applies one disposition to the rules ticked inside the open pane.
+ *
+ *
+ * The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
+ * leaves the case open on the sixth rather than closing it.
+ */
+ reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
+ const codes = Array.from(this.selectedRuleCodes);
+ if (!codes.length) {
+ return;
+ }
+ this.dialog
+ .open(AmlReviewAlertDialogComponent, {
+ width: '560px',
+ maxWidth: '95vw',
+ autoFocus: false,
+ disableClose: true,
+ data: { alert, disposition, ruleCodes: codes }
+ })
+ .afterClosed()
+ .subscribe((result: string | undefined) => {
+ if (!result) {
+ return;
+ }
+ this.alertService.alert({
+ type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
+ message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
+ disposition === 'WHITELIST' ? 'cleared' : 'escalated'
+ }.`
+ });
+ this.selectedRuleCodes.clear();
+ // The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
+ this.fetch();
+ });
+ }
+
+ reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
+ const batch = this.selectedAlerts();
+ if (!batch.length) {
+ return;
+ }
+ this.dialog
+ .open(AmlBulkReviewDialogComponent, {
+ width: '620px',
+ maxWidth: '95vw',
+ autoFocus: false,
+ disableClose: true,
+ data: { alerts: batch, disposition }
+ })
+ .afterClosed()
+ .subscribe((done: boolean | undefined) => {
+ if (!done) {
+ return;
+ }
+ this.selectedIds.clear();
+ this.fetch();
+ });
+ }
}
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
index c5bc66317..2d6825d64 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
@@ -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);
diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts
index 76f120463..4a6fe1a06 100644
--- a/src/app/reports/reports-routing.module.ts
+++ b/src/app/reports/reports-routing.module.ts
@@ -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' },
diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts
new file mode 100644
index 000000000..51a3e1798
--- /dev/null
+++ b/src/app/risk-review/risk-change-approvals.component.ts
@@ -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 = { 1: 'Low', 2: 'Medium', 3: 'High' };
+
+/**
+ * The approval queue for risk-category changes raised from the Risk Review Report.
+ *
+ *
+ * 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: `
+
+
+
+
Risk Category Approvals
+
+ 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.
+
+ {{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
+
+
+
+
+
+
+
+ `,
+ 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);
+
+ 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 });
+ }
+ });
+ }
+}
diff --git a/src/app/risk-review/risk-change-dialog.component.ts b/src/app/risk-review/risk-change-dialog.component.ts
new file mode 100644
index 000000000..2cd8c7915
--- /dev/null
+++ b/src/app/risk-review/risk-change-dialog.component.ts
@@ -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.
+ *
+ *
+ * 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: `
+
Change Risk Category
+
+
+
+ Customer
+ {{ data.customerName }}
+
+
+ Rating on file
+ {{ labelOf(data.currentCategory) }}
+
+
+
+
+
+ Why this came up {{ data.recommendationReason }}
+
+
+
+
+
+ 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.
+
+
+
{{ errorMessage }}
+
+
+
+
+
+ `,
+ 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);
+
+ 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.';
+ }
+ });
+ }
+}
diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts
index 633de824d..fea25ea07 100644
--- a/src/app/risk-review/risk-review-data.ts
+++ b/src/app/risk-review/risk-review-data.ts
@@ -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 {
diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html
index 503cb0dad..9bef269f2 100644
--- a/src/app/risk-review/risk-review-report.component.html
+++ b/src/app/risk-review/risk-review-report.component.html
@@ -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. -->
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss
index c4f9b6066..db236d881 100644
--- a/src/app/risk-review/risk-review-report.component.scss
+++ b/src/app/risk-review/risk-review-report.component.scss
@@ -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 {
diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts
index 8f0775fc3..3b4fd2fb4 100644
--- a/src/app/risk-review/risk-review-report.component.ts
+++ b/src/app/risk-review/risk-review-report.component.ts
@@ -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.
+ *
+ *
+ * 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 = { 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';
}
/**
diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts
index 67499a2bc..558b80ab5 100644
--- a/src/app/risk-review/risk-review.compute.ts
+++ b/src/app/risk-review/risk-review.compute.ts
@@ -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();
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,
diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html
index 9a5bb2c85..03f8fa26a 100644
--- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html
+++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html
@@ -232,6 +232,12 @@
DD Revoke Requests
+
+
+
+ Risk Category Changes
+