Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest

# Conflicts:
#	src/environments/.env.ts
This commit is contained in:
2026-09-18 13:15:48 +05:30
14 changed files with 862 additions and 233 deletions
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Create financial activity mapping component.
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/
setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
});
}
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Edit financial activity mapping component.
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/
setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
});
}
@@ -0,0 +1,38 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The GL accounts a financial activity may be mapped to.
*
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
* the id instead, listing the ones they knew:
*
* case 100, 101, 102, 103 → asset
* case 200, 201 → liability
* case 300 → equity
*
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
* of step with the backend.
*
* @param activities the template's financialActivityOptions
* @param activityId the activity the user picked
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
*/
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
const activity = (activities || []).find((a) => a?.id === activityId);
const accountType = activity?.mappedGLAccountType;
if (!accountType || !glAccountOptions) {
return [];
}
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
}
+53 -86
View File
@@ -167,7 +167,9 @@
</div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<div class="dc-meta">
Due: <strong>{{ ctrDueDateLabel }}</strong>
</div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -220,9 +222,7 @@
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -432,87 +432,17 @@
}
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
their own register below. -->
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="ESCALATED">Escalated</option>
</select>
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
<div class="fiu-note">
<fa-icon icon="info-circle"></fa-icon>
<span>
Alerts are triaged in <strong>RBI &rsaquo; AML Rules Master &rsaquo; Flagged Transactions</strong>. What an
officer escalates there arrives here as an STR case.
</span>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
evidence recorded at review time. -->
@@ -590,7 +520,7 @@
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
@for (sc of liveStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
@@ -607,13 +537,50 @@
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Filed cases are finished work, not outstanding work, so they sit apart from the live queue — but they are
the bank's record of what went to FIU-IND and against which reference, so they stay visible. -->
@if (filedStrCases.length) {
<div class="fiu-form-meta" style="margin-top: 18px">
<div class="fiu-form-title">
<h3>Filed with FIU-IND</h3>
<p>Reported and closed — kept for the record, with the reference the filing was made under.</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Conclusion</th>
<th>Filed On</th>
<th>FIU Reference</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of filedStrCases; track sc.id) {
<tr>
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}</td>
<td class="mono">{{ sc.strReferenceNo || '—' }}</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">View</button></td>
</tr>
}
</tbody>
</table>
</div>
}
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
+26 -56
View File
@@ -220,13 +220,29 @@ export class FiuComponent implements OnInit {
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
/**
* The cases still carrying work, which is what "Live" means here.
*
* <p>
* A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
* is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
* one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
* the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
*/
get liveStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status !== 'FILED');
}
/** Cases already filed with FIU-IND, kept for the record rather than as work. */
get filedStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status === 'FILED');
}
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
@@ -633,10 +649,9 @@ export class FiuComponent implements OnInit {
}
/**
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
* another.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
@@ -679,7 +694,8 @@ export class FiuComponent implements OnInit {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
err?.error?.defaultUserMessage ||
'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
@@ -779,48 +795,6 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
});
}
escalateAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
},
error: this.apiError(`Escalating alert #${alert.id}`)
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
@@ -1039,12 +1013,6 @@ export class FiuComponent implements OnInit {
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
@@ -1707,7 +1675,9 @@ export class FiuComponent implements OnInit {
private writeLiveStrXml(cases: any[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(
`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`
);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
@@ -57,8 +57,8 @@ export interface AmlBulkReviewData {
</h2>
<mat-dialog-content>
<p class="bk-intro">
The same decision, reason and evidence are recorded against each alert below. Every one stays
individually auditable.
The same decision, reason and evidence are recorded against each alert below. Every one stays individually
auditable.
</p>
<div class="bk-list">
@@ -218,7 +218,9 @@ export class AmlBulkReviewDialogComponent {
submitting = false;
errorMessage = '';
constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) {
data: AmlBulkReviewData = inject(MAT_DIALOG_DATA);
constructor() {
this.form = this.fb.group({
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
evidenceRef: [
@@ -36,6 +36,17 @@
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
<mat-icon>refresh</mat-icon>
</button>
<!-- Exports what is on screen, not the whole feed: the workbook has to reconcile with the report it came from. -->
<button
type="button"
class="export-btn"
[disabled]="exporting || !alerts.data.length"
[matTooltip]="alerts.data.length ? 'Download the alerts currently in view as Excel' : 'Nothing in view to export'"
(click)="exportExcel()"
>
<mat-icon>file_download</mat-icon>
{{ exporting ? 'Preparing…' : 'Excel' }}
</button>
</div>
</div>
@@ -44,6 +55,52 @@
<span>Could not reach the alert feed. Retrying automatically.</span>
</div>
<!-- Narrow the feed to one day. The feed accumulates, so without this an officer asking "what did we decide today"
reads past a week of older alerts to find out. Composes with the status chips below: Today + Escalated answers
"what did we mark suspicious today". -->
<div class="status-filters">
<span class="status-filters-label">Period</span>
@for (m of periodModes; track m.key) {
<button type="button" class="status-chip" [class.active]="periodMode === m.key" (click)="setPeriodMode(m.key)">
{{ m.label }}
</button>
}
<!-- One control per mode, so the picker always matches the granularity chosen. A week is picked by naming any day
inside it; the resolved Monday-to-Sunday span is printed beside it so the officer sees what was selected. -->
@if (periodMode === 'day') {
<input type="date" class="day-input" [(ngModel)]="filterDay" (ngModelChange)="onPeriodChange()" aria-label="Day" />
}
@if (periodMode === 'week') {
<input
type="date"
class="day-input"
[(ngModel)]="filterWeek"
(ngModelChange)="onPeriodChange()"
aria-label="Any day in the week"
/>
}
@if (periodMode === 'month') {
<input
type="month"
class="day-input"
[(ngModel)]="filterMonth"
(ngModelChange)="onPeriodChange()"
aria-label="Month"
/>
}
@if (periodMode === 'year') {
<select class="day-input" [(ngModel)]="filterYear" (ngModelChange)="onPeriodChange()" aria-label="Year">
@for (y of availableYears; track y) {
<option [value]="y">{{ y }}</option>
}
</select>
}
@if (periodLabel) {
<span class="period-resolved">{{ periodLabel }}</span>
}
</div>
<!-- Narrow the feed by where each alert sits in the review workflow -->
<div class="status-filters">
<span class="status-filters-label">Status</span>
@@ -60,6 +117,34 @@
</button>
</div>
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
below it can wait. Ranked over the period and status already chosen. -->
<div class="status-filters">
<span class="status-filters-label">Top rules</span>
@for (n of topRuleOptions; track n) {
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
{{ n === null ? 'All rules' : 'Top ' + n }}
</button>
}
@if (topRules !== null) {
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
}
</div>
<!-- Which rules are driving the queue, and by how much. Ranking without the counts narrows the table and leaves the
officer to work out what it kept. -->
@if (ruleFrequency.length > 1) {
<div class="rule-freq">
@for (r of ruleFrequency; track r.code) {
<span class="rule-freq-item">
{{ r.code }}
<span class="rule-freq-n">{{ r.count }}</span>
</span>
}
</div>
}
<!-- Severity mix of what the table is showing. Each tile narrows the table to its own band;
clicking the one already in force clears it, as does Total Flagged. -->
<div class="risk-summary">
@@ -103,7 +188,9 @@
@if (selectedCount > 0) {
<div class="bulk-bar">
<span class="bulk-count">{{ selectedCount }} selected</span>
<span class="bulk-note">Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span>
<span class="bulk-note"
>Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
<button
@@ -227,7 +314,10 @@
@if (isExpanded(alert)) {
<div class="detail-inner">
<div class="detail-head">
<span>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }}</span>
<span
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
{{ amount(alert) }}</span
>
@if (selectableRulesOf(alert).length > 1) {
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
@@ -273,15 +363,25 @@
"
matTooltipClass="evidence-tooltip"
>
{{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }}
{{
!rule.directlyDecided
? 'remove_circle_outline'
: rule.disposition === 'ESCALATE'
? 'report'
: 'verified'
}}
</mat-icon>
}
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{ severityLabel(rule.riskCategory) }}</span>
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
severityLabel(rule.riskCategory)
}}</span>
<div class="detail-rule-text">
<span class="detail-rule-code">
{{ rule.ruleCode }}
@if (rule.governing) {
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule">governing</span>
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
>governing</span
>
}
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
transaction being unreported. -->
@@ -317,10 +417,17 @@
clearing five of six rules leaves the case open on the sixth. -->
@if (selectedRuleCount > 0) {
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<span class="rule-action-count">{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span>
<span class="rule-action-count"
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
<button
mat-stroked-button
color="primary"
type="button"
(click)="reviewSelectedRules(alert, 'WHITELIST')"
>
Whitelist
</button>
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
@@ -384,23 +491,41 @@
</div>
</ng-container>
<ng-template #reviewed>
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
→ STR
</span>
</div>
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
from anyone but the officer who has it, and this says who to ask. -->
@if (heldByAnother(alert)) {
<div class="reviewed-cell">
<mat-icon
class="held-icon"
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
>
lock
</mat-icon>
<span class="held-by">with {{ heldBy(alert) }}</span>
</div>
} @else {
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span
*ngIf="alert.status === 'ESCALATED'"
class="str-routed"
matTooltip="An STR case is open for this alert"
>
→ STR
</span>
</div>
}
</ng-template>
</td>
</ng-container>
@@ -902,3 +902,112 @@
cursor: help;
user-select: none;
}
/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read
as one control strip rather than a form field dropped above a set of buttons. */
.day-input {
padding: 5px 11px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover {
border-color: #1a237e;
}
&:focus {
outline: none;
border-color: #1a237e;
}
}
/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer
has no way to tell which Monday-to-Sunday span they are looking at. */
.period-resolved {
font-size: 12px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather
than changing what is on screen. */
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 13px;
border: 1px solid #bbf7d0;
border-radius: 999px;
background: #f0fdf4;
color: #15803d;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
background: #dcfce7;
border-color: #86efac;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */
.held-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
cursor: help;
}
.held-by {
color: #64748b;
font-size: 12px;
font-style: italic;
}
/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth
filter competing with the three above it. */
.rule-freq {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 0 14px;
}
.rule-freq-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
font-size: 11px;
font-weight: 600;
color: #475569;
}
.rule-freq-n {
padding: 0 6px;
border-radius: 999px;
background: #e2e8f0;
color: #1e293b;
font-variant-numeric: tabular-nums;
}
@@ -17,15 +17,21 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, timer, of } from 'rxjs';
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
import { exportWorkbook } from '../../../cashier/cashier-export';
/** How the feed is narrowed by time. */
type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
/**
* Live feed of transactions the AML rule engine has flagged.
@@ -43,6 +49,7 @@ import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component
standalone: true,
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
@@ -58,6 +65,10 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private auth = inject(AuthenticationService);
/** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */
private readonly currentUsername = this.auth.getCredentials()?.username ?? '';
private destroy$ = new Subject<void>();
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
@@ -146,7 +157,15 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
expandedId: number | null = null;
/**
* Opens or closes the detail pane. Nothing is recorded by reading.
*
* <p>
* Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
* starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
* on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
*/
toggleExpanded(alert: any): void {
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.
@@ -164,7 +183,9 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
* it, so an older row still expands to something rather than appearing to have matched nothing.
*/
matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
matchedRules(
alert: any
): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
return alert.matchedRules;
}
@@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
{ key: 'UNDER_REVIEW', label: 'Under review' },
// No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
// chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
// canReview still treats them as open — but it is not offered as a filter nobody can populate.
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'WHITELISTED', label: 'Whitelisted' }
];
activeStatus = '';
private statusCounts: Record<string, number> = {};
/**
* The period being worked.
*
* A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
* today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
* — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
*/
readonly periodModes: { key: PeriodMode; label: string }[] = [
{ key: 'all', label: 'All time' },
{ key: 'day', label: 'Day wise' },
{ key: 'week', label: 'Week wise' },
{ key: 'month', label: 'Month wise' },
{ key: 'year', label: 'Year wise' }
];
periodMode: PeriodMode = 'all';
/** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
filterDay = '';
filterWeek = '';
filterMonth = '';
filterYear = '';
/**
* Narrows the table to the rules firing most often in the chosen period.
*
* <p>
* A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
* pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
* the count are the ones worth an officer's attention first, and the long tail below them can wait.
*
* <p>
* Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
*/
readonly topRuleOptions: (number | null)[] = [
null,
10,
20,
50
];
topRules: number | null = null;
setTopRules(n: number | null): void {
this.topRules = n;
this.applyStatusFilter(true);
}
/**
* The rule codes to keep, most-triggered first, or null when every rule is in view.
*
* <p>
* Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
* the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
* instead of shifting between polls.
*/
private topRuleCodes(pool: any[]): Set<string> | null {
if (this.topRules === null) {
return null;
}
const counts = new Map<string, number>();
for (const a of pool) {
const code = a?.ruleCode;
if (code) {
counts.set(code, (counts.get(code) ?? 0) + 1);
}
}
if (counts.size <= this.topRules) {
return null;
}
const ranked = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, this.topRules)
.map(([code]) => code);
return new Set(ranked);
}
/** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
get distinctRuleCount(): number {
return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
}
/**
* The rules in view with how often each fired, busiest first.
*
* <p>
* The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
* Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
*/
get ruleFrequency(): { code: string; count: number }[] {
const counts = new Map<string, number>();
for (const a of this.alerts.data) {
if (a?.ruleCode) {
counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
}
}
return Array.from(counts.entries())
.map(
([
code,
count
]) => ({ code, count })
)
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
}
setPeriodMode(mode: PeriodMode): void {
this.periodMode = mode;
// Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
// empty field the officer has to fill before anything happens.
const now = new Date();
const iso = this.isoOf(now);
if (mode === 'day' && !this.filterDay) {
this.filterDay = iso;
}
if (mode === 'week' && !this.filterWeek) {
this.filterWeek = iso;
}
if (mode === 'month' && !this.filterMonth) {
this.filterMonth = iso.slice(0, 7);
}
if (mode === 'year' && !this.filterYear) {
this.filterYear = String(now.getFullYear());
}
this.applyStatusFilter(true);
}
onPeriodChange(): void {
this.applyStatusFilter(true);
}
/** The years the feed actually covers, newest first, so the picker offers nothing empty. */
get availableYears(): string[] {
const years = new Set<string>();
for (const a of this.allAlerts) {
if (a.raisedOnDate) {
years.add(String(a.raisedOnDate.getFullYear()));
}
}
const list = Array.from(years).sort().reverse();
return list.length ? list : [String(new Date().getFullYear())];
}
/** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
get periodLabel(): string {
const range = this.periodRange();
if (!range) {
return '';
}
return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
}
private isoOf(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* The chosen period as an inclusive from/to, or null for all time.
*
* Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
* the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
*/
private periodRange(): { from: string; to: string } | null {
switch (this.periodMode) {
case 'day':
return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
case 'week': {
if (!this.filterWeek) {
return null;
}
const [
y,
m,
d
] = this.filterWeek.split('-').map(Number);
const picked = new Date(y, m - 1, d);
const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
const monday = new Date(y, m - 1, d - backToMonday);
const sunday = new Date(y, m - 1, d - backToMonday + 6);
return { from: this.isoOf(monday), to: this.isoOf(sunday) };
}
case 'month': {
if (!this.filterMonth) {
return null;
}
const [
y,
m
] = this.filterMonth.split('-').map(Number);
// Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
}
case 'year':
return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
default:
return null;
}
}
/** Whether an alert was raised inside the chosen period. */
private inActivePeriod(alert: any): boolean {
const range = this.periodRange();
if (!range) {
return true;
}
const d: Date | null = alert?.raisedOnDate;
if (!d) {
return false;
}
const iso = this.isoOf(d);
return iso >= range.from && iso <= range.to;
}
/**
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
@@ -530,11 +764,16 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* it — doing that on the first pass left pageSize at 0 and emptied the table.
*/
private applyStatusFilter(resetPage = false): void {
const byStatus = this.activeStatus
? this.allAlerts.filter((a) => a.status === this.activeStatus)
: this.allAlerts;
this.alerts.data =
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
const byStatus = this.activeStatus ? byDay.filter((a) => a.status === this.activeStatus) : byDay;
const bySeverity =
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
// Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
// the ten busiest overall then filtered down to a handful.
const keep = this.topRuleCodes(bySeverity);
this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity;
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
@@ -573,15 +812,119 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* add up to the total while one of them is narrowing the table, and the total reads as the way
* back to all of them.
*/
exporting = false;
/**
* Exports exactly what the table is showing.
*
* <p>
* Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
* the period, status and severity in the file. A workbook that quietly held more than the screen did would be
* worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
* on has no way to tell.
*
* <p>
* The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
* with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
*/
async exportExcel(): Promise<void> {
const rows = this.alerts.data;
if (!rows.length || this.exporting) {
return;
}
this.exporting = true;
try {
const scope = [
this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
`Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
`Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
`Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
].join(' · ');
await exportWorkbook(
[
{
sheetName: 'Flagged Transactions',
title: 'AML — Flagged Transactions',
subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
headers: [
'Alert ID',
'Raised On',
'Customer',
'Customer ID',
'Transaction',
'Txn ID',
'Governing Rule',
'Rule Description',
'All Rules Matched',
'Severity',
'Amount',
'Status',
'Reviewed By',
'Review Notes'
],
rows: rows.map((a) => [
a.id ?? null,
a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
a.clientDisplayName || '',
a.clientId ?? null,
a.transactionType || '',
a.transactionId ?? null,
a.ruleCode || '',
a.ruleDescription || '',
// Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
// hide a high-severity finding that a reader of the sheet would need.
this.matchedRules(a)
.map((r) => r.ruleCode)
.join(', '),
this.severityLabel(this.severityBand(a)),
Number(a.amount) || 0,
a.status || '',
a.reviewedByUsername || '',
a.reviewNotes || ''
]),
widths: [
9,
20,
24,
12,
12,
10,
30,
38,
44,
11,
15,
14,
14,
50
]
}
],
`AML-Flagged-${this.periodLabel || 'all-time'}`
);
} finally {
this.exporting = false;
}
}
private statusLabel(key: string): string {
return this.statusFilters.find((s) => s.key === key)?.label ?? key;
}
get summaryTotal(): number {
return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length;
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length;
}
private recomputeStatusCounts(): void {
this.statusCounts = { '': this.allAlerts.length };
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
// A chip whose figure describes a different set from the table under it is worse than no figure.
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
this.statusCounts = { '': pool.length };
for (const s of this.statusFilters) {
if (s.key) {
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length;
}
}
}
@@ -633,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
return false;
}
// An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
// anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
return !this.heldByAnother(alert);
}
/** True when this alert is under review by somebody other than the signed-in officer. */
heldByAnother(alert: any): boolean {
return (
alert?.status === 'UNDER_REVIEW' &&
!!alert?.reviewedByUsername &&
alert.reviewedByUsername !== this.currentUsername
);
}
/** Who has the alert, for the message shown in place of the action buttons. */
heldBy(alert: any): string {
return alert?.reviewedByUsername || 'another officer';
}
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
@@ -196,7 +196,12 @@ export class AmlReviewAlertDialogComponent {
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
// 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.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: () => {
@@ -92,7 +92,9 @@ const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High
</td>
<td class="rca-reason">{{ r.reason }}</td>
<td>
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{ r.status }}</span>
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{
r.status
}}</span>
</td>
<td>{{ r.makerUsername }}</td>
<td class="mono">{{ r.createdDate }}</td>
@@ -250,7 +252,12 @@ export class RiskChangeApprovalsComponent implements OnInit {
private snackBar = inject(MatSnackBar);
rows: any[] = [];
readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', ''];
readonly statuses = [
'PENDING',
'APPROVED',
'REJECTED',
''
];
selectedStatus = 'PENDING';
ngOnInit(): void {
@@ -363,11 +370,15 @@ export class RejectRiskChangeDialogComponent {
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
data: { request: any } = inject(MAT_DIALOG_DATA);
form: FormGroup;
busy = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
this.form = this.fb.group({ rejectionReason: ['', Validators.required] });
constructor() {
this.form = this.fb.group({ rejectionReason: [
'',
Validators.required
] });
}
submit(): void {
@@ -264,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit {
)
)
).subscribe((results) => {
const byClient = new Map(results.map((r) => [r.id, r]));
const byClient = new Map(
results.map((r) => [
r.id,
r
])
);
this.liveAlerts = rows.map((r) => {
const info = byClient.get(r.clientId);
return {
@@ -470,24 +475,24 @@ export class RiskReviewReportComponent implements OnInit {
]
},
{
// Its own section, and above "no change needed", because it is the opposite of that. An unrated customer
// 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.
// Its own section, and above the watching list, 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 give them one, and grouping them with the customers nothing need be done
// about buried the one action the report was asking for.
key: 'unrated',
title: 'Rating needed',
title: 'Update risk',
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
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.
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
key: 'same',
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.',
title: 'Watching — not enough to act on',
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
tone: 'hold',
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
}
+58 -5
View File
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
/**
* What has to have been seen before this screen's own reckoning will argue for lowering a rating.
*
* The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts,
* and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest,
* because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on
* amounts alone let three small deposits in one month argue for taking a customer off Medium.
*/
const MIN_ALERTS_TO_LOWER = 5;
const MIN_MONTHS_TO_LOWER = 3;
export interface DateRange {
from: string;
to: string;
@@ -157,17 +168,26 @@ function recommendRating(
largestAmount: number,
totalAmount: number,
escalated: boolean,
ruleCodes: string[]
ruleCodes: string[],
alertCount: number,
distinctMonths: number
): { risk: RiskLevel; reason: string } {
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so
// bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over
// enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single
// month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only
// three times over.
const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER;
const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// What the rating should be. Amounts only ever pull the rules' bound down, and
// only where nothing weightier holds it up.
let risk: RiskLevel;
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
if (escalated) {
risk = 'HIGH';
basis = 'escalated';
@@ -177,6 +197,12 @@ function recommendRating(
} else if (material) {
risk = implied;
basis = 'material';
} else if (!enoughActivity) {
// Too little observed to say anything about the rating either way, so it stands. Stating this rather than
// quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin,
// not that the customer was examined and found unremarkable.
risk = currentRisk ?? implied;
basis = 'thin';
} else {
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
basis = small ? 'small' : 'moderate';
@@ -220,6 +246,27 @@ function recommendRating(
};
}
if (basis === 'thin') {
// A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling
// them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues
// against it in the same row. The thin-period test is about lowering a rating that exists.
if (!currentRisk) {
return {
risk,
reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${
alertCount === 1 ? '' : 's'
}. Rate the customer; behaviour can only be judged against a rating.`
};
}
// Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether
// the customer needs watching for another month or another five.
const shortOn =
alertCount < MIN_ALERTS_TO_LOWER
? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed`
: `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`;
return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` };
}
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
return {
@@ -345,7 +392,9 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
const raw = windows[clientId];
const staleWindow =
raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
raw?.riskCategoryAtReview != null &&
currentRisk != null &&
RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
const window = staleWindow ? undefined : raw;
const recommendation = window?.suggestedDirection
? {
@@ -358,7 +407,11 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
alerts.map((a) => a.ruleCode),
alerts.length,
// Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one
// week is a busy fortnight, not five months of steady conduct.
new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size
);
// Where the customer carries no rating there is nothing to move, so an
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260918',
'hash': 'e53dfb5a'
'hash': 'c7428d0c'
},
'allow_switching_backend_instance': true
};